一台 MacBook,桌面干净,没有 Jenkins 的托盘图标,没有 GitHub Actions 的公网 runner 客户端,也没有 PostgreSQL 或 MySQL 的守护进程。打开浏览器,点一个按钮——镜像构建、推送、k3s 发布、服务探测——一条链路在几分钟内跑完。所有状态存在本地一个 JSON 文件里,所有代码编译进一个二进制。
这就是 cicdkit 的场景:纯 Go 标准库零三方依赖的后端 + 嵌入的 React 前端 = 一个二进制搞定从配置管理到流水线执行的全部流程(GitHub: erishen/cicdkit)。
本文将沿着一次真实发布的数据流——从浏览器点击到镜像落地 k3s——逐层拆解 cicdkit 的内部结构,重点看它在「能跑起来、跑得安全」上做了哪些工程取舍。
速览(TL;DR)
- 一个二进制、零外部依赖:纯 Go 标准库后端 + 嵌入的 React/Vite 前端(go:embed),编译成单文件;所有状态存本地
store.json,无 PostgreSQL/MySQL 等外部数据库。 - 一条本地优先的流水线:在同一个 Web UI 里完成配置管理 → Docker 构建 → 镜像推送 → k3s 发布 → SSH 直发裸机 → 服务探测,不依赖公网 CI runner。
- 安全模型是硬约束:非本机绑定必须显式
API_TOKEN,否则拒绝启动;宿主机命令执行受控;密钥在列表接口经Redacted()/MergeSecrets()脱敏,落盘文件权限 0600。 - 8 个多语言示例(Go/Rust/Python/Ruby/PHP/Java/.NET/Node)一键跑通构建与发布。
- 可选增强:LLM 故障诊断 + 知识库,以及 gitleaks pre-commit 钩子防密钥泄露。
入口与嵌入:从 main.go 到一个单二进制
启动流程按严格的顺序执行:
命令行参数解析 → .env 加载 → 配置加载 → store 初始化(NewJsonStore) → runner 创建(pipeline.New) → 前端 embed(编译期 go:embed,运行期 fs.Sub 抽出 web/dist) → AUTO_TOKEN 处理 → IsLoopback 安全闸门 → HTTP 服务器启动(优雅关闭内含 Flush)
配置加载阶段还负责处理 .env 和 .env.local 文件:
if err := config.LoadDotEnv(); err != nil {
log.Printf("加载 .env 失败 (忽略): %v", err)
}
cfg, err := config.Load(*configPath)
这让 SSH 连接密钥等敏感信息可以驻留在环境变量文件中,而非直接写在项目 JSON 或 UI 表单里。真实的环境变量优先于文件。
在配置之后、服务器启动之前,有一个关键的安全检查——IsLoopback()。如果监听地址不是本机绑定,则必须设置显式的 API_TOKEN;否则 log.Fatalf 直接拒绝启动。
前端怎么进来:静态资源与 SPA 回退
对于已知路径的请求,直接由 http.FileServer 提供静态文件;对于所有未匹配的前端路由,回退到 index.html,让 React Router 接管——这是 SPA 的经典模式,但在 Go 单二进制中实现得格外简洁。
鉴权走的是 withAuth 中间件。/api/health 和 /api/version 两个端点对所有请求开放,其余 /api/* 路径均要求携带 API Token(通过 Bearer 或 X-API-Token header 传递)。
当后端通过 window.__CICD_API_TOKEN__ 注入一次性令牌时,前端自动将其写入 localStorage:
if (window.__CICD_API_TOKEN__) {
writeToken(window.__CICD_API_TOKEN__)
}
normalizeToken 函数处理了 .env 文件中常见的引号包裹问题——用户复制 token 时经常连同双引号一起粘贴,导致前后端 token 长度不一致、始终 401。这里在读写两侧都做了归一化处理:
function normalizeToken(t) {
if (!t) return ''
const s = String(t).trim()
return s.replace(/^["']|["']$/g, '')
}
多个并发请求同时遇到 401 的情况由 tokenPrompting 标志符保护:同一时刻只允许一个 prompt 在途,避免首页多个请求连环弹出「请输入 Token」框。
配置与安全:本机绑定是硬约束
由于后端会执行 docker、kubectl 等宿主机命令,一个暴露在公网且无鉴权的 cicdkit 实例等同于开放了任意命令执行。因此非本机绑定时,如果只有 AUTO_TOKEN 而没有显式 API_TOKEN,程序直接退出:
if !cfg.Server.IsLoopback() {
switch {
case cfg.Server.APIToken == "":
log.Fatalf("安全错误: 监听地址 %s 不限于本机,但未设置 API_TOKEN。本平台会在宿主机执行 docker/kubectl,等同于把命令执行开放给全网。请设置 API_TOKEN 环境变量,或把地址改为 127.0.0.1。", cfg.Server.Addr)
case autoTokenUsed:
log.Fatalf("安全错误: 监听地址 %s 不限于本机,但使用的是 AUTO_TOKEN(令牌已写入前端页面,对外网无效)。请改用显式 API_TOKEN 环境变量后再启动。", cfg.Server.Addr)
}
}
SSH 字段通过 Redacted() 和 MergeSecrets() 实现密钥脱敏,确保列表响应中不会泄露明文凭证。
API 路由与项目生命周期
路由通过路径前缀匹配分发:
action := parts[1]
switch action {
case "build":
s.trigger(w, r, id, "build")
case "pipeline":
s.trigger(w, r, id, "pipeline")
case "deploy":
s.triggerDeploy(w, r, id)
case "validate":
s.handleValidateProject(w, r, id)
case "probe":
s.handleProbeProject(w, r, id)
case "generate":
// GET 预览脚手架文件,POST 写盘落地
if r.Method == http.MethodPost {
s.handleGenerateApply(w, r, id)
} else {
s.handleGeneratePlan(w, r, id)
}
}
服务器端路径模式更安全——用户通过 /api/fs/roots 和 /api/fs/list 接口浏览允许范围内的目录。
文件系统浏览与路径安全
隐藏条目(以 . 开头的)被跳过,目录和文件分别排序后合并返回。
流水线执行与持久化
每个项目触发 build、pipeline 或 deploy 动作时,runner 根据项目配置决定构建流程(Dockerfile 构建 → 镜像推送 → 部署到 k3s),并写运行记录到 JSON store。
HTTP 服务器的超时配置体现了对「长任务」的容忍:
httpSrv := &http.Server{
Addr: cfg.Server.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
}
构建戳:确认你连的是最新版本
前端 footer 展示这个时间,配合前端 UI_BUILD 的时间戳,用户可以一眼确认浏览器是否连上了最新编译的二进制——排查「改了代码却没生效」时非常实用。格式选择「空格分隔、无时区」的本地时间,与前端 UI_BUILD 的样式统一。
源码导航
- cmd/server/main.go — 启动顺序、AUTO_TOKEN、IsLoopback 安全闸门、优雅关闭
Flush() - cmd/server/web/src/api.js — 前端鉴权:
normalizeToken/writeToken/tokenPrompting - internal/api/server.go — 路由分发、
withAuth中间件、HTTP 超时配置 - internal/api/handlers.go — 各 API handler(build/pipeline/deploy/validate/probe/generate)
- internal/api/fs.go —
/api/fs/roots|list文件系统浏览与路径安全 - internal/store/store.go — 进程内
RWMutex+ 200ms 合并窗口 + 原子rename持久化 - internal/config/config.go —
IsLoopback、配置加载 - internal/config/dotenv.go —
.env/.env.local加载
项目地址
- GitHub:erishen/cicdkit
常见问题
cicdkit 为什么要求非本机绑定时必须设置显式 API_TOKEN,而不能使用 AUTO_TOKEN?
AUTO_TOKEN 是一次性随机令牌,启动时自动生成并注入前端页面的 JavaScript 变量中。由于令牌已写入前端源码,任何能访问该页面的用户都能看到它——这意味着 AUTO_TOKEN 无法防止跨站访问。非本机绑定时服务暴露在网络上,必须使用显式 API_TOKEN,确保只有持有正确令牌的外部请求才能访问。
cicdkit 如何避免多个并发请求同时触发 401 弹框?
前端 api.js 中使用 tokenPrompting 布尔标志符。当第一个 401 请求触发 prompt 时将其设为 true,后续并发的 401 请求检测到该标志后直接抛错,不再弹框。用户输入 token 后,原请求重试,其他失败请求由各自的重试逻辑处理。
cicdkit 的 JSON 文件存储如何保证写入的安全性?
store 层并没有 OS 级文件锁,而是用进程内读写锁(sync.RWMutex)保护内存中的数据集——所有读走读锁、写走写锁。每次写入并不直接落盘,而是调用 persist() 启动一个 200ms 的合并窗口(persistDelay),把一次构建过程中每个 stage 触发的 SaveRun 折叠成一次磁盘写入。落地时 flushNow() 先取读锁做内存快照,写入 store.json.tmp 再以 os.Rename 原子替换,读者永远不会看到半截文件;文件权限设 0600,因为 store 可能含 registry 密码 / ssh key 路径等密钥。Graceful shutdown 在收到 SIGINT/SIGTERM 后,让 HTTP 服务在 10s 内等完当前请求,再调 Flush() 停止待定定时器并强制把最后一个合并窗口写入磁盘,避免进程退出丢失最近操作。
发表回复