├── hosts.json ├── img └── logo.png ├── .gitignore ├── go.mod ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── .env.example ├── README_CN.md ├── .github └── workflows │ └── build.yml ├── pkg └── env │ └── env.go ├── README.md ├── README_RU.md ├── go.sum └── main.go /hosts.json: -------------------------------------------------------------------------------- 1 | { 2 | "docker-hostforuser": "user@host.docker.internal:22" 3 | } -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rand1l/ssh-bot/HEAD/img/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dev environment 2 | .env 3 | 4 | # Build files 5 | bin 6 | ssh-bot* -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rand1l/ssh-bot 2 | 3 | go 1.23.4 4 | 5 | require github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 6 | 7 | require ( 8 | github.com/kr/fs v0.1.0 // indirect 9 | github.com/pkg/sftp v1.13.9 // indirect 10 | golang.org/x/crypto v0.39.0 // indirect 11 | golang.org/x/sys v0.33.0 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ssh-bot: 3 | container_name: ssh-bot 4 | image: lifailon/ssh-bot:latest 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | volumes: 9 | - .env:/ssh-bot/.env 10 | - ${SSH_PRIVATE_KEY_PATH_HOST}:/root/.ssh/id_rsa 11 | - ~/.ssh/known_hosts:/ssh-bot/known_hosts 12 | - ./hosts.json:/ssh-bot/hosts.json 13 | restart: unless-stopped 14 | extra_hosts: 15 | - "host.docker.internal:host-gateway" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build image 2 | FROM golang:1.24-alpine3.22 AS build 3 | WORKDIR /ssh-bot 4 | 5 | # Copy only dependency files 6 | # This allows Docker to cache the dependency layer and avoid downloading them each time, 7 | # if only the source code changes, not go.mod/go.sum. 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | COPY . . 11 | 12 | RUN go mod download 13 | ARG TARGETOS TARGETARCH 14 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /ssh-bot/ssh-bot 15 | 16 | # Final image 17 | FROM alpine:3.22 18 | WORKDIR /ssh-bot 19 | COPY --from=build /ssh-bot/ssh-bot ./ 20 | 21 | ENTRYPOINT ["/ssh-bot/ssh-bot"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 Lifailon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Telegram api key from https://telegram.me/BotFather 2 | TELEGRAM_BOT_TOKEN=XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 3 | # Your Telegram id from https://t.me/getmyid_bot 4 | TELEGRAM_USER_ID=7777777777 5 | 6 | # Interpreter used only when running the bot local in Windows 7 | # Available values: powershell/pwsh 8 | WIN_SHELL=pwsh 9 | # Interpreter used on local and remote hosts in Linux 10 | # Available values: sh/bash/zsh or other 11 | LINUX_SHELL=bash 12 | 13 | # Parallel (async) execution of commands (default: false) 14 | PARALLEL_EXEC=true 15 | 16 | # Global parameters for ssh connection (low priority) 17 | SSH_PORT=22 18 | SSH_USER=rand1l 19 | 20 | # Use password to connect (optional) 21 | SSH_PASSWORD= 22 | 23 | # Path to the private key INSIDE THE CONTAINER. 24 | # This tells the bot where to find the key after it has been mounted. 25 | # IMPORTANT: This path MUST match the right side of the volume mount in docker-compose.yml. 26 | # For this project, it should always be '/root/.ssh/id_rsa'. 27 | # If you leave this empty, the bot will use this default path. 28 | SSH_PRIVATE_KEY_PATH= 29 | SSH_CONNECT_TIMEOUT=2 30 | 31 | # Save and reuse passed variables and functions (default: false) 32 | SSH_SAVE_ENV=true 33 | 34 | # Path to the private key ON YOUR HOST MACHINE. 35 | # This is used by docker-compose to find the key and mount it into the container. 36 | SSH_PRIVATE_KEY_PATH_HOST=~/.ssh/id_rsa 37 | 38 | # Log the output of command execution 39 | LOG_MODE=DEBUG 40 | 41 | # User personal PIN hash 42 | PIN_HASH= 43 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | SSH Bot 5 |
6 |

7 | 8 |

9 | English (🇺🇸) | Russian (🇷🇺) | 中文 (🇨🇳) 10 |

11 | 12 | 一个 Telegram 机器人,允许您在家庭网络中的选定主机上运行指定命令,并实时返回其执行结果。该机器人与远程主机建立持久的 `SSH` 连接,从而可以实时执行长时间运行的命令,支持 `top` 等工具的伪终端(PTY),并能够停止正在运行的进程。内置的 `sftp` 支持可以方便地上传和下载文件。 13 | 14 | 该机器人为您节省了设置 `VPN` 服务器、静态 IP 地址或用于访问本地网络的 `VPS` 所需的时间和金钱。它还消除了在远程设备上使用第三方应用程序(如 `VPN` 和 `ssh` 客户端)的需要,并且不需要稳定的互联网连接。 15 | 16 |
17 | 18 | https://github.com/user-attachments/assets/96fbb55e-62b3-4552-8923-89733e0c5798 19 | 20 |
21 | 22 | ## 命令执行与交互性 23 | 24 | * [x] 在**本地**或**远程**(通过 SSH)环境中执行命令。 25 | * [x] 为长时间运行的命令(例如 `ping`、`tail`)提供**输出流**,并实时更新消息。 26 | * [x] 支持交互式程序(例如 `top`、`htop`)的**伪终端(PTY)**,并提供特殊的屏幕刷新模式(`/tty_refresh`)。 27 | * [x] 能够通过 Telegram 中的按钮**强制停止**正在运行的远程命令。 28 | * [x] 支持并行(异步)命令执行。 29 | * [x] 支持目录导航(`cd`)。 30 | 31 | ----- 32 | 33 | ## 文件管理 (SFTP) 34 | 35 | * [x] **上传文件**到远程服务器(`/upload`)。 36 | * [x] 从远程服务器直接**下载文件**到 Telegram 聊天(`/download`)。 37 | 38 | ----- 39 | 40 | ## SSH 连接管理 41 | 42 | * [x] **动态主机管理器**:添加(`/add_host`)和删除(`/del_host`)服务器,列表保存在 `hosts.json` 文件中。 43 | * [x] 通过**密钥**和/或**交互式密码提示**组合访问主机。 44 | * [x] 首次连接时进行交互式**主机密钥验证**,并能够将新密钥添加到 `known_hosts`。 45 | 46 | ----- 47 | 48 | ## 用户体验与界面 49 | 50 | * [x] 为长命令输出提供**分页**功能,并配有便捷的导航按钮。 51 | * [x] 自动**清除输出中的 ANSI 代码**(颜色),以实现干净可读的显示。 52 | * [x] 为需要交互式终端的命令提供优雅的错误处理。 53 | 54 | ## 启动 55 | 56 | 您可以从 [releases](https://github.com/rand1l/ssh-bot/releases) 页面下载预编译的可执行文件,并在本地运行该机器人。 57 | 58 | > [\!NOTE] 59 | > 启动前,您需要使用 [@BotFather](https://telegram.me/BotFather) 创建您的 Telegram 机器人并获取其 `API Token`,该 Token 必须在配置文件中指定。 60 | 61 | - 创建工作目录: 62 | 63 | 64 | 65 | ```shell 66 | mkdir ssh-bot 67 | cd ssh-bot 68 | ``` 69 | 70 | - 在工作目录中创建并填写 `.env` 文件: 71 | 72 | 73 | 74 | ```shell 75 | # 从 https://telegram.me/BotFather 获取的 Telegram API 密钥 76 | TELEGRAM_BOT_TOKEN=XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 77 | # 从 https://t.me/getmyid_bot 获取的您的 Telegram ID 78 | TELEGRAM_USER_ID=7777777777 79 | 80 | # 仅在 Windows 本地运行机器人时使用的解释器 81 | # 可用值:powershell/pwsh 82 | WIN_SHELL=pwsh 83 | # 在 Linux 本地和远程主机上使用的解释器 84 | # 可用值:sh/bash/zsh 或其他 85 | LINUX_SHELL=bash 86 | 87 | # 并行(异步)执行命令(默认为 false) 88 | PARALLEL_EXEC=true 89 | 90 | # SSH 连接的全局参数(低优先级) 91 | SSH_PORT=22 92 | SSH_USER=rand1l 93 | 94 | # 使用密码连接(可选) 95 | SSH_PASSWORD= 96 | 97 | # 容器内的私钥路径。 98 | # 这告诉机器人在挂载后在哪里找到密钥。 99 | # 重要提示:此路径必须与 docker-compose.yml 中卷挂载的右侧部分匹配。 100 | # 对于本项目,它应始终为 '/root/.ssh/id_rsa'。 101 | # 如果留空,机器人将使用此默认路径。 102 | SSH_PRIVATE_KEY_PATH= 103 | SSH_CONNECT_TIMEOUT=2 104 | 105 | # 保存并重用传递的变量和函数(默认为 false) 106 | SSH_SAVE_ENV=true 107 | 108 | # 您主机上的私钥路径。 109 | # docker-compose 使用此路径找到密钥并将其挂载到容器中。 110 | SSH_PRIVATE_KEY_PATH_HOST=~/.ssh/id_rsa 111 | 112 | # 记录命令执行的输出 113 | LOG_MODE=DEBUG 114 | # 用户个人 PIN 码的哈希值 115 | PIN_HASH= 116 | ``` 117 | 118 | > [\!NOTE] 119 | > 机器人的访问受用户 ID 限制。您可以使用 [@getmyid\_bot](https://t.me/getmyid_bot) 或在向机器人发送消息时在其日志中找到 Telegram `id`。 120 | 121 | - 在容器中运行机器人: 122 | 123 | 124 | 125 | ```shell 126 | docker run -d \ 127 | --name ssh-bot \ 128 | -v ./.env:/ssh-bot/.env \ 129 | -v $HOME/.ssh/id_rsa:/root/.ssh/id_rsa \ 130 | -v $HOME/.ssh/known_hosts:/ssh-bot/known_hosts \ 131 | -v ./hosts.json:/ssh-bot/hosts.json \ 132 | --restart unless-stopped \ 133 | rand1l/ssh-bot:latest 134 | ``` 135 | 136 | > [\!NOTE] 137 | > 机器人环境不存储在镜像中,而是使用挂载机制。要使用密钥访问远程主机,您需要将私钥文件从主机系统转发到容器中(如上例所示),并将 `SSH_PRIVATE_KEY_PATH` 变量的内容留空。 138 | 139 | ## 构建 140 | 141 | ```shell 142 | git clone https://github.com/rand1l/ssh-bot 143 | cd ssh-bot 144 | cp .env.example .env 145 | docker-compose up --build 146 | ``` 147 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | Docker: 7 | description: 'Build and push to Docker Hub' 8 | default: false 9 | type: boolean 10 | Binary: 11 | description: 'Build binary' 12 | default: true 13 | type: boolean 14 | Version: 15 | description: 'Version for binary' 16 | required: true 17 | default: 'latest' 18 | type: string 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Clone main repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Login to Docker Hub 29 | if: ${{ github.event.inputs.Docker == 'true' }} 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKER_PASSWORD }} 34 | 35 | - name: Install Docker Buildx 36 | if: ${{ github.event.inputs.Docker == 'true' }} 37 | uses: docker/setup-buildx-action@v3 38 | with: 39 | driver: docker-container 40 | install: true 41 | 42 | - name: Build and push Docker images for amd64 and arm64 43 | if: ${{ github.event.inputs.Docker == 'true' }} 44 | run: | 45 | # docker build -t lifailon/ssh-bot:latest . 46 | # docker push lifailon/ssh-bot:latest 47 | docker buildx build \ 48 | --platform \ 49 | linux/amd64,linux/arm64 \ 50 | -t lifailon/ssh-bot \ 51 | --push . 52 | continue-on-error: true 53 | 54 | - name: Install Go 55 | if: ${{ github.event.inputs.Binary == 'true' }} 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: 1.23 59 | 60 | - name: Build binaries 61 | if: ${{ github.event.inputs.Binary == 'true' }} 62 | run: | 63 | mkdir -p bin 64 | architectures=("amd64" "arm64") 65 | for arch in "${architectures[@]}"; do 66 | CGO_ENABLED=0 GOOS=windows GOARCH=$arch go build -o bin/ssh-bot-${{ github.event.inputs.Version }}-windows-$arch.exe main.go 67 | CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -o bin/ssh-bot-${{ github.event.inputs.Version }}-linux-$arch main.go 68 | # CGO_ENABLED=0 GOOS=darwin GOARCH=$arch go build -o bin/ssh-bot-${{ github.event.inputs.Version }}-darwin-$arch main.go 69 | done 70 | ls -lh bin 71 | mkdir -p bin/{ssh-bot-linux,ssh-bot-windows,ssh-bot-raspberry-pi} 72 | mv bin/ssh-bot-${{ github.event.inputs.Version }}-windows-amd64.exe bin/ssh-bot-windows/ssh-bot.exe 73 | cp ./.env.example bin/ssh-bot-windows/.env 74 | mv bin/ssh-bot-${{ github.event.inputs.Version }}-linux-amd64 bin/ssh-bot-linux/ssh-bot 75 | cp ./.env.example bin/ssh-bot-linux/.env 76 | mv bin/ssh-bot-${{ github.event.inputs.Version }}-linux-arm64 bin/ssh-bot-raspberry-pi/ssh-bot 77 | cp ./.env.example bin/ssh-bot-raspberry-pi/.env 78 | ls -lha bin/* 79 | tar -cvf bin/ssh-bot-windows.tar -C bin/ssh-bot-windows/ ssh-bot.exe .env 80 | tar -cvf bin/ssh-bot-linux.tar -C bin/ssh-bot-linux/ ssh-bot .env 81 | tar -cvf bin/ssh-bot-raspberry-pi.tar -C bin/ssh-bot-raspberry-pi/ ssh-bot .env 82 | 83 | - name: Upload binaries for Windows 84 | if: ${{ github.event.inputs.Binary == 'true' }} 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: ssh-bot-windows-${{ github.event.inputs.Version }} 88 | path: bin/ssh-bot-windows.tar 89 | - name: Upload binaries for Linux 90 | if: ${{ github.event.inputs.Binary == 'true' }} 91 | uses: actions/upload-artifact@v4 92 | with: 93 | name: ssh-bot-linux-${{ github.event.inputs.Version }} 94 | path: bin/ssh-bot-linux.tar 95 | 96 | - name: Upload binaries for Raspberry Pi 97 | if: ${{ github.event.inputs.Binary == 'true' }} 98 | uses: actions/upload-artifact@v4 99 | with: 100 | name: ssh-bot-raspberry-pi-${{ github.event.inputs.Version }} 101 | path: bin/ssh-bot-raspberry-pi.tar 102 | -------------------------------------------------------------------------------- /pkg/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "runtime" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | type Env struct { 12 | TELEGRAM_BOT_TOKEN string 13 | TELEGRAM_USER_ID int64 14 | WIN_SHELL string 15 | LINUX_SHELL string 16 | PARALLEL_EXEC bool 17 | SSH_PORT string 18 | SSH_USER string 19 | SSH_PASSWORD string 20 | SSH_PRIVATE_KEY_PATH string 21 | SSH_CONNECT_TIMEOUT string 22 | SSH_SAVE_ENV bool 23 | SSH_HOSTS string 24 | LOG_MODE string 25 | SshHostsMap map[string]string 26 | PIN_HASH string 27 | } 28 | 29 | func (env *Env) GetEnv() { 30 | data, err := os.ReadFile(".env") 31 | // Check reading of env file 32 | if err != nil { 33 | // If .env doesn't exist, we just proceed with defaults, no need to crash 34 | if os.IsNotExist(err) { 35 | log.Println("[WARN] .env file not found, using default values and environment variables.") 36 | } else { 37 | log.Fatal(err) 38 | } 39 | } 40 | // Get array strings from file 41 | dataString := strings.TrimSpace(string(data)) 42 | lines := strings.Split(dataString, "\n") 43 | 44 | // Remove comments and lines that do not match key=value 45 | var linesNotComments []string 46 | for _, line := range lines { 47 | if strings.HasPrefix(line, "#") { 48 | continue 49 | } else if strings.Contains(line, "=") { 50 | linesNotComments = append(linesNotComments, line) 51 | } 52 | } 53 | 54 | // Fill the environment 55 | for _, line := range linesNotComments { 56 | parts := strings.SplitN(line, "=", 2) 57 | if len(parts) != 2 { 58 | continue 59 | } 60 | envKey := strings.TrimSpace(parts[0]) 61 | envValue := strings.TrimSpace(parts[1]) 62 | 63 | switch { 64 | case envKey == "TELEGRAM_BOT_TOKEN": 65 | env.TELEGRAM_BOT_TOKEN = strings.TrimSpace(strings.Split(envValue, "#")[0]) 66 | case envKey == "TELEGRAM_USER_ID": 67 | TELEGRAM_USER_ID_STR := strings.TrimSpace(strings.Split(envValue, "#")[0]) 68 | TELEGRAM_USER_ID_INT, _ := strconv.ParseInt(TELEGRAM_USER_ID_STR, 10, 64) 69 | env.TELEGRAM_USER_ID = TELEGRAM_USER_ID_INT 70 | case envKey == "WIN_SHELL": 71 | env.WIN_SHELL = strings.TrimSpace(strings.Split(envValue, "#")[0]) 72 | case envKey == "LINUX_SHELL": 73 | env.LINUX_SHELL = strings.TrimSpace(strings.Split(envValue, "#")[0]) 74 | case envKey == "PARALLEL_EXEC": 75 | checkType := strings.ToLower(strings.TrimSpace(strings.Split(envValue, "#")[0])) 76 | if checkType == "true" { 77 | env.PARALLEL_EXEC = true 78 | } else { 79 | env.PARALLEL_EXEC = false 80 | } 81 | case envKey == "SSH_PORT": 82 | env.SSH_PORT = strings.TrimSpace(strings.Split(envValue, "#")[0]) 83 | case envKey == "SSH_USER": 84 | env.SSH_USER = strings.TrimSpace(strings.Split(envValue, "#")[0]) 85 | case envKey == "SSH_PASSWORD": 86 | env.SSH_PASSWORD = strings.TrimSpace(strings.Split(envValue, "#")[0]) 87 | case envKey == "SSH_PRIVATE_KEY_PATH": 88 | env.SSH_PRIVATE_KEY_PATH = strings.TrimSpace(strings.Split(envValue, "#")[0]) 89 | case envKey == "SSH_CONNECT_TIMEOUT": 90 | env.SSH_CONNECT_TIMEOUT = strings.TrimSpace(strings.Split(envValue, "#")[0]) 91 | case envKey == "SSH_SAVE_ENV": 92 | checkType := strings.ToLower(strings.TrimSpace(strings.Split(envValue, "#")[0])) 93 | if checkType == "true" { 94 | env.SSH_SAVE_ENV = true 95 | } else { 96 | env.SSH_SAVE_ENV = false 97 | } 98 | case envKey == "LOG_MODE": 99 | env.LOG_MODE = strings.TrimSpace(strings.Split(envValue, "#")[0]) 100 | case envKey == "PIN_HASH": 101 | env.PIN_HASH = strings.TrimSpace(strings.Split(envValue, "#")[0]) 102 | } 103 | } 104 | 105 | // Fill the default environment 106 | if len(env.WIN_SHELL) == 0 { 107 | env.WIN_SHELL = "powershell" 108 | } 109 | if len(env.LINUX_SHELL) == 0 { 110 | env.LINUX_SHELL = "sh" 111 | } 112 | if len(env.SSH_PORT) == 0 { 113 | env.SSH_PORT = "22" 114 | } 115 | if len(env.SSH_USER) == 0 { 116 | env.SSH_USER = "root" 117 | } 118 | if len(env.SSH_PRIVATE_KEY_PATH) == 0 { 119 | var envPath string 120 | if runtime.GOOS == "windows" { 121 | envPath = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 122 | } else { 123 | envPath = os.Getenv("HOME") 124 | } 125 | env.SSH_PRIVATE_KEY_PATH = envPath + "/.ssh/id_rsa" 126 | } 127 | if len(env.SSH_CONNECT_TIMEOUT) == 0 { 128 | env.SSH_CONNECT_TIMEOUT = "2" 129 | } 130 | } 131 | 132 | func (env *Env) PrintEnv() { 133 | log.Println() 134 | log.Println("[ENV] TELEGRAM_BOT_TOKEN: " + env.TELEGRAM_BOT_TOKEN) 135 | log.Printf("[ENV] TELEGRAM_USER_ID: %d \n", env.TELEGRAM_USER_ID) 136 | log.Println("[ENV] WIN_SHELL: " + env.WIN_SHELL) 137 | log.Println("[ENV] LINUX_SHELL: " + env.LINUX_SHELL) 138 | log.Printf("[ENV] PARALLEL_EXEC: %t\n", env.PARALLEL_EXEC) 139 | log.Println("[ENV] SSH_PORT: " + env.SSH_PORT) 140 | log.Println("[ENV] SSH_USER: " + env.SSH_USER) 141 | log.Println("[ENV] SSH_PASSWORD: " + env.SSH_PASSWORD) 142 | log.Println("[ENV] SSH_PRIVATE_KEY_PATH: " + env.SSH_PRIVATE_KEY_PATH) 143 | log.Println("[ENV] SSH_CONNECT_TIMEOUT: " + env.SSH_CONNECT_TIMEOUT) 144 | log.Printf("[ENV] SSH_SAVE_ENV: %t\n", env.SSH_SAVE_ENV) 145 | 146 | if env.PIN_HASH != "" { 147 | log.Println("[ENV] PIN_HASH: ") 148 | } else { 149 | log.Println("[ENV] PIN_HASH: ") 150 | } 151 | 152 | log.Println("[ENV] SSH_HOSTS (from hosts.json):") 153 | if len(env.SshHostsMap) > 0 { 154 | for alias, connStr := range env.SshHostsMap { 155 | log.Printf("[ENV] - %s -> %s\n", alias, connStr) 156 | } 157 | } else { 158 | log.Println("[ENV] - No hosts loaded.") 159 | } 160 | log.Println() 161 | } 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | SSH Bot 5 |
6 |

