Skip to content

一个 CLI 工具的开源迭代记录:从单二进制到全平台分发

这不是一篇产品介绍。这是一个用 Go 写的 CLI 工具在 v0.2.x 阶段的工程迭代记录——怎么把一个"能跑"的二进制,变成一个用户在任何平台都能用一行命令装上的工具。过程中用到的工具链、踩过的坑、做过的取舍,可能对同样在做开源 CLI 的人有参考价值。


背景

Shield CLI 是一个用 Go 写的内网穿透工具,核心功能是通过 Chisel 协议建立加密隧道,支持 SSH/RDP/VNC/HTTP 等协议的浏览器内访问。

v0.1.x 阶段做完了核心功能,通过 GoReleaser 交叉编译出 macOS / Linux / Windows 的二进制,放到 GitHub Release 上,用户 curl | shbrew install 能装上。

但实际推出去之后发现,"能装"和"好装"之间还差着不少工程量。v0.2.x 主要在填这个坑。


一、Web UI 和系统服务:从命令行工具到常驻服务

问题

CLI 工具默认是前台进程,终端一关就断了。对于隧道这种需要长时间运行的服务,这不够用。

做法

v0.2.0 加了一个内嵌的 Web UI(shield start 启动,默认 localhost:8181),用浏览器管理多个应用连接。v0.2.1 接着做了系统服务注册:

bash
# 注册为系统服务,开机自启
shield install

# 指定端口
shield install --port 8182

三个平台走的是不同的底层机制:

平台机制备注
macOSlaunchd 用户代理不需要 sudo
Linuxsystemd 服务标准做法
WindowsWindows Service需要管理员权限

同时 macOS 和 Windows 加了系统托盘图标,点击可以快速打开 Dashboard、重启、退出。

踩坑记录:系统托盘依赖 CGO(底层用到了各平台的原生 GUI 库),但 Linux 服务器通常没有桌面环境,也不需要托盘。所以 GoReleaser 配置拆成了两套:桌面版(macOS/Windows,CGO_ENABLED=1)和服务器版(Linux,纯 Go 编译)。这是一个在 CI 里调了很久才跑通的东西——交叉编译 + CGO 基本上是噩梦级别的组合,最后 macOS 和 Windows 各自在对应平台的 runner 上原生编译才解决。


二、Docker 容器化:看似简单,实际有坑

为什么要做

有用户反馈想在服务器上容器化部署,和已有的 Docker Compose 栈统一管理。

Dockerfile

dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o shield-cli .

FROM alpine:3.21
COPY --from=builder /app/shield-cli /usr/local/bin/shield
ENV SHIELD_LISTEN_HOST=0.0.0.0
ENTRYPOINT ["shield", "start"]

多阶段构建,最终镜像基于 Alpine,很常规。但有两个细节不常规:

1. 必须用 --network host

一般 Web 应用容器 -p 8080:8080 就行了。但内网穿透工具的核心功能是访问宿主机网络和内网资源——10.0.0.0/24 网段在 bridge 模式下不可达。--network host 让容器共享宿主机网络栈,这是这类工具容器化的必要条件。

2. 监听地址的问题

容器内 127.0.0.1 是容器自己的 loopback,外部流量进不来。所以 Docker 镜像默认把 SHIELD_LISTEN_HOST 设为 0.0.0.0。第一次上线时漏掉了这个,导致好几个用户反馈"容器启动了但访问不了"。

CI 自动构建

GitHub Actions 里用 docker/build-push-action 做多架构构建(amd64 + arm64),同时推到 Docker Hub 和 GHCR。语义化标签(latest0.2.20.2)通过 docker/metadata-action 自动生成。

这套流程现在是标准模板了,任何 Go 项目都可以直接抄:

yaml
- uses: docker/metadata-action@v5
  with:
    images: |
      fengyily/shield-cli
      ghcr.io/fengyily/shield-cli
    tags: |
      type=semver,pattern={{version}}
      type=semver,pattern={{major}}.{{minor}}
      type=raw,value=latest

三、Linux 包管理器:APT 和 YUM 仓库搭建

为什么要做

用户已经可以通过 curl | sh 安装了,但 Linux 运维习惯的是 apt install / yum install。更重要的是,包管理器支持 apt upgrade 自动更新,不用手动重跑安装脚本。

技术方案

没有用第三方包管理托管服务(Packagecloud 等要收费),而是基于 GitHub Pages 自建仓库:

  • APT 仓库:用 dpkg-scanpackages 生成 Packages.gz,用 GPG 签名 Release 文件
  • YUM 仓库:用 createrepo 生成 repodata/,RPM 包用 GPG 签名

整个仓库托管在一个独立的 GitHub repo 的 gh-pages 分支上,GitHub Actions 在每次 Release 时自动把新的 deb/rpm 包推进去并重新生成索引。

用户配置仓库源:

bash
# Debian / Ubuntu
curl -fsSL https://cdn.jsdelivr.net/gh/fengyily/shield-cli@main/install.sh | sh
sudo apt update && sudo apt install shield-cli

# RHEL / CentOS / Fedora
sudo tee /etc/yum.repos.d/shield-cli.repo <<EOF
[shield-cli]
name=Shield CLI Repository
baseurl=https://fengyily.github.io/linux-repo/yum
enabled=1
gpgcheck=0
EOF
sudo yum install shield-cli   # 或 dnf install shield-cli

