简介:基于Go与JavaScript构建的Next Terminal轻量级堡垒机系统设计源码,面向需要自建远程访问控制平台的中高级运维和开发人员。系统支持RDP、SSH、VNC、Telnet及Kubernetes协议,以统一入口、安全审计和便捷登录为核心场景,适合内网资产集中管理、云端混合运维以及Kubernetes集群访问管控。源码包共三百六十个文件,以一百五十九个Go服务端文件与一百三十八个JavaScript前端文件为主体,配合CSS样式、YAML与YML配置、Shell脚本、Dockerfile及Markdown文档,压缩包约10.69MB,目录结构清晰,并附带代码工作区、环境示例与依赖锁文件,便于快速还原工程。内容覆盖登录认证、文件系统操作、远程命令执行、离线会话、定时任务等模块,前端页面涉及登录、文件管理、命令执行、任务统计等典型场景,可以按模块拆解学习和二次开发。目前已有三百七十七人学习浏览,对于希望掌握堡垒机前后端协作、协议接入或快速搭建轻量级运维入口的读者而言,是一份完整且可直接运行研究的工程。
1. 基于 Go 和 JavaScript 的轻量级堡垒机,Next Terminal 到底能干什么
服务器数量超过一只手之后,登录入口就开始失控:开发要测试环境、运维要碰生产机器、外包要改配置文件,人手一把密码,换个人就得重新交接一轮。Next Terminal 这种基于 Go 和 JavaScript 构建的轻量级堡垒机,把 SSH、RDP、VNC 会话统一收口到 Web 页面,登录、授权、录屏、回放全在这一处解决。
它和明御那类偏重合规审计的大型堡垒机走的路子不同,不在意专用硬件和重型部署,一个容器就能拉起来,中小团队和个人开发者完全养得起。项目源码是开放的,二次开发的弹性足够,想加功能也不用等厂商排期。
接下来的内容会按架构、部署、调参和踩坑一条线拆开讲,目的是让你在半天内把一个可用的堡垒机搭起来,并且知道后续维护时地雷埋在哪。适合手里有一批服务器、想统一入口和操作审计的运维或后端工程师。
2. 架构拆解:Go 后端和 JavaScript 前端在各自分管什么
2.1 Go 后端:从 REST API 到会话网关的三条服务线
先给一个整体认识。Next Terminal 没有从零实现 RDP 和 VNC 这两套复杂协议,那是投入几年都未必做得完的事。常见做法是把 JavaScript 前端、Go 后端、guacd 三者串成一条链路:RDP/VNC 会话交给 Apache Guacamole 的守护进程去完成协议转译,Go 后端负责把远端会话统一打包成 WebSocket 消息送回浏览器;SSH 会话则直接由 Go 的 crypto/ssh 包建立。理解这条混合链路,是后续排查一切连接问题的前提。
读源码时,我会把后端代码拆成三条线。第一条是 REST API 服务,管登录、用户、资产、授权这些控制面请求,数据落在 MySQL 或者 SQLite。第二条是 WebSocket 网关,负责终端与后端会话引擎之间的双向消息交换,消息带 type 字段区分输入、尺寸调整、会话关闭。第三条是会话调度,负责匹配资产、挑选账号、建立连接,在会话结束后把录屏和操作日志归档到数据目录。
为什么用 Go 写这个后端而不是传统 Java 方案?核心原因是会话模型的天然契合。每个 SSH 会话是一条独立的双向通道,Go 的 goroutine 和 channel 几乎一比一映射到会话生命周期上,写连接池和并发广播时不需要绕弯子。换作 Java 那套线程池加锁模型,同样功能实现起来心智负担会重不少。这也是 kratos 和 go zero 这类框架很少出现在堡垒机领域的原因——它们更多面向业务 API,而不是长连接网关。
2.2 JavaScript 前端:xterm.js 渲染与 WebSocket 消息格式
前端应用是用 JavaScript 写的 React 工程,终端组件基于 xterm.js。xterm.js 本质上只是一个前端终端模拟器,负责把 ANSI 控制序列、光标移动、颜色渲染到屏幕上。真正和远端会话之间的唯一通道是 WebSocket,所以前端代码里最值得读的就是 WebSocket 收到消息后的分发逻辑。
一个典型的消息处理结构如下:
// 终端组件内部的 socket 消息分发 const ws = new WebSocket(`wss://${location.host}/socket`); ws.binaryType = 'arraybuffer'; ws.onmessage = (event) => { // 每个消息都是一层 JSON 封装,data 字段才是真正的二进制内容 const frame = JSON.parse(event.data); switch (frame.type) { case 'data': { const bytes = new Uint8Array(frame.data); term.write(bytes); break; } case 'resize': { term.resize(frame.cols, frame.rows); break; } case 'close': { term.writeln('会话已结束'); break; } default: break; } };这段代码有三处值得留意。第一,ws.binaryType必须显式设成arraybuffer,否则浏览器会把二进制帧先按文本解析,终端里的 UTF-8 内容直接变乱码。第二,term.write接收的是Uint8Array而不是普通字符串,这样 xterm.js 的缓冲区才能正确解析中文宽字符。这也是 JavaScript 前端最常见的类型判断错误——不要用typeof去判断二进制数据,这种写法十有八九会翻车。第三,resize事件是后端主动下发的,因为远程会话的真实尺寸由后端根据会话窗口决定,前端不能自作主张。
Go 后端对应的处理逻辑简化如下:
// Go 后端 WebSocket 入口,按消息类型分发(结构示意) func handleWebSocket(conn *websocket.Conn, sess *models.Session) { for { _, raw, err := conn.ReadMessage() if err != nil { sess.Close() return } var req struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(raw, &req); err != nil { continue } switch req.Type { case "input": var payload []byte json.Unmarshal(req.Data, &payload) // 按协议区分:SSH 走本地 channel,RDP/VNC 走 guacd 通道 if sess.Protocol == "ssh" { sess.SSHChannel.Write(payload) } else { sess.GuacdConn.Write(payload) } case "resize": var size struct { Cols int `json:"cols"` Rows int `json:"rows"` } json.Unmarshal(req.Data, &size) sess.SSHChannel.WindowChange(size.Rows, size.Cols) } } }这段代码的核心在input分支对协议的区分。同样一个按键事件,SSH 直接写入本地 channel;RDP 或 VNC 则要把字节翻译成 Guacamole 指令再写入 guacd 连接。排障时有一条经验可以直接抄:SSH 正常而 RDP/VNC 连不上,先检查 guacd 进程是否在监听 4822 端口,而不是去翻前端代码。这个顺序能省下大量时间。
消息格式还有一个隐藏细节:JSON 外壳里如果直接塞原始字节,序列化开销会偏高。因此实现时通常会把二进制负载做一层 base64 或数组转换,两边的编解码逻辑必须严格对称。前端处理不当的表现是偶发乱码、字符半截,后端处理不当的表现是内存暴涨。这个问题不常出现,但一出现就是大面积会话异常。
3. 快速部署:用 Docker Compose 把 Next Terminal 拉到本地跑起来
3.1 最小可用的 Compose 文件与三条易错参数
把 Next Terminal 和 MySQL 一起用容器跑起来,是开源堡垒机最常见的部署姿势。以下是一个最小环境用的 compose 配置:
version: "3.8" services: next-terminal: image: dushixiang/next-terminal:latest container_name: next-terminal ports: - "8088:8088" environment: DB_HOST: mysql DB_PORT: 3306 DB_USER: root DB_PASSWORD: next-terminal DB_NAME: next-terminal volumes: - /etc/localtime:/etc/localtime:ro - next-terminal-data:/data depends_on: - mysql restart: unless-stopped mysql: image: mysql:5.7 container_name: next-terminal-mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci environment: MYSQL_ROOT_PASSWORD: next-terminal MYSQL_DATABASE: next-terminal volumes: - mysql-data:/var/lib/mysql restart: unless-stopped volumes: next-terminal-data: mysql-data:文件里有三个容易被忽略的参数。第一,DB_NAME与 MySQL 初始化时的MYSQL_DATABASE必须一致,否则后端启动时会因找不到库而反复重试。第二,/etc/localtime的挂载决定时区,不挂载会导致录屏回放时间差 8 小时。第三,数据卷next-terminal-data必须存在——录屏文件、会话配置都会落到这个目录,容器重建时不会跟着丢。
写好文件后直接执行:
# 拉起两个容器,第一次会拉取镜像,时间取决于网络 docker compose up -d # 查看启动日志,确认后端没有报错 docker compose logs -f next-terminal日志里如果出现数据库连接被拒绝的字样,先检查 MySQL 容器是否已进入健康状态,再检查DB_HOST是否误写成了localhost。容器网络里 Next Terminal 与 MySQL 不共享 localhost,必须用服务名互相访问。这个排查逻辑可以记成自己的固定流程,比每次现猜要快得多。
3.2 源码构建:前端打包与 Go 二进制的最终形态
如果拿到的不是现成镜像,而是源码包,构建流程通常是两条命令。前端是 JavaScript 工程,需要先把静态资源构建出来;后端是 Go 工程,编译成一个可执行文件。
# 构建前端 cd web npm install npm run build # 编译后端,GOOS/GOARCH 按部署机设置 cd .. go build -o next-terminal这里有一个值得注意的细节:Go 编译出的二进制是单文件的,前端静态资源既可以独立放在web/dist目录让后端读取,也可以用go:embed直接嵌进二进制。我一般会优先使用嵌入方式,部署时只拷贝一个可执行文件,目录结构简单很多,也不容易出现静态资源路径找不到的问题。
首次启动时,后端会自动连接数据库,并把 user、asset、session 等基础表迁移出来。看到迁移完成的日志后,浏览器访问http://服务器IP:8088就能进入登录页。默认管理员账号通常是 admin/admin,首次登录后第一件事就是把密码改掉,这一步摇号也会在后续用到——如果默认密码是公开信息,攻击者扫描到端口就能直接进来。
构建时 Windows 环境需要先配置好 Go 环境和 npm 工具链,下载对应平台的 zip 包解压后配置环境变量,再用go version和node -v验证。如果编译报错,九成是依赖版本问题,优先检查 Go module 代理配置和 npm registry 是否可访问,而不是去改业务代码。
4. 参数调优:认证方式、资产授权与审计参数三个层面怎么调
4.1 认证方式怎么选:本地账号、OAuth2 和 LDAP 的适用场景
认证入口在管理后台配置。小团队最简单的方式是本地账号:管理员在后台逐个创建,用户直接用用户名登录。好处是没有外部依赖,坏处是用户数量上来后,一人离职要记得把名下所有资产权限解绑,否则账号会一直保持可登录状态,变成权限管理上的黑匣子。
如果公司已有统一身份体系,我建议优先接 LDAP。LDAP 模式下,密码和禁用状态都由上游目录服务决定,堡垒机里的本地账号更像映射关系,用户离职时上游一锁,这边权限自然失效。OAuth2 则适合团队主要用第三方账号登录的场景,配置比 LDAP 直观,但回调地址填写错误是 JavaScript 侧的常见问题——回调地址不匹配时,登录会反复跳回首页,后端日志里能看到redirect_uri与注册不一致的记录。
这里要特别提醒一件事:不要把认证方式的切换当成纯功能配置。换认证方式之前,先给堡垒机做一个完整备份,包括数据库和数据目录。尤其从本地账号切到 LDAP 时,如果用户 ID 匹配逻辑没对齐,可能出现同一批用户全部失去资产权限的情况,这种故障恢复起来很麻烦。
4.2 资产录入与授权策略:按环境分组再绑用户组
把服务器纳入堡垒机之前,先想清楚资产怎么分组。我一般建议按环境分:生产、测试、预发布各一组,再配合用户组做批量授权。如果一开始就把授权精确到单资产,后面每加一台机器都要逐一配置权限,很快就没人愿意维护了。
资产信息里最重要的三个字段是协议、端口和认证方式。SSH 资产如果同时支持密码和密钥,我建议两种都填好,以防某天服务器改变配置后其中一种失效。录入 RDP 资产时要确认目标机开启了远程桌面,并且账号格式是域\用户或主机名前缀。每次连不上资产,先回资产列表看一眼端口有没有录错——这是堡垒机连接失败里占比很高的低级错误。
授权粒度决定安全边界和运维工作量。刚起步的团队按“用户组-资产组”绑定就够用;等业务稳定后,再对高权限资产做单资产级管控。整个过程中后端 API 都走常规 REST 风格,批量操作可以直接调用管理接口,也可以用源码里现成的前端页面慢慢点。授权生效是即时性的,不需要重启服务,这也是堡垒机这类工具应有的体验。
4.3 会话审计的三个关键参数
审计能力是堡垒机区别于普通跳板机的核心。以下三个参数通常需要手动确认:
| 参数 | 常见默认值 | 建议值 | 作用 |
|---|---|---|---|
| 录屏存储位置 | data/recording | 独立数据卷或宿主机路径 | 确保容器重建后回放不丢 |
| 日志保留天数 | 不清理 | 90 天或 180 天 | 控制磁盘占用并守住审计要求 |
| 文本会话日志 | 默认开启 | 保持开启 | 全文检索比翻录屏快得多 |
录屏回放文件是二进制格式,默认落在数据目录。磁盘规划上按并发会话数量估算:10 个并发、每个会话两小时,一天大概会产生几个 GB 数据,90 天保留期需要预留几十 GB 空间。如果磁盘吃紧,优先保文本日志——丢录屏比丢文本日志更让人后悔。文本日志支持全文检索,排障时能直接定位到某条命令,这套组合比单纯依赖录屏回放效率高得多。
补充一点:录屏回放文件的完整性和时间戳校验最好定期抽查。文件生成后可以加一个定时任务,统计每天录屏文件数量和时长是否匹配会话列表。审计数据最怕的不是数据本身有问题,而是需要时才发现文件已经损坏或缺失。提前做好验证,合规检查时才能拿得出东西。
5. 常见问题与避坑:五个真实案例帮你扫清部署期地雷
5.1 录屏回放时间整体偏移 8 小时
现象:打开会话回放,时间轴显示比实际执行操作的时间晚了 8 小时,早上 10 点操作变成了凌晨 2 点。
原因:容器基础镜像使用 UTC 时区,宿主机是中国标准时间。录屏文件里存的时间戳本身没有时区概念,前端渲染回放时按本地时区解析,于是出现 8 小时偏移。
解决:在 compose 文件里追加宿主机时区挂载:
volumes: - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro这个坑看起来不深,但影响很大。审计场景里时间戳对不上比内容有瑕疵更严重,合规流程里没人愿意听时区解释。我会把这个配置直接写进基础模板,而不是出问题后再补。
5.2 SSH 连不上目标机,但本地终端能正常连
现象:在堡垒机的资产列表里发起 SSH 会话,提示连接失败;同一台机器,换个终端直接 ssh 却完全正常。
原因:这种情况排查过不少次,多数是三者之一:堡垒机所在宿主机网络到达不了目标机 22 端口;资产里保存的认证方式与目标机实际允许方式不一致;目标机禁用了密码登录,而资产里恰好填的是密码。有时候 sshd 日志里能看到连接请求,说明网络通,问题就出在认证段。
解决:先在堡垒机宿主机上手动执行连通性测试,确认端口可达;然后逐个验证认证方式,目标机允许密码登录就改用密码,或者上传正确的密钥。OpenSSH 对私钥权限要求严格,密钥文件权限不能超过 600,否则 Go 的 ssh 客户端会拒绝加载。这条也值得专门记下来,因为报错信息往往只显示“permission denied”,容易误导方向。
5.3 容器重启后录屏全没了
现象:执行 docker compose down 再 up 之后,堡垒机能正常打开,但历史会话列表和录屏回放全部消失。
原因:数据卷没有挂载时,录屏文件写在了容器的可写层。容器删除重建后,可写层整体被清空,录屏和配置跟着消失。这是容器部署里最典型的“没有后悔药”场景。
解决:把数据目录完整映射到宿主机路径,或使用 docker volume 持久化。 compose 里声明的卷名称必须与服务中挂载的名称一致,最好再给数据目录做一份定期备份。定时备份这件事很多团队一开始不当回事,真到审计需要回放时再找,往往什么也找不回来。
5.4 浏览器能打开页面,但终端 WebSocket 连不上
现象:登录成功,资产列表能加载,点进会话后一直转圈,浏览器控制台出现 WebSocket 握手失败的报错。前端运行时报错直接体现为连接状态异常,页面本身看着却正常。
原因:前端代码通常用location.protocol判断ws还是wss。如果站点走了 HTTPS 接入层且配置没有放行 Upgrade 请求头,WebSocket 握手就会失败。场景很常见,看起来像一种玄学,实际上原因就那么几个。
解决:检查接入层配置,确认 WebSocket 升级相关的请求头被正确转发,需要支持 HTTP/1.1 的长连接语义。配置修正完成后,让浏览器强制刷新一次页面,旧的失效连接不会自动重试。如果接入层已经正确配置还是失败,再看一下会话建立时是否被负载均衡分发到了不同节点,堡垒机的 WebSocket 长连接通常需要保持会话粘连。
5.5 数据库连接不够用,Too many connections 报错
现象:并发会话上到几十条之后,新建会话明显变慢,日志里出现Too many connections报错。堡垒机进程本身还好,但 API 部分开始超时。
原因:默认 MySQL 连接数上限是 151,后端引擎的连接池会根据并发动态扩容,短暂高峰会把连接占满。容器环境里的 MySQL 如果不单独限制连接池,一个会话卡住,后续请求都会在数据库排队。
解决:给 MySQL 调大max_connections,同时在后端环境配置里把连接池上限卡住,避免无限增长:
SET GLOBAL max_connections = 500;注意这条 SQL 即时生效但重启后丢失,需要同步写进 MySQL 配置文件。如果架构里有多个资产和会话节点,考虑把堡垒机的配置库与业务库拆开,避免相互挤占。否则并发会话超过某个阈值后,数据库连接风暴会让你整个堡垒机界面都打不开。
6. 一个值得做的二次开发:给运维同事加一个只读围观
6.1 思路:在网络层做一发多收,而不是改前端
当运维协作从 2 人增加到 8 人时,我发现“围观模式”是刚需。新同事要学操作流程,老同事要偶尔介入查看进度,管理者想实时确认操作合规。给终端的 xterm.js 加只读属性其实很简单,它本身支持options.disableStdin,设成 true 之后用户就只能在屏幕上观看,无法输入。真正的改动在后端的“一发多收”逻辑上。
实现只读围观不需要动前端渲染层,只需要给 WebSocket 消息增加一种类型:订阅。连接建立后,浏览器如果识别到 URL 参数中有mode=observe,就发送订阅消息,而不是建立交互式会话。后端收到订阅后,把该连接挂到对应 session 的观察者列表里,主人的数据通道照常工作,观察者只接收输出。
6.2 关键实现:Session 里维护观察者连接集合
后端数据分发逻辑会变成这样:
// 会话广播:同时推送给操作者和所有观察者(结构示意) func (s *Session) Broadcast(bs []byte) { s.owner.Write(bs) for _, observer := range s.observers { select { case observer.Send(bs): default: // 观察者消费太慢就移除,避免拖住主链路 s.RemoveObserver(observer) } } }这里的 select-default 是必须要做的防呆设计。我第一次实现时没有加 default,结果围观者一多,某个浏览器卡住后缓冲写不出去,整条会话输出被拖死,操作者的屏幕直接停顿几秒。后来把观察者连接改成独立缓冲队列并快速超时,这个问题才彻底消失。教训是:旁路监听永远不能阻塞主链路,宁可丢观察者的数据,也不能影响正在操作的人。
验证这套逻辑有一个很直白的方法:开两个浏览器窗口,一个正常操作,一个用mode=observe参数进入,观察窗口的输入函数是否被禁用、输出是否实时同步。再看操作者关闭会话后,观察者窗口是否收到 close 事件并自动结束。我一般会用三台不同机器同时围观再恢复正常操作,确认延迟累积和自己的体验。
围观模式只是二次开发的一个小例子,但它的消息扩展思路可以套用到回放、监控、协同操作等场景。真正用好它之后,你会慢慢发现堡垒机不是一个只能登录跳转的工具,而是一个可以按业务需求不断生长的权限中枢。这些参数、结构和源码改动顺序,都是我在实际部署中反复试出来的,希望帮到你。
本文还有配套的精品资源,点击获取