7 | 8 |

9 | English (🇺🇸) | Russian (🇷🇺) | 中文 (🇨🇳) 10 |

11 | 12 | A Telegram bot that allows you to run specified commands on a selected host in your home network and returns the results of their execution in real time. The bot establishes a persistent `SSH` connection with the remote host, which allows for the real-time execution of long-running commands, with pseudo-terminal (PTY) support for utilities like top and the ability to stop running processes. Built-in `sftp` support allows for easy uploading and downloading of files. 13 | 14 | The bot saves you the time and money required to set up a `VPN` server, a static IP address, or a `VPS` for local network access. It also eliminates the need for third-party applications (like `VPN` and `ssh` clients) on a remote device and does not require a stable internet connection. 15 | 16 |
17 | 18 | https://github.com/user-attachments/assets/96fbb55e-62b3-4552-8923-89733e0c5798 19 | 20 |
21 | 22 | ## Command Execution & Interactivity 23 | 24 | * [x] Executing commands in a **local** or **remote** (via SSH) environment. 25 | * [x] **Output streaming** for long-running commands (e.g., `ping`, `tail`) with real-time message updates. 26 | * [x] **Pseudo-terminal (PTY) support** for interactive programs (e.g., `top`, `htop`) with a special screen refresh mode (`/tty_refresh`). 27 | * [x] Ability to **forcibly stop** running remote commands via a button in Telegram. 28 | * [x] Support for parallel (asynchronous) command execution. 29 | * [x] Support for directory navigation (`cd`). 30 | 31 | --- 32 | 33 | ## File Management (SFTP) 34 | 35 | * [x] **Uploading files** to the remote server (`/upload`). 36 | * [x] **Downloading files** from the remote server directly to the Telegram chat (`/download`). 37 | 38 | --- 39 | 40 | ## SSH Connection Management 41 | 42 | * [x] **Dynamic host manager**: adding (`/add_host`) and deleting (`/del_host`) servers with the list persisted in a `hosts.json` file. 43 | * [x] Combined access to hosts via **key** and/or an **interactive password prompt**. 44 | * [x] Interactive **host key verification** on the first connection, with the ability to add new keys to `known_hosts`. 45 | 46 | --- 47 | 48 | ## User Experience & Interface 49 | 50 | * [x] **Pagination** for long command outputs with convenient navigation buttons. 51 | * [x] Automatic **cleaning of output from ANSI codes** (colors) for a clean and readable display. 52 | * [x] Graceful error handling for commands that require an interactive terminal. 53 | 54 | 55 | 56 | ## Launch 57 | 58 | You can download the pre-compiled executable from the [releases](https://github.com/rand1l/ssh-bot/releases) page and run the bot locally. 59 | 60 | > [!NOTE] 61 | > Before launching, you need to create your Telegram bot using [@BotFather](https://telegram.me/BotFather) and get its `API Token`, which must be specified in the configuration file. 62 | 63 | - Create a working directory: 64 | 65 | ```shell 66 | mkdir ssh-bot 67 | cd ssh-bot 68 | ``` 69 | 70 | - Create and fill the `.env` file file inside the working directory: 71 | 72 | ```shell 73 | # Telegram api key from https://telegram.me/BotFather 74 | TELEGRAM_BOT_TOKEN=XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 75 | # Your Telegram id from https://t.me/getmyid_bot 76 | TELEGRAM_USER_ID=7777777777 77 | 78 | # Interpreter used only when running the bot local in Windows 79 | # Available values: powershell/pwsh 80 | WIN_SHELL=pwsh 81 | # Interpreter used on local and remote hosts in Linux 82 | # Available values: sh/bash/zsh or other 83 | LINUX_SHELL=bash 84 | 85 | # Parallel (async) execution of commands (default: false) 86 | PARALLEL_EXEC=true 87 | 88 | # Global parameters for ssh connection (low priority) 89 | SSH_PORT=22 90 | SSH_USER=rand1l 91 | 92 | # Use password to connect (optional) 93 | SSH_PASSWORD= 94 | 95 | # Path to the private key INSIDE THE CONTAINER. 96 | # This tells the bot where to find the key after it has been mounted. 97 | # IMPORTANT: This path MUST match the right side of the volume mount in docker-compose.yml. 98 | # For this project, it should always be '/root/.ssh/id_rsa'. 99 | # If you leave this empty, the bot will use this default path. 100 | SSH_PRIVATE_KEY_PATH= 101 | SSH_CONNECT_TIMEOUT=2 102 | 103 | # Save and reuse passed variables and functions (default: false) 104 | SSH_SAVE_ENV=true 105 | 106 | # Path to the private key ON YOUR HOST MACHINE. 107 | # This is used by docker-compose to find the key and mount it into the container. 108 | SSH_PRIVATE_KEY_PATH_HOST=~/.ssh/id_rsa 109 | 110 | # Log the output of command execution 111 | LOG_MODE=DEBUG 112 | # User personal PIN hash 113 | PIN_HASH= 114 | ``` 115 | 116 | > [!NOTE] 117 | > Access to the bot is limited by user ID. You can find out the Telegram `id` using [@getmyid_bot](https://t.me/getmyid_bot) or in the bot logs when sending a message to it. 118 | 119 | - Run the bot in a container: 120 | 121 | ```shell 122 | docker run -d \ 123 | --name ssh-bot \ 124 | -v ./.env:/ssh-bot/.env \ 125 | -v $HOME/.ssh/id_rsa:/root/.ssh/id_rsa \ 126 | -v $HOME/.ssh/known_hosts:/ssh-bot/known_hosts \ 127 | -v ./hosts.json:/ssh-bot/hosts.json \ 128 | --restart unless-stopped \ 129 | rand1l/ssh-bot:latest 130 | ``` 131 | 132 | > [!NOTE] 133 | > The bot environment is not stored in an image, but uses a mounting mechanism. To access remote hosts using a key, you need to forward the private key file from the host system to the container (as in the example above) and leave the contents of the `SSH_PRIVATE_KEY_PATH` variable empty. 134 | 135 | ## Build 136 | 137 | ```shell 138 | git clone https://github.com/rand1l/ssh-bot 139 | cd ssh-bot 140 | cp .env.example .env 141 | docker-compose up --build 142 | ``` 143 | -------------------------------------------------------------------------------- /README_RU.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | SSH Bot 5 |
6 |