还做了一个安装检测脚本

install.sh 加了 --apt / --yum 参数,自动检测系统类型并配置对应的包管理器源:

bash
curl -fsSL https://cdn.jsdelivr.net/gh/fengyily/shield-cli@main/install.sh | sh -s -- --apt

四、TCP/UDP 端口代理:协议层的扩展

之前的状态

Shield CLI 最初只支持 SSH、RDP、VNC、HTTP、HTTPS、Telnet 这些有明确语义的协议——它在网关侧做协议渲染(比如 SSH 转成 Web Terminal,RDP 转成 HTML5 Canvas),所以每个协议需要对应的网关支持。

新需求

有用户需要代理 MySQL(3306)、Redis(6379)、PostgreSQL(5432)等 TCP 服务。这些不需要浏览器渲染,纯端口转发就够了。还有少量 DNS(53)、Syslog 等 UDP 场景。

实现

bash
# TCP 代理
shield tcp 3306              # 本地 MySQL
shield tcp 10.0.0.5:6379     # 远程 Redis

# UDP 代理
shield udp 53                # DNS
shield udp 10.0.0.5:514      # Syslog

技术上,TCP 走 Chisel 的标准反向隧道;UDP 用 Chisel 原生的 /udp 后缀做 UDP over WebSocket 转发。

和 SSH/RDP 等协议的关键区别:TCP/UDP 没有默认端口,所以 CLI 强制要求用户指定端口号。隧道建立后不会自动打开浏览器(因为没有 Web UI 可看),而是在终端打印连接指南:

  📡 Connection Guide (TCP port proxy):
    gateway.example.com:48721  →  10.0.0.5:3306

    Examples:
      mysql -h gateway.example.com -P 48721 -u root
      redis-cli -h gateway.example.com -p 48721

五、分发矩阵总结

做完 v0.2.x 这一轮之后,Shield CLI 的安装方式变成了这样:

方式命令平台
Homebrewbrew install shield-climacOS
Scoopscoop install shield-cliWindows
APTapt install shield-cliDebian/Ubuntu
YUMyum install shield-cliRHEL/CentOS/Fedora
Dockerdocker run fengyily/shield-cli任意 Linux
curlcurl ... | shmacOS/Linux
PowerShell一键脚本Windows
二进制GitHub Release 下载全平台

这些不是一次性做完的,是在半个月内迭代加上去的。每一种安装方式背后都有对应的 CI 流水线在维护——GoReleaser 管二进制 + Homebrew + Scoop + deb/rpm 包,Docker 走单独的 workflow,APT/YUM 仓库也是独立的 workflow。


开源工具链清单

把这次用到的工具列一下,都是开源的,别的 Go 项目可以直接参考:

工具用途
GoReleaser交叉编译 + 打包 + Homebrew/Scoop/deb/rpm
docker/build-push-action多架构 Docker 构建和推送
docker/metadata-actionDocker 标签自动生成
nfpm不需要 dpkg-deb 也能打 deb/rpm 包(GoReleaser 内置)
GitHub PagesAPT/YUM 仓库静态托管
GitHub Actions SecretsGPG 密钥、Docker 凭证管理

几个教训

1. 不要低估分发的工作量。 核心功能可能只占 40% 的工作量,剩下的全在"让用户装得上"这件事上。Homebrew tap 配置、Scoop bucket manifest、deb/rpm 打包参数、Docker 多架构、APT/YUM 仓库签名、install.sh 的各种 edge case……每一个都不难,但加起来很花时间。

2. CGO 和交叉编译是两个互斥的目标。 如果你的 Go 项目依赖 CGO(GUI 库、SQLite 等),老老实实在目标平台上原生编译。不要试图在 Linux CI 上交叉编译 macOS 的 CGO 项目,那条路走不通。

3. Docker + 内网穿透 = --network host 这个组合很违反直觉,因为 Docker 的核心价值之一是网络隔离。但对于需要访问宿主机网络的工具,host 模式是唯一选择。在文档里一定要把这个说清楚,否则用户的第一反应永远是"为什么容器里连不上"。

4. GitHub Pages 做包仓库足够用了。 不需要 Packagecloud,不需要 Artifactory。一个 gh-pages 分支 + GitHub Actions 自动更新索引,对于中小型开源项目完全够。省钱,可控。


最后

这篇文章记录的不是 Shield CLI 本身的功能,而是一个开源 CLI 工具在 分发和部署 层面的工程实践。如果你也在做一个 Go CLI 项目,正在纠结怎么让用户更方便地安装和运行,这里面的工具链和踩坑经验应该能帮上忙。

所有构建配置和 CI 脚本都在仓库里,可以直接参考:

  • GoReleaser 配置:.goreleaser.yaml
  • Docker 构建:Dockerfile + .github/workflows/docker.yml
  • APT/YUM 仓库:.github/workflows/update-repo.yml

项目地址:https://github.com/fengyily/shield-cli

如果这篇文章对你有帮助,去仓库点个 Star 就是最好的支持。用的过程中遇到问题或者有想法,欢迎直接提 Issue