├── 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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------