7 | 8 |

9 | English (🇺🇸) | Русский (🇷🇺) | 中文 (🇨🇳) 10 |

11 | 12 | Telegram бот, который позволяет запускать заданные команды на выбранном хосте в домашней сети и возвращать результат их выполнения в реальном времени. Бот устанавливает постоянное ssh-соеденение с удаленным хостом, что позволяет в реальном времени исполнять длинные команды, с поддержкой псевдо-терминала (PTY) для таких утилит как top и возможностью останавливать запущенные процессы. Встроенная поддержка `sftp` позволяет легко загружать и скачивать файлы. 13 | 14 | 15 | Бот предоставляет возможность не тратить время на настройку `VPN` сервера и деньги на внешний ip-адрес или `VPS` сервер для доступа к локальной сети, а также избавляет от необходимости использования сторонних приложений (`VPN` и `ssh` клиентов) на удаленном устройстве и не требует стабильного Интернет соединения. 16 | 17 |
18 | 19 | https://github.com/user-attachments/assets/96fbb55e-62b3-4552-8923-89733e0c5798 20 | 21 |
22 | 23 | ## Roadmap 24 | 25 | ## Выполнение команд и интерактивность 26 | 27 | * [x] Выполнение команд в **локальной** или **удалённой** (через SSH) среде. 28 | * [x] **Потоковый вывод** для долгих команд (например, `ping`, `tail`) с обновлением сообщений в реальном времени. 29 | * [x] Поддержка **псевдотерминала (PTY)** для интерактивных программ (например, `top`, `htop`) со специальным режимом обновления экрана (`/tty_refresh`). 30 | * [x] Возможность **принудительной остановки** запущенных удалённых команд через кнопку в Telegram. 31 | * [x] Поддержка параллельного (асинхронного) выполнения команд. 32 | * [x] Поддержка навигации по директориям (`cd`). 33 | 34 | --- 35 | 36 | ## Управление файлами (SFTP) 37 | 38 | * [x] **Загрузка файлов** на удалённый сервер (`/upload`). 39 | * [x] **Скачивание файлов** с удалённого сервера напрямую в чат Telegram (`/download`). 40 | 41 | --- 42 | 43 | ## Управление SSH-подключениями 44 | 45 | * [x] **Динамический менеджер хостов**: добавление (`/add_host`) и удаление (`/del_host`) серверов с сохранением списка в файле `hosts.json`. 46 | * [x] Комбинированный доступ к хостам через **ключ** и/или **интерактивный ввод пароля**. 47 | * [x] Интерактивная **проверка ключа хоста** при первом подключении с возможностью добавления новых ключей в `known_hosts`. 48 | 49 | --- 50 | 51 | ## Интерфейс 52 | 53 | * [x] **Пагинация** для длинных выводов команд с удобными кнопками навигации. 54 | * [x] Автоматическая **очистка вывода от ANSI-кодов** (цветов) для чистого и читаемого отображения. 55 | * [x] Аккуратная обработка ошибок для команд, требующих интерактивного терминала. 56 | 57 | ## Запуск 58 | 59 | Вы можете загрузить предварительно скомпилированный исполняемый файл на странице [релизов](https://github.com/rand1l/ssh-bot/releases) и запустить бота локально. 60 | 61 | > [!NOTE] 62 | > Перед запуском, необходимо создать своего Telegram бота, используя [@BotFather](https://telegram.me/BotFather) и получить его `API Token`, который необходимо указать в конфигурационном файле. 63 | 64 | - Создайте рабочий каталог: 65 | 66 | ```shell 67 | mkdir ssh-bot 68 | cd ssh-bot 69 | ``` 70 | 71 | - Создайте и заполните `.env` файл внутри рабочего каталога: 72 | 73 | ```shell 74 | ```env 75 | # Ключ API Telegram из https://telegram.me/BotFather 76 | TELEGRAM_BOT_TOKEN=XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 77 | # Ваш Telegram ID из https://t.me/getmyid_bot 78 | TELEGRAM_USER_ID=7777777777 79 | 80 | # Интерпретатор используется только при запуске бота локально в Windows 81 | # Доступные значения: powershell/pwsh 82 | WIN_SHELL=pwsh 83 | # Интерпретатор, используемый на локальных и удалённых хостах в Linux 84 | # Доступные значения: sh/bash/zsh или другие 85 | LINUX_SHELL=bash 86 | 87 | # Параллельное (асинхронное) выполнение команд (по умолчанию: false) 88 | PARALLEL_EXEC=true 89 | 90 | # Глобальные параметры SSH-подключения (низкий приоритет) 91 | SSH_PORT=22 92 | SSH_USER=rand1l 93 | 94 | # Использовать пароль для подключения (опционально) 95 | SSH_PASSWORD= 96 | 97 | # Путь к приватному ключу ВНУТРИ КОНТЕЙНЕРА. 98 | # Указывает боту, где искать ключ после того, как он был смонтирован. 99 | # ВАЖНО: этот путь ДОЛЖЕН совпадать с правой частью volume mount в docker-compose.yml. 100 | # Для этого проекта он всегда должен быть '/root/.ssh/id_rsa'. 101 | # Если оставить пустым, бот будет использовать этот путь по умолчанию. 102 | SSH_PRIVATE_KEY_PATH= 103 | SSH_CONNECT_TIMEOUT=2 104 | 105 | # Сохранять и повторно использовать переданные переменные и функции (по умолчанию: false) 106 | SSH_SAVE_ENV=true 107 | 108 | # Путь к приватному ключу НА ВАШЕЙ ХОСТ-МАШИНЕ. 109 | # Используется docker-compose для поиска ключа и монтирования его в контейнер. 110 | SSH_PRIVATE_KEY_PATH_HOST=~/.ssh/id_rsa 111 | 112 | # Логирование вывода выполнения команд 113 | LOG_MODE=DEBUG 114 | 115 | # переменная хеша для пинкода 116 | PIN_HASH= 117 | ``` 118 | 119 | > [!NOTE] 120 | > Доступ к боту ограничен идентификатором пользователя. Вы можете узнать Telegram `id`, используя [@getmyid_bot](https://t.me/getmyid_bot) или в логах бота, при отправки ему сообщения. 121 | 122 | - Запустите бота в контейнере: 123 | 124 | ```shell 125 | docker run -d \ 126 | --name ssh-bot \ 127 | -v ./.env:/ssh-bot/.env \ 128 | -v $HOME/.ssh/id_rsa:/root/.ssh/id_rsa \ 129 | -v $HOME/.ssh/known_hosts:/ssh-bot/known_hosts \ 130 | -v ./hosts.json:/ssh-bot/hosts.json \ 131 | --restart unless-stopped \ 132 | rand1l/ssh-bot:latest 133 | ``` 134 | 135 | > [!NOTE] 136 | > Окружение бота не хранится в образе, вместо этого используется механизм монтирования. Для доступа к удалённым хостам с помощью ключа необходимо пробросить файл закрытого ключа из хост-системы в контейнер (как в примере выше) и оставить содержимое переменной `SSH_PRIVATE_KEY_PATH` пустым. 137 | 138 | ## Сборка 139 | 140 | ```shell 141 | git clone https://github.com/rand1l/ssh-bot 142 | cd ssh-bot 143 | cp .env.example .env 144 | docker-compose up -d --build 145 | ``` 146 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= 4 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= 5 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 7 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 8 | github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= 9 | github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 12 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 13 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 14 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 15 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 16 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 17 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 18 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 19 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 20 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 21 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 22 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 23 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 24 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 25 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 26 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 27 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 28 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 29 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 30 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 31 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 32 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 33 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 34 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 35 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 36 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 37 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 38 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 39 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 41 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 42 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 43 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 44 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 45 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 53 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 54 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 55 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 56 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 57 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 58 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 59 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 60 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 61 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 62 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 63 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 64 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 65 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 66 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 67 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 68 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 69 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 70 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 71 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 72 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 73 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 74 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 75 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 76 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 77 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 78 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 79 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 80 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 81 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 82 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 83 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "os/exec" 14 | "regexp" 15 | "runtime" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "time" 20 | 21 | "golang.org/x/crypto/bcrypt" 22 | 23 | env "github.com/rand1l/ssh-bot/pkg/env" 24 | 25 | api "github.com/go-telegram-bot-api/telegram-bot-api/v5" 26 | "github.com/pkg/sftp" 27 | sshClient "golang.org/x/crypto/ssh" 28 | "golang.org/x/crypto/ssh/knownhosts" 29 | ) 30 | 31 | const autoLockInactivityDuration = 15 * time.Minute 32 | 33 | const hostsFilePath = "/ssh-bot/hosts.json" 34 | 35 | // Used to strip ANSI escape codes (e.g., color codes, cursor movements) 36 | // from the command output to get clean text for Telegram. 37 | var ansiRegex = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*((([a-zA-Z\\d]*(;[a-zA-Z\\d]*)*)?\u0007)|((\\d{1,4}(;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))") 38 | 39 | type PagedMessage struct { 40 | Pages []string // Slice with prepared pages 41 | Header string // Message header (status + host) 42 | CurrentPage int // Current page for paginated output 43 | Follow bool // Whether to follow the output (auto-scroll) 44 | } 45 | 46 | func isNumeric(s string) bool { 47 | _, err := strconv.Atoi(s) 48 | return err == nil 49 | } 50 | 51 | // updateEnvFile updates a key-value pair in the .env file. 52 | // Creates the file if it doesn't exist. 53 | func updateEnvFile(envFilePath, key, value string) error { 54 | input, err := os.ReadFile(envFilePath) 55 | if err != nil && !os.IsNotExist(err) { 56 | return err 57 | } 58 | 59 | lines := strings.Split(string(input), "\n") 60 | keyExists := false 61 | newLines := []string{} 62 | 63 | // Filter out empty lines and update the key if it exists 64 | for _, line := range lines { 65 | if line != "" { 66 | if strings.HasPrefix(line, key+"=") { 67 | newLines = append(newLines, key+"="+value) 68 | keyExists = true 69 | } else { 70 | newLines = append(newLines, line) 71 | } 72 | } 73 | } 74 | 75 | if !keyExists { 76 | newLines = append(newLines, key+"="+value) 77 | } 78 | 79 | output := strings.Join(newLines, "\n") 80 | // Check if the file ends with a newline, add one if not 81 | if !strings.HasSuffix(output, "\n") { 82 | output += "\n" 83 | } 84 | 85 | return os.WriteFile(envFilePath, []byte(output), 0644) 86 | } 87 | 88 | 89 | const pageLen = 4000 90 | 91 | // SSH holds the state and client for a persistent SSH connection. 92 | type SSH struct { 93 | Pwd string 94 | SSHMode bool 95 | SSHHost string 96 | SSHUser string 97 | SSHPort string 98 | SSHPrivateKey []byte 99 | Client *sshClient.Client 100 | } 101 | 102 | const maxFileSize = 48 * 1024 * 1024 // 48MB 103 | 104 | 105 | // maxOutputBufferSize defines the maximum size of the command output buffer in bytes (1024 KB). 106 | // This prevents a single command from consuming excessive memory and overwhelming the Telegram API. 107 | const maxOutputBufferSize = 1024 * 1024 108 | 109 | 110 | type BotServer struct { 111 | bot *api.BotAPI 112 | env *env.Env 113 | ssh *SSH 114 | 115 | pinHash string 116 | isAuthenticated bool 117 | 118 | lastActivityTime time.Time 119 | autoLockMutex *sync.RWMutex 120 | 121 | pendingHostKeyVerifications map[int64]chan bool 122 | pendingPasswordRequests map[int64]chan string 123 | 124 | // maps a message ID to a running SSH session. 125 | // used to stop long-running commands via a callback button. 126 | activeSessions map[int]*sshClient.Session 127 | activeSessionsMutex *sync.Mutex 128 | 129 | 130 | // stores the destination path for a file upload that was initiated 131 | // by the /upload command. The key is the chat ID. 132 | // It is protected by a mutex to handle concurrent messages safely.var 133 | pendingUploads map[int64]string 134 | pendingUploadsMutex *sync.Mutex 135 | 136 | pagedMessages map[int]PagedMessage 137 | pagedMessagesMutex *sync.Mutex 138 | 139 | 140 | sshHosts map[string]string 141 | sshHostsMutex *sync.RWMutex 142 | } 143 | 144 | func (s *BotServer) autoLockChecker() { 145 | 146 | ticker := time.NewTicker(1 * time.Second) 147 | defer ticker.Stop() 148 | 149 | for range ticker.C { 150 | s.autoLockMutex.RLock() 151 | isAuthenticated := s.isAuthenticated 152 | lastActivity := s.lastActivityTime 153 | pinIsSet := s.pinHash != "" 154 | s.autoLockMutex.RUnlock() 155 | 156 | // Start the auto-lock countdown only if a PIN is set and the user is currently authenticated 157 | if pinIsSet && isAuthenticated { 158 | if time.Since(lastActivity) > autoLockInactivityDuration { 159 | s.autoLockMutex.Lock() 160 | // We re-check isAuthenticated in case the user locked the bot 161 | if s.isAuthenticated { 162 | s.isAuthenticated = false 163 | log.Println("[INFO] Bot has been auto-locked due to inactivity.") 164 | 165 | msg := api.NewMessage(s.env.TELEGRAM_USER_ID, "🔒 The bot is automatically locked after 15 minutes of inactivity.") 166 | s.bot.Send(msg) 167 | } 168 | s.autoLockMutex.Unlock() 169 | } 170 | } 171 | } 172 | } 173 | 174 | func (s *BotServer) handleAddHost(chatID int64, text string) { 175 | parts := strings.Fields(text) 176 | if len(parts) != 3 { 177 | s.bot.Send(api.NewMessage(chatID, "Usage: `/add_host `")) 178 | return 179 | } 180 | alias := parts[1] 181 | connectionString := parts[2] 182 | 183 | s.sshHostsMutex.Lock() 184 | defer s.sshHostsMutex.Unlock() 185 | 186 | s.sshHosts[alias] = connectionString 187 | 188 | // Save the updated list to a file 189 | data, err := json.MarshalIndent(s.sshHosts, "", " ") 190 | if err != nil { 191 | log.Printf("[ERROR] Failed to marshal hosts to JSON: %v", err) 192 | s.bot.Send(api.NewMessage(chatID, "❌ Error saving host list.")) 193 | return 194 | } 195 | 196 | err = os.WriteFile(hostsFilePath, data, 0644) 197 | if err != nil { 198 | log.Printf("[ERROR] Failed to write to hosts.json: %v", err) 199 | s.bot.Send(api.NewMessage(chatID, "❌ Error saving host list to file.")) 200 | return 201 | } 202 | 203 | s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("✅ Host '%s' was added.", alias))) 204 | log.Printf("[INFO] Host '%s' added.", alias) 205 | } 206 | 207 | func (s *BotServer) handleDelHost(chatID int64, text string) { 208 | parts := strings.Fields(text) 209 | if len(parts) != 2 { 210 | s.bot.Send(api.NewMessage(chatID, "Usage: `/del_host `")) 211 | return 212 | } 213 | alias := parts[1] 214 | 215 | s.sshHostsMutex.Lock() 216 | defer s.sshHostsMutex.Unlock() 217 | 218 | if _, ok := s.sshHosts[alias]; !ok { 219 | s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("❌ Host '%s' not found.", alias))) 220 | return 221 | } 222 | 223 | delete(s.sshHosts, alias) 224 | 225 | // Save the updated list to a file 226 | data, err := json.MarshalIndent(s.sshHosts, "", " ") 227 | if err != nil { 228 | log.Printf("[ERROR] Failed to marshal hosts to JSON: %v", err) 229 | s.bot.Send(api.NewMessage(chatID, "❌ Error saving host list.")) 230 | return 231 | } 232 | 233 | err = os.WriteFile(hostsFilePath, data, 0644) 234 | if err != nil { 235 | log.Printf("[ERROR] Failed to write to hosts.json: %v", err) 236 | s.bot.Send(api.NewMessage(chatID, "❌ Error saving host list to file.")) 237 | return 238 | } 239 | 240 | s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("✅ Host '%s' was deleted.", alias))) 241 | log.Printf("[INFO] Host '%s' deleted.", alias) 242 | } 243 | 244 | // Handles downloading a file from the server to Telegram 245 | func (s *BotServer) handleFileDownload(chatID int64, remotePath string) { 246 | if !s.ssh.SSHMode || s.ssh.Client == nil { 247 | s.bot.Send(api.NewMessage(chatID, "Error: An active SSH connection is required to download files.")) 248 | return 249 | } 250 | 251 | // Send a placeholder message 252 | placeholder, _ := s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("⏳ Downloading `%s`...", remotePath))) 253 | 254 | // Create an SFTP client 255 | sftpClient, err := sftp.NewClient(s.ssh.Client) 256 | if err != nil { 257 | errorMsg := fmt.Sprintf("❌ Error creating SFTP client: %v", err) 258 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 259 | log.Printf("[ERROR] SFTP client creation failed: %v", err) 260 | return 261 | } 262 | defer sftpClient.Close() 263 | 264 | // Check file size before reading 265 | stat, err := sftpClient.Stat(remotePath) 266 | if err != nil { 267 | errorMsg := fmt.Sprintf("❌ Failed to get file info for `%s`: %v", remotePath, err) 268 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 269 | return 270 | } 271 | if stat.Size() > maxFileSize { 272 | errorMsg := fmt.Sprintf("❌ File is too large: %.2f MB. Limit: %.2f MB.", float64(stat.Size())/1024/1024, float64(maxFileSize)/1024/1024) 273 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 274 | return 275 | } 276 | if stat.IsDir() { 277 | errorMsg := fmt.Sprintf("❌ The specified path `%s` is a directory, not a file.", remotePath) 278 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 279 | return 280 | } 281 | 282 | // Open the remote file 283 | remoteFile, err := sftpClient.Open(remotePath) 284 | if err != nil { 285 | errorMsg := fmt.Sprintf("❌ Failed to open remote file `%s`: %v", remotePath, err) 286 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 287 | log.Printf("[ERROR] Remote file open failed: %v", err) 288 | return 289 | } 290 | defer remoteFile.Close() 291 | 292 | // Read the file content 293 | fileBytes, err := io.ReadAll(remoteFile) 294 | if err != nil { 295 | errorMsg := fmt.Sprintf("❌ Error reading file `%s`: %v", remotePath, err) 296 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 297 | log.Printf("[ERROR] File read failed: %v", err) 298 | return 299 | } 300 | 301 | // Send the document to Telegram 302 | doc := api.FileBytes{ 303 | Name: stat.Name(), 304 | Bytes: fileBytes, 305 | } 306 | msg := api.NewDocument(chatID, doc) 307 | msg.Caption = fmt.Sprintf("✅ File `%s` downloaded successfully.", remotePath) 308 | msg.ParseMode = api.ModeMarkdown 309 | 310 | s.bot.Send(msg) 311 | // Delete the placeholder 312 | s.bot.Request(api.NewDeleteMessage(chatID, placeholder.MessageID)) 313 | log.Printf("[INFO] File downloaded successfully: %s", remotePath) 314 | } 315 | 316 | // Manages the process of receiving a document from Telegram 317 | // and uploading it to the remote server via SFTP. It requires a pending 318 | // upload state to be set by the /upload command first. 319 | func (s *BotServer) handleFileUpload(update api.Update) { 320 | chatID := update.Message.Chat.ID 321 | doc := update.Message.Document 322 | 323 | // Check for a pending upload path from the /upload command 324 | s.pendingUploadsMutex.Lock() 325 | remotePath, isPending := s.pendingUploads[chatID] 326 | if isPending { 327 | // Clear the pending state immediately to prevent re-uploading the next file 328 | delete(s.pendingUploads, chatID) 329 | } 330 | s.pendingUploadsMutex.Unlock() 331 | 332 | // If no upload was initiated with the /upload command, reject the file. 333 | if !isPending { 334 | s.bot.Send(api.NewMessage(chatID, "❌ Error: To upload a file, you must first specify the destination path with the command:\n`/upload /path/to/destination`\nand attach the file in a subsequent message.")) 335 | return 336 | } 337 | 338 | // General checks 339 | if !s.ssh.SSHMode || s.ssh.Client == nil { 340 | s.bot.Send(api.NewMessage(chatID, "Error: An active SSH connection is required to upload files.")) 341 | return 342 | } 343 | if doc.FileSize > maxFileSize { 344 | s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("❌ File is too large: %.2f MB. Limit: %.2f MB.", float64(doc.FileSize)/1024/1024, float64(maxFileSize)/1024/1024))) 345 | return 346 | } 347 | 348 | placeholder, _ := s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("⏳ Uploading file to server at `%s`...", remotePath))) 349 | 350 | // Get a direct URL for the file 351 | fileURL, err := s.bot.GetFileDirectURL(doc.FileID) 352 | if err != nil { 353 | errorMsg := fmt.Sprintf("❌ Failed to get file link: %v", err) 354 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 355 | log.Printf("[ERROR] Failed to get file URL: %v", err) 356 | return 357 | } 358 | 359 | // Download the file from Telegram's servers 360 | resp, err := http.Get(fileURL) 361 | if err != nil { 362 | errorMsg := fmt.Sprintf("❌ Failed to download file from Telegram servers: %v", err) 363 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 364 | log.Printf("[ERROR] Failed to download file from Telegram: %v", err) 365 | return 366 | } 367 | defer resp.Body.Close() 368 | 369 | // Create an SFTP client 370 | sftpClient, err := sftp.NewClient(s.ssh.Client) 371 | if err != nil { 372 | errorMsg := fmt.Sprintf("❌ Error creating SFTP client: %v", err) 373 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 374 | log.Printf("[ERROR] SFTP client creation failed: %v", err) 375 | return 376 | } 377 | defer sftpClient.Close() 378 | 379 | // Create the file on the remote server 380 | dstFile, err := sftpClient.Create(remotePath) 381 | if err != nil { 382 | errorMsg := fmt.Sprintf("❌ Failed to create file on server at `%s`: %v", remotePath, err) 383 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 384 | log.Printf("[ERROR] Failed to create remote file: %v", err) 385 | return 386 | } 387 | defer dstFile.Close() 388 | 389 | // Copy the content to the remote file 390 | bytesCopied, err := io.Copy(dstFile, resp.Body) 391 | if err != nil { 392 | errorMsg := fmt.Sprintf("❌ Error writing data to file `%s`: %v", remotePath, err) 393 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, errorMsg)) 394 | log.Printf("[ERROR] Failed to write to remote file: %v", err) 395 | return 396 | } 397 | 398 | successMsg := fmt.Sprintf("✅ File `%s` (%.2f MB) uploaded successfully.", remotePath, float64(bytesCopied)/1024/1024) 399 | s.bot.Request(api.NewEditMessageText(chatID, placeholder.MessageID, successMsg)) 400 | log.Printf("[INFO] File uploaded successfully to %s", remotePath) 401 | } 402 | 403 | // Get parameters for ssh connection from env 404 | func (ssh *SSH) paramParse(host string, env *env.Env) (string, string, string) { 405 | var userName, port string 406 | if strings.Contains(host, "@") { 407 | hostSplit := strings.Split(host, "@") 408 | userName = hostSplit[0] 409 | host = hostSplit[1] 410 | } else { 411 | userName = env.SSH_USER 412 | } 413 | if strings.Contains(host, ":") { 414 | hostSplit := strings.Split(host, ":") 415 | port = hostSplit[1] 416 | host = hostSplit[0] 417 | } else { 418 | port = env.SSH_PORT 419 | } 420 | return host, userName, port 421 | } 422 | 423 | // Change directory on localhost 424 | func (s *BotServer) localChangeDir(chatID int64, message string) { 425 | newPath := strings.TrimSpace(message[3:]) 426 | err := os.Chdir(newPath) 427 | if err != nil { 428 | msg := api.NewMessage(chatID, "⚠ Error changing directory:\n\n```Go\n"+err.Error()+"```") 429 | msg.ParseMode = api.ModeMarkdown 430 | s.bot.Send(msg) 431 | log.Printf("[ERROR] Error changing directory: %s", err.Error()) 432 | return 433 | } 434 | pwd, _ := os.Getwd() 435 | msg := api.NewMessage(chatID, "Current directory:\n\n`"+pwd+"`") 436 | msg.ParseMode = api.ModeMarkdown 437 | s.bot.Send(msg) 438 | log.Printf("[INFO] Current directory: %s", pwd) 439 | } 440 | 441 | // Establishes a persistent SSH connection to a remote host. 442 | // It handles key-based authentication, interactive password prompts, and host key verification. 443 | func (s *BotServer) sshConnect(chatID int64) error { 444 | // If a client is already connected, close the old connection first. 445 | if s.ssh.Client != nil { 446 | s.ssh.Client.Close() 447 | s.ssh.Client = nil 448 | } 449 | 450 | knownHostsPath := "/ssh-bot/known_hosts" 451 | 452 | // Initialize the known_hosts callback from the file. 453 | hostKeyCallback, err := knownhosts.New(knownHostsPath) 454 | if err != nil { 455 | log.Printf("[ERROR] Could not load known_hosts file: %v", err) 456 | return fmt.Errorf("could not load known_hosts file: %w", err) 457 | } 458 | 459 | // This is our custom host key verification logic. 460 | // It's a closure to capture the 'bot' and 'chatID' variables. 461 | var customHostKeyCallback sshClient.HostKeyCallback = func(hostname string, remote net.Addr, key sshClient.PublicKey) error { 462 | // First, check if the key is already in our known_hosts file. 463 | err := hostKeyCallback(hostname, remote, key) 464 | 465 | // Case 1: Key is known and matches. No error. 466 | if err == nil { 467 | log.Printf("[INFO] Host key for %s is known and matches.", hostname) 468 | return nil 469 | } 470 | 471 | // An error occurred. Let's find out what kind. 472 | var keyErr *knownhosts.KeyError 473 | if errors.As(err, &keyErr) { 474 | // Case 3: A key for this host is known, but it DOES NOT MATCH. 475 | // This is a potential man-in-the-middle attack. Abort 476 | if len(keyErr.Want) > 0 { 477 | log.Printf("[CRITICAL] HOST KEY MISMATCH for %s! Aborting connection.", hostname) 478 | return fmt.Errorf("host key mismatch: possible man-in-the-middle attack") 479 | } 480 | // Case 2: The key is simply NOT FOUND in the file. This is normal for a first-time connection. 481 | // We proceed to ask the user for confirmation. 482 | } else { 483 | // This is some other unexpected error (e.g., file permissions). Abort. 484 | log.Printf("[ERROR] Unexpected error during host key verification: %v", err) 485 | return err 486 | } 487 | 488 | // Ask the user for verification. 489 | log.Printf("[WARN] Unknown host key for %s. Asking user for verification.", hostname) 490 | 491 | decisionChan := make(chan bool) 492 | s.pendingHostKeyVerifications[chatID] = decisionChan 493 | 494 | fingerprint := sshClient.FingerprintSHA256(key) 495 | messageText := fmt.Sprintf( 496 | "⚠️ The authenticity of host '%s' can't be established.\n\n"+ 497 | "Key fingerprint is `%s`.\n\n"+ 498 | "Are you sure you want to continue connecting?", 499 | hostname, 500 | fingerprint, 501 | ) 502 | 503 | yesButton := api.NewInlineKeyboardButtonData("✅ Yes", "verify_host_yes") 504 | noButton := api.NewInlineKeyboardButtonData("❌ No", "verify_host_no") 505 | keyboard := api.NewInlineKeyboardMarkup(api.NewInlineKeyboardRow(yesButton, noButton)) 506 | msg := api.NewMessage(chatID, messageText) 507 | msg.ParseMode = api.ModeMarkdown 508 | msg.ReplyMarkup = &keyboard 509 | s.bot.Send(msg) 510 | 511 | // Block execution until we get a response from the user 512 | decision := <-decisionChan 513 | delete(s.pendingHostKeyVerifications, chatID) 514 | 515 | if !decision { 516 | return fmt.Errorf("host key verification rejected by user") 517 | } 518 | 519 | // User agreed. Add the new key to our known_hosts file 520 | f, ferr := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 521 | if ferr != nil { 522 | return fmt.Errorf("could not open known_hosts to add new key: %w", ferr) 523 | } 524 | defer f.Close() 525 | 526 | // Ensure the file ends with a newline before appending a new key 527 | stat, _ := f.Stat() 528 | if stat.Size() > 0 { 529 | buf := make([]byte, 1) 530 | f.ReadAt(buf, stat.Size()-1) 531 | if buf[0] != '\n' { 532 | f.WriteString("\n") 533 | } 534 | } 535 | 536 | // Append the new key in the correct format 537 | newLine := knownhosts.Line([]string{hostname}, key) 538 | if _, ferr = f.WriteString(newLine + "\n"); ferr != nil { 539 | return fmt.Errorf("could not write new key to known_hosts: %w", ferr) 540 | } 541 | log.Printf("[INFO] Added new host key for %s to known_hosts.", hostname) 542 | return nil 543 | } 544 | 545 | // handles password prompts from the SSH server 546 | // by asking the user for input in Telegram. 547 | keyboardInteractiveChallenge := func(user, instruction string, questions []string, echos []bool) ([]string, error) { 548 | if len(questions) == 0 { 549 | return nil, nil 550 | } 551 | 552 | passwordChan := make(chan string) 553 | s.pendingPasswordRequests[chatID] = passwordChan 554 | 555 | prompt := fmt.Sprintf("Enter password for `%s@%s`:", user, s.ssh.SSHHost) 556 | if len(questions) > 0 { 557 | prompt = fmt.Sprintf("%s\n\n```\n%s\n```", prompt, strings.Join(questions, "\n")) 558 | } 559 | msg := api.NewMessage(chatID, prompt) 560 | msg.ParseMode = api.ModeMarkdown 561 | s.bot.Send(msg) 562 | 563 | // Block and wait for the user to type the password 564 | password := <-passwordChan 565 | delete(s.pendingPasswordRequests, chatID) 566 | 567 | return []string{password}, nil 568 | } 569 | 570 | signer, err := sshClient.ParsePrivateKey(s.ssh.SSHPrivateKey) 571 | if err != nil { 572 | log.Printf("[ERROR] Could not parse private key: %v", err) 573 | return fmt.Errorf("could not parse private key: %w", err) 574 | } 575 | 576 | timeoutSeconds, _ := strconv.Atoi(s.env.SSH_CONNECT_TIMEOUT) 577 | timeoutDuration := time.Duration(timeoutSeconds) * time.Second 578 | 579 | // Assemble the final SSH client configuration. 580 | config := &sshClient.ClientConfig{ 581 | User: s.ssh.SSHUser, 582 | HostKeyCallback: customHostKeyCallback, 583 | Timeout: timeoutDuration, 584 | Auth: []sshClient.AuthMethod{ 585 | sshClient.PublicKeys(signer), // 1. Try public key authentication first. 586 | sshClient.KeyboardInteractive(keyboardInteractiveChallenge), // 2. Try interactive password prompt. 587 | sshClient.Password(s.env.SSH_PASSWORD), // 3. Try non-interactive password from .env. 588 | }, 589 | } 590 | 591 | log.Printf("[DEBUG] Dialing tcp to %s:%s...", s.ssh.SSHHost, s.ssh.SSHPort) 592 | client, err := sshClient.Dial("tcp", fmt.Sprintf("%s:%s", s.ssh.SSHHost, s.ssh.SSHPort), config) 593 | if err != nil { 594 | log.Printf("[ERROR] Failed to dial: %v", err) 595 | return fmt.Errorf("failed to dial: %w", err) 596 | } 597 | log.Println("[DEBUG] TCP connection established.") 598 | 599 | s.ssh.Client = client 600 | return nil 601 | } 602 | 603 | // Executes a command and returns the output without streaming. 604 | // It's used for internal commands like 'pwd' or 'uname'. 605 | func (ssh *SSH) sshRunSimpleCommand(command string) ([]byte, error) { 606 | if ssh.Client == nil { 607 | return nil, errors.New("ssh client is not connected") 608 | } 609 | 610 | session, err := ssh.Client.NewSession() 611 | if err != nil { 612 | log.Printf("[ERROR] Failed to create session for simple command: %v", err) 613 | return nil, fmt.Errorf("failed to create session: %w", err) 614 | } 615 | defer session.Close() 616 | 617 | // Use CombinedOutput to get both stdout and stderr 618 | output, err := session.CombinedOutput(command) 619 | if err != nil { 620 | log.Printf("[ERROR] Simple command failed: %v. Output: %s", err, string(output)) 621 | return output, err 622 | } 623 | 624 | return output, nil 625 | } 626 | 627 | // Creates the inline keyboard with "Up", "Page X/Y", and "Down" buttons 628 | // It disables buttons by replacing them with a blank placeholder when at the start or end of the output 629 | func getPaginationKeyboard(messageID int, currentPage int, totalPages int) *api.InlineKeyboardMarkup { 630 | var row []api.InlineKeyboardButton 631 | 632 | if currentPage > 0 { 633 | row = append(row, api.NewInlineKeyboardButtonData("⬆️ Up", fmt.Sprintf("page_up_%d", messageID))) 634 | } else { 635 | row = append(row, api.NewInlineKeyboardButtonData(" ", "noop")) 636 | } 637 | 638 | pageIndicator := api.NewInlineKeyboardButtonData(fmt.Sprintf("%d / %d", currentPage+1, totalPages), "noop") 639 | row = append(row, pageIndicator) 640 | 641 | if currentPage < totalPages-1 { 642 | row = append(row, api.NewInlineKeyboardButtonData("⬇️ Down", fmt.Sprintf("page_down_%d", messageID))) 643 | } else { 644 | row = append(row, api.NewInlineKeyboardButtonData(" ", "noop")) 645 | } 646 | 647 | keyboard := api.NewInlineKeyboardMarkup(row) 648 | return &keyboard 649 | } 650 | 651 | // buildPages breaks large text into pages, preserving line integrity. 652 | func buildPages(fullText string, maxPageLen int) []string { 653 | lines := strings.Split(fullText, "\n") 654 | if len(lines) == 1 && len(lines[0]) == 0 { 655 | return []string{""} // Return one empty page if there is no output 656 | } 657 | 658 | var pages []string 659 | var pageBuilder strings.Builder 660 | 661 | for _, line := range lines { 662 | // Check if adding a newline will exceed the page limit 663 | // +1 is needed for the line break character '\n' 664 | if pageBuilder.Len()+len(line)+1 > maxPageLen { 665 | // The current page is full, save it 666 | pages = append(pages, pageBuilder.String()) 667 | pageBuilder.Reset() 668 | } 669 | 670 | pageBuilder.WriteString(line) 671 | pageBuilder.WriteString("\n") 672 | } 673 | 674 | // Add the last, unfilled page, if there is one 675 | if pageBuilder.Len() > 0 { 676 | pages = append(pages, pageBuilder.String()) 677 | } 678 | 679 | // If after all operations there are no pages (for example, the input string was empty) 680 | if len(pages) == 0 { 681 | return []string{""} 682 | } 683 | 684 | return pages 685 | } 686 | 687 | func (s *BotServer) sendOrEditFinalMessage(chatID int64, messageID int, header string, cleanedOutput string) { 688 | // Clean up any previous pagination state for this message to prevent memory leaks 689 | s.pagedMessagesMutex.Lock() 690 | delete(s.pagedMessages, messageID) 691 | s.pagedMessagesMutex.Unlock() 692 | 693 | pages := buildPages(cleanedOutput, pageLen) 694 | 695 | 696 | if len(cleanedOutput) > pageLen { 697 | // Long output: set up a new pagination state. 698 | s.pagedMessagesMutex.Lock() 699 | s.pagedMessages[messageID] = PagedMessage{ 700 | Pages: pages, 701 | Header: header, 702 | CurrentPage: 0, 703 | Follow: false, 704 | } 705 | s.pagedMessagesMutex.Unlock() 706 | 707 | firstPageContent := pages[0] 708 | 709 | keyboard := getPaginationKeyboard(messageID, 0, len(pages)) 710 | 711 | finalText := header + "```sh\n" + firstPageContent + "```" 712 | finalEdit := api.NewEditMessageText(chatID, messageID, finalText) 713 | finalEdit.ParseMode = api.ModeMarkdown 714 | finalEdit.ReplyMarkup = keyboard 715 | s.bot.Request(finalEdit) 716 | 717 | } else { 718 | // Short output. Send as is and remove any previous buttons (like "Stop"). 719 | finalText := header 720 | if len(cleanedOutput) > 0 { 721 | finalText += "```sh\n" + cleanedOutput + "```" 722 | } 723 | 724 | finalEdit := api.NewEditMessageText(chatID, messageID, finalText) 725 | finalEdit.ParseMode = api.ModeMarkdown 726 | finalEdit.ReplyMarkup = &api.InlineKeyboardMarkup{InlineKeyboard: [][]api.InlineKeyboardButton{}} 727 | s.bot.Request(finalEdit) 728 | } 729 | } 730 | 731 | func max(a, b int) int { 732 | if a > b { 733 | return a 734 | } 735 | return b 736 | } 737 | 738 | // runCommand is the core function for executing commands, now acting as a dispatcher. 739 | func (s *BotServer) runCommand(chatID int64, messageText string, requestPTY bool, isRefreshMode bool) { 740 | if s.ssh.SSHMode { 741 | s.runSSHCommand(chatID, messageText, requestPTY, isRefreshMode) 742 | } else { 743 | s.runLocalCommand(chatID, messageText) 744 | } 745 | } 746 | 747 | // runLocalCommand handles the execution of commands on the local machine where the bot is running. 748 | func (s *BotServer) runLocalCommand(chatID int64, messageText string) { 749 | var SHELL string 750 | if runtime.GOOS == "windows" { 751 | SHELL = s.env.WIN_SHELL 752 | } else { 753 | SHELL = s.env.LINUX_SHELL 754 | } 755 | output, err := exec.Command(SHELL, "-c", messageText).CombinedOutput() 756 | 757 | const maxLen = 200 758 | var outputStr string 759 | if len(output) > maxLen { 760 | outputStr = "... (output truncated)\n" + string(output[len(output)-maxLen:]) 761 | } else { 762 | outputStr = string(output) 763 | } 764 | 765 | header := "`localhost`\n" 766 | var finalMessage string 767 | if err != nil { 768 | finalMessage = "⚠ " + header 769 | } else { 770 | finalMessage = "✅ " + header 771 | } 772 | msg := api.NewMessage(chatID, finalMessage+"```sh\n"+outputStr+"```") 773 | msg.ParseMode = api.ModeMarkdown 774 | s.bot.Send(msg) 775 | 776 | if err != nil { 777 | log.Printf("[ERROR] Local execution error: %v. Output: %s", err, string(output)) 778 | } 779 | } 780 | 781 | // runSSHCommand handles the execution of commands over an active SSH connection. 782 | func (s *BotServer) runSSHCommand(chatID int64, messageText string, requestPTY bool, isRefreshMode bool) { 783 | command := "cd " + s.ssh.Pwd + " && " + messageText 784 | 785 | placeholderText := fmt.Sprintf("▶️ Executing on `%s`...", s.ssh.SSHHost) 786 | msg, err := s.bot.Send(api.NewMessage(chatID, placeholderText)) 787 | if err != nil { 788 | log.Printf("[ERROR] Failed to send initial message: %v", err) 789 | return 790 | } 791 | messageID := msg.MessageID 792 | 793 | if s.ssh.Client == nil { 794 | editMsg := api.NewEditMessageText(chatID, messageID, "⚠ Error: SSH client is not connected.") 795 | editMsg.ParseMode = api.ModeMarkdown 796 | s.bot.Request(editMsg) 797 | return 798 | } 799 | session, err := s.ssh.Client.NewSession() 800 | if err != nil { 801 | editMsg := api.NewEditMessageText(chatID, messageID, fmt.Sprintf("⚠ Error: Failed to create session: %v", err)) 802 | editMsg.ParseMode = api.ModeMarkdown 803 | s.bot.Request(editMsg) 804 | return 805 | } 806 | defer session.Close() 807 | 808 | // Request a pseudo-terminal (PTY) if the user specified /tty or /tty_refresh. 809 | // This is necessary for interactive programs like 'top' or for getting colored output. 810 | if requestPTY { 811 | log.Println("[INFO] PTY requested by user.") 812 | modes := sshClient.TerminalModes{ 813 | sshClient.ECHO: 0, 814 | sshClient.TTY_OP_ISPEED: 14400, 815 | sshClient.TTY_OP_OSPEED: 14400, 816 | } 817 | if err := session.RequestPty("xterm", 1000, 150, modes); err != nil { 818 | log.Printf("[ERROR] Request for PTY failed: %v", err) 819 | editMsg := api.NewEditMessageText(chatID, messageID, fmt.Sprintf("⚠ Error: Could not request PTY: %v", err)) 820 | editMsg.ParseMode = api.ModeMarkdown 821 | s.bot.Request(editMsg) 822 | return 823 | } 824 | } 825 | 826 | stdoutPipe, _ := session.StdoutPipe() 827 | stderrPipe, _ := session.StderrPipe() 828 | multiReader := io.MultiReader(stdoutPipe, stderrPipe) 829 | session.Start(command) 830 | 831 | done := make(chan error) 832 | go func() { 833 | done <- session.Wait() 834 | }() 835 | 836 | 837 | // Race the command completion against a short timeout. 838 | // This allows us to handle short commands instantly without entering a slow streaming mode. 839 | shortTimeout := time.After(400 * time.Millisecond) 840 | 841 | // A buffer to accumulate the command's output. 842 | var outputBuffer bytes.Buffer 843 | go io.Copy(&outputBuffer, multiReader) 844 | 845 | // For refresh mode, the final output is just the last screen state. 846 | // This prevents saving the entire command history (e.g., every frame of 'top'). 847 | const clearScreenCode = "\x1b[2J" 848 | const cursorHomeCode = "\x1b[H" 849 | 850 | // Race the command completion against a short timeout. 851 | // If the command finishes before the timeout, we treat it as a "quick command" 852 | // and send the full output at once. 853 | // If the timeout wins, we switch to "streaming mode" for a long-running command. 854 | select { 855 | case err := <-done: 856 | log.Printf("[INFO] Command finished quickly.") 857 | cleanedOutput := ansiRegex.ReplaceAllString(outputBuffer.String(), "") 858 | 859 | finalHeader := "`" + s.ssh.SSHHost + "`\n" 860 | var finalStatus string 861 | if err != nil { 862 | finalStatus = "⚠ " 863 | } else { 864 | finalStatus = "✅ " 865 | } 866 | fullHeader := finalStatus + finalHeader 867 | s.sendOrEditFinalMessage(chatID, messageID, fullHeader, cleanedOutput) 868 | 869 | if err != nil { 870 | log.Printf("[ERROR] Simple execution failed: %v. Output: %s", err, outputBuffer.String()) 871 | } 872 | return 873 | 874 | case <-shortTimeout: // Command is long-running, switch to streaming mode 875 | log.Printf("[INFO] Command is long-running. Mode: Refresh=%v", isRefreshMode) 876 | 877 | s.activeSessionsMutex.Lock() 878 | s.activeSessions[messageID] = session 879 | s.activeSessionsMutex.Unlock() 880 | defer func() { 881 | s.activeSessionsMutex.Lock() 882 | delete(s.activeSessions, messageID) 883 | s.activeSessionsMutex.Unlock() 884 | }() 885 | 886 | s.pagedMessagesMutex.Lock() 887 | header := fmt.Sprintf("▶️ Streaming on `%s`:", s.ssh.SSHHost) 888 | s.pagedMessages[messageID] = PagedMessage{ 889 | Pages: []string{}, 890 | Header: header, 891 | CurrentPage: 0, 892 | Follow: false, // Does not start in auto-scroll mode by default. 893 | } 894 | s.pagedMessagesMutex.Unlock() 895 | 896 | ticker := time.NewTicker(1000 * time.Millisecond) 897 | defer ticker.Stop() 898 | 899 | var lastText string 900 | 901 | for { 902 | select { 903 | case err := <-done: // Command finished, send final output 904 | var finalOutput string 905 | fullBufferStr := outputBuffer.String() 906 | 907 | if isRefreshMode && len(fullBufferStr) > 0 { 908 | // For refresh mode, the final output is just the last screen state. 909 | // This prevents saving the entire command history (e.g., every frame of 'top'). 910 | lastClear := strings.LastIndex(fullBufferStr, clearScreenCode) 911 | lastHome := strings.LastIndex(fullBufferStr, cursorHomeCode) 912 | splitIndex := max(lastClear, lastHome) 913 | var lastFrame string 914 | if splitIndex != -1 { 915 | lastFrame = fullBufferStr[splitIndex:] 916 | } else { 917 | lastFrame = fullBufferStr // Fallback if no clear codes are found. 918 | } 919 | finalOutput = ansiRegex.ReplaceAllString(lastFrame, "") 920 | } else { 921 | // For normal streaming commands (like ping), the final output is the entire history. 922 | finalOutput = ansiRegex.ReplaceAllString(fullBufferStr, "") 923 | } 924 | 925 | finalHeader := "`" + s.ssh.SSHHost + "`\n" 926 | var finalStatus string 927 | if err != nil { 928 | finalStatus = "⚠ " 929 | } else { 930 | finalStatus = "✅ " 931 | } 932 | fullHeader := finalStatus + finalHeader 933 | s.sendOrEditFinalMessage(chatID, messageID, fullHeader, finalOutput) 934 | 935 | if err != nil { 936 | log.Printf("[ERROR] Streaming execution finished with error: %v", err) 937 | } 938 | return 939 | 940 | case <-ticker.C: // Command is long-running, switch to streaming mode. Ticker for periodic updates. 941 | if outputBuffer.Len() > maxOutputBufferSize { 942 | outputBuffer.Next(outputBuffer.Len() - maxOutputBufferSize) 943 | } 944 | 945 | s.pagedMessagesMutex.Lock() 946 | state, ok := s.pagedMessages[messageID] 947 | if !ok { 948 | s.pagedMessagesMutex.Unlock() 949 | continue 950 | } 951 | 952 | fullBufferStr := outputBuffer.String() 953 | var currentScreenContent string 954 | 955 | if isRefreshMode { 956 | lastClear := strings.LastIndex(fullBufferStr, clearScreenCode) 957 | lastHome := strings.LastIndex(fullBufferStr, cursorHomeCode) 958 | splitIndex := max(lastClear, lastHome) 959 | if splitIndex != -1 { 960 | currentScreenContent = fullBufferStr[splitIndex:] 961 | } else { 962 | currentScreenContent = fullBufferStr 963 | } 964 | } else { 965 | currentScreenContent = fullBufferStr 966 | } 967 | 968 | cleanedOutput := ansiRegex.ReplaceAllString(currentScreenContent, "") 969 | pages := buildPages(cleanedOutput, pageLen) 970 | 971 | currentPageIndex := state.CurrentPage 972 | if state.Follow { 973 | currentPageIndex = len(pages) - 1 // If follow mode, always show the last one 974 | } 975 | if currentPageIndex < 0 { 976 | currentPageIndex = 0 977 | } 978 | if currentPageIndex >= len(pages) { 979 | currentPageIndex = len(pages) - 1 980 | } 981 | 982 | state.Pages = pages 983 | state.CurrentPage = currentPageIndex 984 | s.pagedMessages[messageID] = state 985 | 986 | pageContent := pages[currentPageIndex] 987 | newText := state.Header + "\n```sh\n" + pageContent + "```" 988 | 989 | s.pagedMessagesMutex.Unlock() 990 | 991 | if newText == lastText { 992 | continue 993 | } 994 | lastText = newText 995 | 996 | stopButton := api.NewInlineKeyboardButtonData("⏹️ Stop", fmt.Sprintf("stop_cmd_%d", messageID)) 997 | var followButton api.InlineKeyboardButton 998 | if state.Follow { 999 | followButton = api.NewInlineKeyboardButtonData("📜 Unfollow", fmt.Sprintf("follow_off_%d", messageID)) 1000 | } else { 1001 | followButton = api.NewInlineKeyboardButtonData("📜 Follow", fmt.Sprintf("follow_on_%d", messageID)) 1002 | } 1003 | 1004 | totalPages := len(state.Pages) 1005 | currentPage := state.CurrentPage 1006 | 1007 | paginationKeyboard := getPaginationKeyboard(messageID, currentPage, totalPages) 1008 | combinedKeyboard := api.NewInlineKeyboardMarkup( 1009 | paginationKeyboard.InlineKeyboard[0], 1010 | api.NewInlineKeyboardRow(stopButton, followButton), 1011 | ) 1012 | editMsg := api.NewEditMessageText(chatID, messageID, newText) 1013 | editMsg.ParseMode = api.ModeMarkdown 1014 | editMsg.ReplyMarkup = &combinedKeyboard 1015 | if _, err := s.bot.Request(editMsg); err != nil { 1016 | log.Printf("[ERROR] Failed to edit message during streaming (ID: %d): %v", messageID, err) 1017 | 1018 | errStr := err.Error() 1019 | 1020 | if strings.Contains(errStr, "message to edit not found") || strings.Contains(errStr, "message can't be edited") { 1021 | log.Printf("[INFO] Stopping stream for message %d as it can no longer be edited.", messageID) 1022 | s.activeSessionsMutex.Lock() 1023 | if session, ok := s.activeSessions[messageID]; ok { 1024 | session.Signal(sshClient.SIGKILL) 1025 | } 1026 | s.activeSessionsMutex.Unlock() 1027 | return 1028 | } 1029 | 1030 | if strings.Contains(errStr, "Too Many Requests") { 1031 | re := regexp.MustCompile(`retry after (\d+)`) 1032 | matches := re.FindStringSubmatch(errStr) 1033 | 1034 | if len(matches) > 1 { 1035 | retryAfter, _ := strconv.Atoi(matches[1]) 1036 | 1037 | log.Printf("[WARN] Rate limit hit for message %d. Pausing for %d seconds.", messageID, retryAfter) 1038 | 1039 | s.pagedMessagesMutex.Lock() 1040 | if state, ok := s.pagedMessages[messageID]; ok { 1041 | if !strings.HasPrefix(state.Header, "⏳") { 1042 | state.Header = "⏳ " + state.Header 1043 | s.pagedMessages[messageID] = state 1044 | throttledEdit := api.NewEditMessageText(chatID, messageID, state.Header+"\n```sh\n"+lastText+"```") 1045 | throttledEdit.ParseMode = api.ModeMarkdown 1046 | s.bot.Request(throttledEdit) 1047 | } 1048 | } 1049 | s.pagedMessagesMutex.Unlock() 1050 | 1051 | time.Sleep(time.Duration(retryAfter) * time.Second) 1052 | 1053 | s.pagedMessagesMutex.Lock() 1054 | if state, ok := s.pagedMessages[messageID]; ok { 1055 | state.Header = strings.TrimPrefix(state.Header, "⏳ ") 1056 | s.pagedMessages[messageID] = state 1057 | } 1058 | s.pagedMessagesMutex.Unlock() 1059 | 1060 | continue 1061 | } 1062 | } 1063 | } 1064 | } 1065 | } 1066 | } 1067 | } 1068 | 1069 | func (s *BotServer) handleUpdate(update api.Update) { 1070 | switch { 1071 | case update.Message != nil: 1072 | s.handleMessage(update) 1073 | case update.CallbackQuery != nil: 1074 | s.handleCallbackQuery(update.CallbackQuery) 1075 | } 1076 | } 1077 | 1078 | func (s *BotServer) handleMessage(update api.Update) { 1079 | chatID := update.Message.Chat.ID 1080 | 1081 | // Access check 1082 | if chatID != s.env.TELEGRAM_USER_ID { 1083 | s.bot.Send(api.NewMessage(chatID, "⛔ Access denied ⛔")) 1084 | log.Printf("[WARN] Unauthorized access from %s %s (%s - %d)", update.Message.From.FirstName, update.Message.From.LastName, update.Message.From.UserName, chatID) 1085 | return 1086 | } 1087 | 1088 | 1089 | s.autoLockMutex.Lock() 1090 | s.lastActivityTime = time.Now() 1091 | s.autoLockMutex.Unlock() 1092 | 1093 | 1094 | if s.pinHash != "" && !s.isAuthenticated { 1095 | // Acess /set_pin command even when locked 1096 | if strings.HasPrefix(update.Message.Text, "/set_pin") { 1097 | s.handleSetPinCommand(chatID, update.Message.Text) 1098 | 1099 | s.bot.Request(api.NewDeleteMessage(chatID, update.Message.MessageID)) 1100 | return 1101 | } 1102 | 1103 | pinAttempt := update.Message.Text 1104 | 1105 | defer s.bot.Request(api.NewDeleteMessage(chatID, update.Message.MessageID)) 1106 | 1107 | if len(pinAttempt) != 4 || !isNumeric(pinAttempt) { 1108 | msg, _ := s.bot.Send(api.NewMessage(chatID, "🔒 Bot locked. Please enter your 4-digit PIN.")) 1109 | // Delete message with hint after several seconds 1110 | go func() { 1111 | time.Sleep(5 * time.Second) 1112 | s.bot.Request(api.NewDeleteMessage(chatID, msg.MessageID)) 1113 | }() 1114 | return 1115 | } 1116 | 1117 | err := bcrypt.CompareHashAndPassword([]byte(s.pinHash), []byte(pinAttempt)) 1118 | if err == nil { 1119 | s.isAuthenticated = true 1120 | msg := api.NewMessage(chatID, "✅ Access allowed.") 1121 | s.bot.Send(msg) 1122 | } else { 1123 | msg, _ := s.bot.Send(api.NewMessage(chatID, "❌ Incorrect PIN code.")) 1124 | 1125 | go func() { 1126 | time.Sleep(3 * time.Second) 1127 | s.bot.Request(api.NewDeleteMessage(chatID, msg.MessageID)) 1128 | }() 1129 | } 1130 | return // Stop further processing until authenticated 1131 | } 1132 | 1133 | if passwordChan, ok := s.pendingPasswordRequests[chatID]; ok { 1134 | passwordChan <- update.Message.Text 1135 | s.bot.Request(api.NewDeleteMessage(chatID, update.Message.MessageID)) 1136 | confirmMsg := api.NewMessage(chatID, " `Password received, authenticating...`") 1137 | confirmMsg.ParseMode = api.ModeMarkdown 1138 | s.bot.Send(confirmMsg) 1139 | return 1140 | } 1141 | 1142 | if update.Message.Document != nil { 1143 | s.handleFileUpload(update) 1144 | return 1145 | } 1146 | 1147 | messageText := update.Message.Text 1148 | log.Printf("[INFO] Request from %d: %s", chatID, messageText) 1149 | 1150 | switch { 1151 | case strings.HasPrefix(messageText, "/set_pin"): 1152 | s.handleSetPinCommand(chatID, messageText) 1153 | s.bot.Request(api.NewDeleteMessage(chatID, update.Message.MessageID)) 1154 | case messageText == "/lock": 1155 | s.handleLockCommand(chatID) 1156 | case strings.HasPrefix(messageText, "/upload"): 1157 | s.handleUploadCommand(chatID, messageText) 1158 | case strings.HasPrefix(messageText, "/download "): 1159 | s.handleDownloadCommand(chatID, messageText) 1160 | case strings.HasPrefix(messageText, "/add_host"): 1161 | s.handleAddHost(chatID, messageText) 1162 | case strings.HasPrefix(messageText, "/del_host"): 1163 | s.handleDelHost(chatID, messageText) 1164 | case messageText == "/exit" || messageText == "exit": 1165 | s.handleExitCommand(chatID) 1166 | case messageText == "/host_list": 1167 | s.handleHostListCommand(chatID) 1168 | 1169 | case messageText == "/start": 1170 | welcomeMessage := `👋 **Welcome!** 1171 | 1172 | This is an SSH bot for managing servers. It's currently in **local mode**, executing commands on the same host where the bot is running. 1173 | 1174 | To connect to a remote host, use the ` + "`/ssh user@hostname`" + ` command or select a host from the list: ` + "`/host_list`" + `. 1175 | 1176 | Please be mindful of Telegram's API limits. Running multiple commands in the background, especially those that generate frequent output (like ` + "`ping` or `top`" + `), can cause the bot to be temporarily rate-limited by Telegram. This will result in delayed responses or errors until the timeout period passes.` 1177 | 1178 | msg := api.NewMessage(chatID, welcomeMessage) 1179 | msg.ParseMode = api.ModeMarkdown 1180 | s.bot.Send(msg) 1181 | case strings.HasPrefix(messageText, "/ssh"): 1182 | // launch the entire connection logic in a separate goroutine 1183 | // to avoid blocking the main loop while waiting for user input (host key/password). 1184 | go s.handleSSHCommand(chatID, messageText) 1185 | case strings.HasPrefix(messageText, "cd "): 1186 | s.handleChangeDirCommand(chatID, messageText) 1187 | default: 1188 | // Run command for execution 1189 | s.handleGenericCommand(chatID, messageText) 1190 | } 1191 | } 1192 | 1193 | func (s *BotServer) handleCallbackQuery(callback *api.CallbackQuery) { 1194 | chatID := callback.Message.Chat.ID 1195 | data := callback.Data 1196 | 1197 | if chatID != s.env.TELEGRAM_USER_ID { 1198 | s.bot.Send(api.NewMessage(chatID, "⛔ Access denied ⛔")) 1199 | return 1200 | } 1201 | 1202 | s.autoLockMutex.Lock() 1203 | s.lastActivityTime = time.Now() 1204 | s.autoLockMutex.Unlock() 1205 | 1206 | 1207 | s.autoLockMutex.RLock() 1208 | isAuthenticated := s.isAuthenticated 1209 | pinIsSet := s.pinHash != "" 1210 | s.autoLockMutex.RUnlock() 1211 | 1212 | if pinIsSet && !isAuthenticated { 1213 | callbackAlert := api.NewCallback(callback.ID, "🔒 The bot is locked. Please enter your PIN first.") 1214 | s.bot.Request(callbackAlert) 1215 | return 1216 | } 1217 | 1218 | switch { 1219 | case data == "noop": 1220 | s.bot.Request(api.NewCallback(callback.ID, "")) 1221 | case strings.HasPrefix(data, "page_"): 1222 | s.handlePaginationCallback(callback) 1223 | case strings.HasPrefix(data, "stop_cmd_"): 1224 | s.handleStopCallback(callback) 1225 | case strings.HasPrefix(data, "follow_"): 1226 | s.handleFollowCallback(callback) 1227 | case strings.HasPrefix(data, "verify_host_"): 1228 | s.handleHostVerificationCallback(callback) 1229 | default: 1230 | if strings.HasPrefix(data, "/ssh") { 1231 | go s.handleSSHCommand(chatID, data) 1232 | } 1233 | s.bot.Request(api.NewCallback(callback.ID, "")) 1234 | } 1235 | } 1236 | 1237 | func (s *BotServer) handleUploadCommand(chatID int64, text string) { 1238 | remotePath := strings.TrimSpace(strings.TrimPrefix(text, "/upload")) 1239 | if remotePath == "" { 1240 | s.bot.Send(api.NewMessage(chatID, "To upload a file, specify the path:\n`/upload /path/to/save`\n\nOr just send the file with the path in the signature.")) 1241 | } else { 1242 | s.pendingUploadsMutex.Lock() 1243 | s.pendingUploads[chatID] = remotePath 1244 | s.pendingUploadsMutex.Unlock() 1245 | s.bot.Send(api.NewMessage(chatID, fmt.Sprintf("Path `%s` is set. Now send me the file to upload.", remotePath))) 1246 | } 1247 | } 1248 | 1249 | func (s *BotServer) handleDownloadCommand(chatID int64, text string) { 1250 | remotePath := strings.TrimSpace(strings.TrimPrefix(text, "/download")) 1251 | if remotePath == "" { 1252 | s.bot.Send(api.NewMessage(chatID, "Please specify the path to the file. Example: `/download /etc/hosts`")) 1253 | } else { 1254 | s.handleFileDownload(chatID, remotePath) 1255 | } 1256 | } 1257 | 1258 | func (s *BotServer) handleExitCommand(chatID int64) { 1259 | // Disconnect from ssh and clear declared environment (remove temp file) 1260 | if s.ssh.SSHMode { 1261 | if s.env.SSH_SAVE_ENV { 1262 | s.ssh.sshRunSimpleCommand("rm /tmp/ssh-bot.temp") 1263 | } 1264 | if s.ssh.Client != nil { 1265 | s.ssh.Client.Close() 1266 | s.ssh.Client = nil 1267 | } 1268 | s.ssh.SSHMode = false 1269 | messageOutput := api.NewMessage(chatID, "Disconnected from `"+s.ssh.SSHHost+"`") 1270 | messageOutput.ParseMode = api.ModeMarkdown 1271 | s.bot.Send(messageOutput) 1272 | log.Println("[INFO] Disconnected from " + s.ssh.SSHHost) 1273 | } else { 1274 | s.bot.Send(api.NewMessage(chatID, "Remote connection not established")) 1275 | log.Println("[INFO] Remote connection not established") 1276 | } 1277 | } 1278 | 1279 | func (s *BotServer) handleHostListCommand(chatID int64) { 1280 | s.sshHostsMutex.RLock() 1281 | defer s.sshHostsMutex.RUnlock() 1282 | if len(s.sshHosts) == 0 { 1283 | s.bot.Send(api.NewMessage(chatID, "No hosts configured. Add one with `/add_host`.")) 1284 | return 1285 | } 1286 | 1287 | var keyboardButton [][]api.InlineKeyboardButton 1288 | var messageBuilder strings.Builder 1289 | messageBuilder.WriteString("Available hosts:\n\n") 1290 | for alias, connStr := range s.sshHosts { 1291 | messageBuilder.WriteString(fmt.Sprintf("• **%s** -> `%s`\n", alias, connStr)) 1292 | btn := api.NewInlineKeyboardButtonData(alias, "/ssh "+connStr) 1293 | keyboardButton = append(keyboardButton, []api.InlineKeyboardButton{btn}) 1294 | } 1295 | keyboard := api.NewInlineKeyboardMarkup(keyboardButton...) 1296 | msg := api.NewMessage(chatID, messageBuilder.String()) 1297 | msg.ParseMode = api.ModeMarkdown 1298 | msg.ReplyMarkup = &keyboard 1299 | s.bot.Send(msg) 1300 | } 1301 | 1302 | func (s *BotServer) handleSSHCommand(chatID int64, messageText string) { 1303 | // Switch to selected host via ssh 1304 | selectedHost := strings.TrimSpace(strings.Replace(messageText, "/ssh", "", 1)) 1305 | if len(selectedHost) == 0 { 1306 | messageOutput := api.NewMessage(chatID, "Host name not specified\n\nPass the host name as a parameter, for example: `/ssh user@192.168.1.1`") 1307 | messageOutput.ParseMode = api.ModeMarkdown 1308 | s.bot.Send(messageOutput) 1309 | log.Println("[ERROR] Host name not specified") 1310 | return 1311 | } 1312 | 1313 | // Parse connection parameters first 1314 | s.ssh.SSHHost, s.ssh.SSHUser, s.ssh.SSHPort = s.ssh.paramParse(selectedHost, s.env) 1315 | 1316 | // Send a temporary "Connecting..." message to the user 1317 | sendMessage, _ := s.bot.Send(api.NewMessage(chatID, "Connecting to "+selectedHost+"...")) 1318 | lastMessageID := sendMessage.MessageID 1319 | log.Println("[INFO] Connection to " + selectedHost) 1320 | 1321 | // Call our new dedicated connection function 1322 | err := s.sshConnect(chatID) 1323 | 1324 | // Check for connection errors 1325 | if err != nil { 1326 | s.ssh.SSHMode = false 1327 | detailedError := err.Error() 1328 | msgText := "⚠ Connection error to " + selectedHost + "\n\n" + "```Error\n" + detailedError + "```" 1329 | editMessage := api.NewEditMessageText(chatID, lastMessageID, msgText) 1330 | editMessage.ParseMode = api.ModeMarkdown 1331 | s.bot.Send(editMessage) 1332 | log.Println("[ERROR] Connection error: " + detailedError) 1333 | return 1334 | } 1335 | 1336 | // Connection Successful 1337 | s.ssh.SSHMode = true 1338 | log.Println("[INFO] Connection successful to " + selectedHost) 1339 | 1340 | // Get system info to display to the user 1341 | output, err := s.ssh.sshRunSimpleCommand("uname -a") 1342 | if err != nil { 1343 | log.Printf("[WARN] Failed to run 'uname -a' after connect: %v", err) 1344 | } 1345 | 1346 | // Update the temporary message with the success status and system info 1347 | msgText := "✅ Connection successful to " + selectedHost + "\n\n" + "```Info\n" + string(output) + "```" 1348 | editMessage := api.NewEditMessageText(chatID, lastMessageID, msgText) 1349 | editMessage.ParseMode = api.ModeMarkdown 1350 | s.bot.Send(editMessage) 1351 | 1352 | // Get the initial present working directory (pwd) 1353 | output, err = s.ssh.sshRunSimpleCommand("pwd") 1354 | if err != nil { 1355 | log.Printf("[WARN] Failed to run 'pwd' after connect: %v", err) 1356 | } 1357 | s.ssh.Pwd = strings.TrimSpace(string(output)) 1358 | } 1359 | 1360 | func (s *BotServer) handleSetPinCommand(chatID int64, text string) { 1361 | parts := strings.Fields(text) 1362 | if len(parts) != 2 { 1363 | s.bot.Send(api.NewMessage(chatID, "Usage: `/set_pin 1234`")) 1364 | return 1365 | } 1366 | newPin := parts[1] 1367 | 1368 | if len(newPin) != 4 || !isNumeric(newPin) { 1369 | s.bot.Send(api.NewMessage(chatID, "❌ Error: The PIN code must be exactly 4 digits long.")) 1370 | return 1371 | } 1372 | 1373 | hashedPin, err := bcrypt.GenerateFromPassword([]byte(newPin), bcrypt.DefaultCost) 1374 | if err != nil { 1375 | log.Printf("[ERROR] Failed to hash PIN: %v", err) 1376 | s.bot.Send(api.NewMessage(chatID, "❌ An internal error occurred while setting the PIN.")) 1377 | return 1378 | } 1379 | 1380 | // save hash to .env file 1381 | err = updateEnvFile(".env", "PIN_HASH", string(hashedPin)) 1382 | if err != nil { 1383 | log.Printf("[ERROR] Failed to update .env file: %v", err) 1384 | s.bot.Send(api.NewMessage(chatID, "❌ Failed to save new PIN to configuration file.")) 1385 | return 1386 | } 1387 | 1388 | s.pinHash = string(hashedPin) 1389 | s.isAuthenticated = true // Automatically authenticate after setting a new PIN 1390 | 1391 | s.bot.Send(api.NewMessage(chatID, "✅ The PIN code has been successfully set. You are now authenticated.")) 1392 | log.Println("[INFO] PIN has been updated.") 1393 | } 1394 | 1395 | func (s *BotServer) handleLockCommand(chatID int64) { 1396 | if s.pinHash == "" { 1397 | s.bot.Send(api.NewMessage(chatID, "ℹ️ PIN is not set. Set it first with `/set_pin 1234`")) 1398 | return 1399 | } 1400 | s.isAuthenticated = false 1401 | s.bot.Send(api.NewMessage(chatID, "🔒 Bot locked")) 1402 | log.Println("[INFO] Bot has been locked by user command.") 1403 | } 1404 | 1405 | func (s *BotServer) handleChangeDirCommand(chatID int64, messageText string) { 1406 | // Change directory 1407 | if s.ssh.SSHMode { 1408 | // Get path via ssh 1409 | command := "cd " + s.ssh.Pwd + " && " + messageText + " && pwd" 1410 | output, err := s.ssh.sshRunSimpleCommand(command) 1411 | if err != nil { 1412 | msg := api.NewMessage(chatID, "⚠ Error changing directory:\n\n```ssh\n"+string(output)+"```") 1413 | msg.ParseMode = api.ModeMarkdown 1414 | s.bot.Send(msg) 1415 | log.Printf("[ERROR] Error changing directory: %s", string(output)) 1416 | return 1417 | } 1418 | s.ssh.Pwd = strings.TrimSpace(string(output)) 1419 | msg := api.NewMessage(chatID, "Current directory:\n\n`"+s.ssh.Pwd+"`") 1420 | msg.ParseMode = api.ModeMarkdown 1421 | s.bot.Send(msg) 1422 | log.Printf("[INFO] Current directory: %s", s.ssh.Pwd) 1423 | } else { 1424 | // Change local directory via os library 1425 | s.localChangeDir(chatID, messageText) 1426 | } 1427 | } 1428 | 1429 | func (s *BotServer) handleGenericCommand(chatID int64, messageText string) { 1430 | var requestPTY bool 1431 | var isRefreshMode bool 1432 | if strings.HasPrefix(messageText, "/tty_refresh ") { 1433 | isRefreshMode = true 1434 | requestPTY = true 1435 | messageText = strings.TrimSpace(strings.TrimPrefix(messageText, "/tty_refresh")) 1436 | } else if strings.HasPrefix(messageText, "/tty ") { 1437 | requestPTY = true 1438 | messageText = strings.TrimSpace(strings.TrimPrefix(messageText, "/tty")) 1439 | } 1440 | 1441 | s.runCommand(chatID, messageText, requestPTY, isRefreshMode) 1442 | } 1443 | 1444 | func (s *BotServer) handlePaginationCallback(callback *api.CallbackQuery) { 1445 | parts := strings.Split(callback.Data, "_") 1446 | if len(parts) != 3 { 1447 | return 1448 | } 1449 | action, msgIDStr := parts[1], parts[2] 1450 | msgID, err := strconv.Atoi(msgIDStr) 1451 | if err != nil { 1452 | return 1453 | } 1454 | 1455 | s.pagedMessagesMutex.Lock() 1456 | state, ok := s.pagedMessages[msgID] 1457 | if !ok { 1458 | s.pagedMessagesMutex.Unlock() 1459 | s.bot.Request(api.NewCallback(callback.ID, "The message is out of date")) 1460 | return 1461 | } 1462 | 1463 | totalPages := len(state.Pages) 1464 | currentPage := state.CurrentPage 1465 | 1466 | if action == "up" { 1467 | state.Follow = false 1468 | if currentPage > 0 { 1469 | state.CurrentPage-- 1470 | } 1471 | } else { // "down" 1472 | if currentPage < totalPages-1 { 1473 | state.CurrentPage++ 1474 | } 1475 | if state.CurrentPage == totalPages-1 { 1476 | state.Follow = true 1477 | } 1478 | } 1479 | 1480 | if state.CurrentPage == currentPage { 1481 | s.pagedMessagesMutex.Unlock() 1482 | s.bot.Request(api.NewCallback(callback.ID, "")) 1483 | return 1484 | } 1485 | 1486 | s.pagedMessages[msgID] = state 1487 | newState := state 1488 | s.pagedMessagesMutex.Unlock() 1489 | 1490 | pageContent := newState.Pages[newState.CurrentPage] 1491 | newText := newState.Header + "\n```sh\n" + pageContent + "```" 1492 | 1493 | s.activeSessionsMutex.Lock() 1494 | _, sessionActive := s.activeSessions[msgID] 1495 | s.activeSessionsMutex.Unlock() 1496 | 1497 | var keyboard api.InlineKeyboardMarkup 1498 | paginationKeyboard := getPaginationKeyboard(msgID, newState.CurrentPage, len(newState.Pages)) 1499 | if sessionActive { 1500 | stopButton := api.NewInlineKeyboardButtonData("⏹️ Stop", fmt.Sprintf("stop_cmd_%d", msgID)) 1501 | keyboard = api.NewInlineKeyboardMarkup( 1502 | paginationKeyboard.InlineKeyboard[0], 1503 | api.NewInlineKeyboardRow(stopButton), 1504 | ) 1505 | } else { 1506 | keyboard = *paginationKeyboard 1507 | } 1508 | 1509 | editMsg := api.NewEditMessageText(callback.Message.Chat.ID, msgID, newText) 1510 | editMsg.ParseMode = api.ModeMarkdown 1511 | editMsg.ReplyMarkup = &keyboard 1512 | s.bot.Request(editMsg) 1513 | s.bot.Request(api.NewCallback(callback.ID, "")) 1514 | } 1515 | 1516 | func (s *BotServer) handleStopCallback(callback *api.CallbackQuery) { 1517 | msgIDStr := strings.TrimPrefix(callback.Data, "stop_cmd_") 1518 | msgID, _ := strconv.Atoi(msgIDStr) 1519 | s.bot.Request(api.NewCallback(callback.ID, "Sending stop signal...")) 1520 | 1521 | s.activeSessionsMutex.Lock() 1522 | sessionToStop, ok := s.activeSessions[msgID] 1523 | s.activeSessionsMutex.Unlock() 1524 | 1525 | if ok { 1526 | log.Printf("[INFO] User requested stop for message %d. Sending SIGINT.", msgID) 1527 | if err := sessionToStop.Signal(sshClient.SIGINT); err != nil { 1528 | log.Printf("[WARN] Failed to send SIGINT signal: %v", err) 1529 | } else { 1530 | editedText := callback.Message.Text + "\n\n--- Sending stop signal (SIGINT)... ---" 1531 | editMsg := api.NewEditMessageText(callback.Message.Chat.ID, msgID, editedText) 1532 | editMsg.ReplyMarkup = &api.InlineKeyboardMarkup{InlineKeyboard: [][]api.InlineKeyboardButton{}} 1533 | s.bot.Request(editMsg) 1534 | } 1535 | 1536 | go func() { 1537 | time.Sleep(3 * time.Second) 1538 | s.activeSessionsMutex.Lock() 1539 | sessionStillExists, stillOk := s.activeSessions[msgID] 1540 | s.activeSessionsMutex.Unlock() 1541 | 1542 | if stillOk { 1543 | log.Printf("[WARN] Process for message %d did not stop. Sending SIGKILL.", msgID) 1544 | if err := sessionStillExists.Signal(sshClient.SIGKILL); err != nil { 1545 | log.Printf("[WARN] Failed to send SIGKILL signal: %v", err) 1546 | } else { 1547 | editedText := callback.Message.Text + "\n\n--- Process unresponsive, sent force-kill (SIGKILL)... ---" 1548 | s.bot.Request(api.NewEditMessageText(callback.Message.Chat.ID, msgID, editedText)) 1549 | } 1550 | } 1551 | }() 1552 | } else { 1553 | log.Printf("[WARN] Stop requested for message %d, but no active session found.", msgID) 1554 | editMsg := api.NewEditMessageText(callback.Message.Chat.ID, msgID, callback.Message.Text) 1555 | editMsg.ReplyMarkup = &api.InlineKeyboardMarkup{InlineKeyboard: [][]api.InlineKeyboardButton{}} 1556 | s.bot.Request(editMsg) 1557 | } 1558 | } 1559 | 1560 | func (s *BotServer) handleFollowCallback(callback *api.CallbackQuery) { 1561 | parts := strings.Split(callback.Data, "_") 1562 | if len(parts) != 3 { 1563 | return 1564 | } 1565 | action, msgIDStr := parts[1], parts[2] 1566 | msgID, err := strconv.Atoi(msgIDStr) 1567 | if err != nil { 1568 | return 1569 | } 1570 | 1571 | s.pagedMessagesMutex.Lock() 1572 | state, ok := s.pagedMessages[msgID] 1573 | if ok { 1574 | state.Follow = (action == "on") 1575 | s.pagedMessages[msgID] = state 1576 | } 1577 | s.pagedMessagesMutex.Unlock() 1578 | s.bot.Request(api.NewCallback(callback.ID, fmt.Sprintf("Follow mode: %s", action))) 1579 | } 1580 | 1581 | func (s *BotServer) handleHostVerificationCallback(callback *api.CallbackQuery) { 1582 | chatID := callback.Message.Chat.ID 1583 | if decisionChan, ok := s.pendingHostKeyVerifications[chatID]; ok { 1584 | var decision bool 1585 | if callback.Data == "verify_host_yes" { 1586 | decision = true 1587 | s.bot.Send(api.NewEditMessageText(chatID, callback.Message.MessageID, "✅ Host key accepted. Continuing connection...")) 1588 | } else { 1589 | decision = false 1590 | s.bot.Send(api.NewEditMessageText(chatID, callback.Message.MessageID, "❌ Host key rejected. Aborting connection.")) 1591 | } 1592 | decisionChan <- decision 1593 | s.bot.Request(api.NewCallback(callback.ID, "")) 1594 | } 1595 | } 1596 | 1597 | func main() { 1598 | log.Println("[INFO] Bot started") 1599 | 1600 | env := &env.Env{} 1601 | env.GetEnv() 1602 | 1603 | if env.TELEGRAM_BOT_TOKEN == "" || env.TELEGRAM_BOT_TOKEN == "XXXXXXXXXX:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" { 1604 | log.Fatal("❌ FATAL: TELEGRAM_BOT_TOKEN is not set or contains a placeholder value. Please check your .env file.") 1605 | } 1606 | if env.TELEGRAM_USER_ID == 0 { 1607 | log.Fatal("❌ FATAL: TELEGRAM_USER_ID is not set. Please check your .env file.") 1608 | } 1609 | if env.TELEGRAM_USER_ID == 7777777777 { 1610 | log.Println("⚠️ WARNING: TELEGRAM_USER_ID is set to the default example value. Please check your .env file.") 1611 | } 1612 | 1613 | bot, err := api.NewBotAPI(env.TELEGRAM_BOT_TOKEN) 1614 | if err != nil { 1615 | log.Fatal(err) 1616 | } 1617 | 1618 | sshState := &SSH{} 1619 | sshState.SSHPrivateKey, _ = os.ReadFile(env.SSH_PRIVATE_KEY_PATH) 1620 | 1621 | server := &BotServer{ 1622 | bot: bot, 1623 | env: env, 1624 | ssh: sshState, 1625 | 1626 | lastActivityTime: time.Now(), 1627 | autoLockMutex: &sync.RWMutex{}, 1628 | 1629 | pendingHostKeyVerifications: make(map[int64]chan bool), 1630 | pendingPasswordRequests: make(map[int64]chan string), 1631 | activeSessions: make(map[int]*sshClient.Session), 1632 | pendingUploads: make(map[int64]string), 1633 | pagedMessages: make(map[int]PagedMessage), 1634 | sshHosts: make(map[string]string), 1635 | 1636 | activeSessionsMutex: &sync.Mutex{}, 1637 | pendingUploadsMutex: &sync.Mutex{}, 1638 | pagedMessagesMutex: &sync.Mutex{}, 1639 | sshHostsMutex: &sync.RWMutex{}, 1640 | } 1641 | 1642 | 1643 | server.pinHash = env.PIN_HASH 1644 | if server.pinHash != "" { 1645 | server.isAuthenticated = false 1646 | log.Println("[INFO] PIN code is set. Bot is locked on startup.") 1647 | } else { 1648 | server.isAuthenticated = true 1649 | log.Println("[INFO] No PIN code set. Bot is unlocked.") 1650 | } 1651 | 1652 | // Loading hosts from the hosts.json file 1653 | server.sshHostsMutex.Lock() 1654 | file, err := os.ReadFile(hostsFilePath) 1655 | if err != nil { 1656 | if os.IsNotExist(err) { 1657 | log.Printf("[WARN] hosts.json not found, creating an empty one.") 1658 | _ = os.WriteFile(hostsFilePath, []byte("{}"), 0644) 1659 | } else { 1660 | log.Fatalf("❌ FATAL: Failed to read hosts file: %v", err) 1661 | } 1662 | } else { 1663 | err = json.Unmarshal(file, &server.sshHosts) 1664 | if err != nil { 1665 | log.Fatalf("❌ FATAL: Failed to parse hosts.json: %v", err) 1666 | } 1667 | } 1668 | server.sshHostsMutex.Unlock() 1669 | log.Printf("[INFO] Loaded %d hosts from %s", len(server.sshHosts), hostsFilePath) 1670 | 1671 | env.SshHostsMap = server.sshHosts 1672 | if env.LOG_MODE == "DEBUG" { 1673 | env.PrintEnv() 1674 | } 1675 | 1676 | go server.autoLockChecker() 1677 | 1678 | u := api.NewUpdate(0) 1679 | u.Timeout = 30 1680 | updates := server.bot.GetUpdatesChan(u) 1681 | 1682 | commands := []api.BotCommand{ 1683 | {Command: "lock", Description: "Lock the bot (requires PIN to unlock)"}, 1684 | {Command: "host_list", Description: "List of hosts for ssh connection"}, 1685 | {Command: "exit", Description: "Disconnect from the remote host and clear the declared environment"}, 1686 | {Command: "tty", Description: "Run command in TTY mode"}, 1687 | {Command: "tty_refresh", Description: "Run in TTY with screen refresh (for top, htop)"}, 1688 | {Command: "download", Description: "Download file from server. /download [path]"}, 1689 | {Command: "upload", Description: "Upload file. /upload [path]"}, 1690 | {Command: "add_host", Description: "Add a new host. /add_host "}, 1691 | {Command: "del_host", Description: "Delete a host. /del_host "}, 1692 | {Command: "ssh", Description: "Connect to host. /ssh "}, 1693 | {Command: "set_pin", Description: "Set or change the 4-digit PIN code. /set_pin 1234"}, 1694 | } 1695 | _, err = server.bot.Request(api.NewSetMyCommands(commands...)) 1696 | if err != nil { 1697 | log.Printf("[ERROR] %s", string(err.Error())) 1698 | } 1699 | 1700 | for update := range updates { 1701 | if server.env.PARALLEL_EXEC { 1702 | go server.handleUpdate(update) 1703 | } else { 1704 | server.handleUpdate(update) 1705 | } 1706 | } 1707 | } 1708 | --------------------------------------------------------------------------------