背景:一次没有 Ansible 的堡垒机换代
九月底我把那套跑在 CentOS7 上的 JumpServer 1.5.1 迁到了 Ubuntu 24.04 的 v4.10.19。选这个版本不是随手挑的新号:官方 2026-09-09 公告(JS-2026.09.09)披露 SQL 查询过滤存在缺陷,任意已登录的普通用户可越权获取管理员 Access Key,受影响范围 v4 ≥4.0.0 且 <4.10.19 LTS,修复动作就是升到 4.10.19。截至写作时这是 v4 线的安全下限,公网部署尤其要先对齐(来源与适用条件见文末)。迁移范围比"换个服务器"大得多:内网 21 台资产,每台统一三套通道账号——jmsadmin(sudo ALL、root 等效)、www(操作)、rd(只读)——新机装好后要把三套新密钥批量推到所有资产机,再把资产、账号、授权整批导入 v4 的对象模型。全程没有 Ansible,就是 SSH 加 Django shell 手工推进,老跳板机观察几天再释放。下文新机写 203.0.113.10,老机 203.0.113.20,域名 jms.example.com。
安装本身先交了两笔网络税:latest 的 quick_start.sh 配套 v5,拿它装 v4 会去找 .sha256 而 v4.10.x 只发 .md5,必须用与版本配套的脚本;国产云上 docker 与 compose 下载源都会卡死,卡住一两分钟就换镜像源,别干等。推密钥前先摸家底:老 1.5.1 宿主机直跑 Django(py3.6),旧系统用户私钥不用手动解密,SystemUser.private_key 属性直接返回解密后的 PEM;拿旧钥逐台试登分 OK / FAIL / UNREACHABLE 摸底,顺手核销了好几台早已释放的 ghost 机器;然后新机生成三套新钥灌入 JMS 的 Account,再借旧通道当"快递通道"逐台写 authorized_keys。
连装带迁加验收,我前后踩了五个连环坑,每个都差点让"看起来成功了"的假象混过去。
坑一:改了 config.txt 只重启 web,域名登录被可信校验拦截
现象。 新机装完,我把 HTTPS 和域名写进 config.txt:
HTTPS_PORT=443
SSL_CERTIFICATE=/opt/jumpserver/config/nginx/cert/example.com.pem
SSL_CERTIFICATE_KEY=/opt/jumpserver/config/nginx/cert/example.com.key
DOMAINS="jms.example.com:443"
证书在有效期内。改完我按直觉执行 ./jmsctl.sh restart web,状态全 healthy,curl IP 也通。结果用域名打开登录页,一提交就是那句:配置文件存在问题,无法登录。
排查。 这句报错来自可信域名校验:站端拿请求 Host 与 DOMAINS 白名单比对,不匹配直接拦截。我登进 core 容器查运行时配置,发现 core 进程里的 DOMAINS 是空值——config.txt 不是 nginx 一家的事,各容器在启动时各自解析这份配置,restart web 只重建了 web,core 还揣着启动那一刻的空 DOMAINS 在跑。
解法。 全组件重启(cd /opt/jumpserver && ./jmsctl.sh restart),status 全 healthy 再走域名登录,拦截消失。
复盘。 配置分发是启动时快照式的,不重启等于没改;报错文案又不提"哪个组件没加载",容易把人往配置写错的方向带。动了全局配置就全家重启,别省那几十秒。
坑二:Django ORM 的"内存态假对象"——拿全零 UUID 去绑外键
现象。 v4 的 PrivateToken 只剩 key 字段,拿 API bearer 调接口照样 401,而 API Token 认证本身就不稳,别在它上面耗时间——最可靠的路是进容器跑 Django shell:本机写 .py → scp → docker cp 进 jms_core → python manage.py shell < /tmp/x.py 从 stdin 灌,别用 shell -c,多层引号迟早翻车。前置必须先 set_to_default_org(),资产、账号、权限都是 org 级模型。
批量建组织树时,我给 Node 建完子节点直接拿返回对象做 M2M 绑定,数据库当场甩来 IntegrityError (1452, Cannot add or update a child row)。回查才发现这个内存对象的 id 是全零 UUID,库里真实节点的 id 根本不是它。
排查。 v4 的 Node 树 API:子节点要用实例方法 parent.get_or_create_child(name),返回 (child, created) 二元组。这个 child 是内存态对象,created 为 False 的分支里它的 id 甚至是占位的全零 UUID,拿它 add 到 M2M,外键指向不存在的节点,必然违例。AssetPermission 是另一个陷阱集中区:accounts/protocols 是 JSONField 存名字列表不是 M2M,对它 .add() 报 'list' object has no attribute 'add';users/assets 才是 M2M;日期必须 timezone-aware,naive datetime 会在 pre_save 信号的 is_expired 比较里抛 TypeError。
解法。 创建后必须重查库拿真实对象,再做关联:
node, created = parent.get_or_create_child("生产网段")
node = Node.objects.get(key=node.key) # 重取真实对象,别信内存态 id
ap, _ = AssetPermission.objects.get_or_create(name="三通道授权")
ap.date_start = tz.now() # 绝不手写 datetime.now()
ap.save() # 先 save 拿 pk,再 .add()
ap.assets.add(*Asset.objects.filter(address__in=addr_list))
还有一处隐性坑:ORM 直接挂资产后 Node 的 assets_amount 缓存列绕过 signal 不刷新,界面树全显示 0,收尾要遍历 Node 重算 get_assets_amount() 再 save(update_fields=["assets_amount"])。另外 21 台 × 3 账号共 63 条 Account 是"每资产每账号一条"的模型设计使然,不是冗余,别一慌就去删。
复盘。 Django 对象的"存在性"以查库为准。凡工厂方法、get_or_create 返回的对象要做外键关联,统一重取一次真实对象——一行代码换一个必现违例,值。
坑三:authorized_keys 批量分发的三连雷
现象。 密钥推完后逐台验证,一部分机器明明推送脚本输出了 OK,新钥连上去照样 Permission denied (publickey)。更诡异的是有的机器上一把密钥还能登,这一轮新推的全不生效。
排查。 老 sshd 拒公钥,排障固定四步:登目标机 ls -ld 看 ~user、.ssh、authorized_keys 的属主权限;awk '{print NR": "length($0)}' 看每行长度,粘连和截断在这里无所遁形;grep /var/log/secure 看 sshd 自述拒因;对比 ssh-keygen -lf 指纹和文件里的 key 材料是否同代。三个雷在前两步现形。
解法。 三连雷:一、属主错位——sudo 建的 .ssh 属主是 root,sshd StrictModes 直接拒绝;且老机上 rd 可能没有同名组(数字 gid),chown rd:rd 报 invalid group,要取 id -g 兜底;二、追加粘连——目标文件末行缺换行符时 tee -a 把新公钥拼到上一行尾巴,两条 key 同时作废,这就是"上一把还能登、这一把全失效"的来源;三、假判重——按注释 grep "ops@example" 判重会误命中同注释的不同算法(上轮推过 ed25519、这轮是 RSA),新钥直接跳过不推,判重必须按公钥 base64 主体比对。合并成一段防守写法:
g=$(id -gn $u 2>/dev/null || id -g $u) # 数字 gid 兜底
install -d -m 700 $hp || sudo install -d -m 700 -o $u -g $g $hp
[ -s $f ] && [ "$(tail -c1 $f | wc -l)" -eq 0 ] && echo >> $f # 补行尾换行
fp=$(echo "$KEY" | cut -d' ' -f2 | cut -c1-50) # 按 key 材料前50字符判重
grep -qF "$fp" $f || printf '%s\n' "$KEY" | sudo tee -a $f >/dev/null
sudo chown -R $u:$g $hp; sudo chmod 600 $f
还有个不算雷的雷:新装 Ubuntu 的机器上根本没有 www/rd 用户,写 key 前先 id $u 探测,没有就 useradd -m -s /bin/bash。推完的验证必须全矩阵跑,不许抽查:每台 × 每账号一条 ssh -o BatchMode=yes -i $u $u@$ip "echo ok" 出结果表——抽查省下的十分钟,排障会还你一整晚。
复盘。 authorized_keys 是"错了也静默失败"的典型:写入命令永远返回 0,错误全藏在下一次登录的拒信里。属主、换行、判重三件事在脚本里逐条防守,比事后翻日志便宜得多。
坑四:老机 OpenSSH 5.3 根本不认 ed25519
现象。 第一版密钥我理所当然用了 ssh-keygen -t ed25519。同一条命令,Ubuntu 24.04 的资产机全通,几台老 CentOS 一律 Permission denied。老机上 sshd -V 一看:OpenSSH_5.3p1——这版本只认 ssh-rsa(SHA-1)签名,rsa-sha2-256/512 不认,ed25519 更是闻所未闻(rsa-sha2 系列从 OpenSSH 7.2 才有,ed25519 从 6.5 才有,而 CentOS 7 全系锁在 5.3p1)。
排查。 连通测试差点掩盖这事:celery 里 paramiko 的测试对部分机器"通过",koko 那边却大面积 FAIL——paramiko 和 koko(Go 实现)默认算法集不同,同一批机器一个全通另一个假 FAIL 完全可能。算法兼容必须拿真 sshd 版本说话,再各挑一台新机、老机实测。
解法。 混合存量机群统一用 ssh-keygen -t rsa -b 3072 生成 RSA-3072 过渡密钥,新旧通吃。注意反向兼容:OpenSSH 自 8.8(2021 年 8 月)起默认禁用 ssh-rsa(SHA-1)签名算法,Ubuntu 24.04 自带的 9.6p1 自然在禁用之列,从新机连老机要显式放宽——ssh -o PubkeyAcceptedAlgorithms=+ssh-rsa -o HostkeyAlgorithms=+ssh-rsa -o BatchMode=yes -i $u $u@$ip "echo ok",两层循环把每台 × 每账号的矩阵一次跑完出报告。
paramiko 走 JMS 连通测试同一条链路,还要显式禁用 rsa-sha2(disabled_algorithms={"pubkeys": ["rsa-sha2-512", "rsa-sha2-256"]}),且 RSA 私钥必须落临时文件加载。等老机器全部退役再一次性切回 ed25519:重生成 → 重跑推送脚本 → 更新 JMS 里 Account 的 secret,三步全复用现成脚本。
复盘。 "新算法更安全"的前提是全链路都认识它。密钥算法是全局约束不是单机选择——存量机群先画兼容矩阵(sshd 版本 × 客户端版本),定一个所有人都能签到的公约数,把升级留到机群换代后做一次批量动作。
坑五:邮件告警里,字符串 "true" 是个假开关
现象。 密钥和资产都就位后配邮件告警,smtp.exmail.qq.com:465。测试发送直接报 ValueError: Mutually exclusive options: SSL and TLS。明明我只开了 SSL,TLS 看板上也是"关",怎么会互斥?
排查。 JMS 的邮件配置存在 Setting 表(category=email)。进 Django shell 把相关项打出来,发现 EMAIL_USE_SSL 存的是字符串 "true"——写入路径把它当普通 value 存了,布尔解析这层把 "true" 判成 falsy:SSL 实际是 False,TLS 又是 False,连接逻辑退回明文 25 端口撞上 STARTTLS 分支,报出这个莫名其妙的 mutually exclusive。
解法。 写 Setting 时存真 bool:Setting.objects.update_or_create(name="EMAIL_USE_SSL", defaults={"value": True, "category": "email"}),密码用 encrypted=True 存。敏感值全程不落明文:本机写临时文件 → scp 到跳板机 → docker exec -e JMS_MAIL_PASS="$(cat passfile)" 注入容器环境变量 → 本机、远端、容器三处即焚,明文绝不出现在聊天、回显和日志里。验证也定死:Django 邮件 API 实发一封,send(fail_silently=False) 返回 1 才算真投递,看板绿灯不算。
复盘。 凡是布尔配置进了"值什么都能存"的 KV 表,就要在写入口强制类型。"true" 这种脏值录入时不报错,只会在全链路最后一环以毫不相干的文案爆出来——排查成本全花在猜"SSL 和 TLS 哪来的"。
验收:koko 端到端链路才算数
功能点全部配完,验收我只认一条链路:经堡垒机的 koko 真正连进一台资产执行 hostname,返回结果才算端到端通。命令行复现 koko 接入方式,三层身份串整体作 -l 参数、主机填 koko 自己。别把密码写进命令行(sshpass -p 的明文会进 ps 进程表和 shell history,多人机器上等于公开),首选给 v4 资产加 ssh_key 类型的 Account、绑公钥后走免密:
# 首选:密钥方式,可无人值守跑全矩阵
ssh -o BatchMode=yes -i ~/.ssh/jms_verify_key -p 2222 \
-l "jmsuser@[email protected]" 203.0.113.10 "hostname"
# 仍用密码时的写法:read -s 交互输入(不回显、不进 history),
# 经环境变量交给 sshpass -e,用完即 unset
read -rsp "JMS 用户密码: " JMS_PASS; echo
SSHPASS="$JMS_PASS" sshpass -e ssh -p 2222 \
-l "jmsuser@[email protected]" 203.0.113.10 "hostname"
unset JMS_PASS
要留痕复用就写进 600 权限的临时文件走 sshpass -f,用完 shred。写成 ssh jmsuser@[email protected]@203.0.113.10 不行——sshd 会把目标 IP 当主机解析。SSH 代理端口 2222 还要在云安全组同步放行,漏了就是内网测试全绿、外部接入全红。老跳板机 203.0.113.20 没有立刻停:E2E 通过后观察数日,确认没有旧链路回流的工单才释放。交付物两份——资产盘点清单(含核销的 ghost 机器)和逐台 × 逐账号测试矩阵报告。
来源与适用范围(2026-10 修订新增)
- JumpServer 安全版本线:本文版本与漏洞描述以官方漏洞公告页 jumpserver.org/security.html 为准,依据 2026-09-09 公告 JS-2026.09.09(SQL 查询过滤缺陷,任意已登录普通用户可越权获取管理员 Access Key;受影响 v3 ≥3.7.0 且 <3.10.23 LTS、v4 ≥4.0.0 且 <4.10.19 LTS)。4.10.19 是我迁移时(2026 年 9 月底) v4 线的安全下限;公告会滚动更新,动手前先查最新条目,别把本文版本号当永久结论。
- OpenSSH 算法边界:ed25519 自 6.5(2014-04)引入,rsa-sha2-256/512 自 7.2(2016)引入,ssh-rsa(SHA-1)签名自 8.8(2021-08)起默认禁用——依据 openssh.com 各版本 Release Notes。CentOS 7 的 OpenSSH 5.3p1 早于这些边界,所以老机两者都不认。
- 适用条件:以上结论绑定「CentOS 7(OpenSSH 5.3p1)+ Ubuntu 24.04(OpenSSH 9.6p1)混部机群 + JumpServer v4.10.19 的 koko/SSH 代理链路」这组具体环境。机群里没有 OpenSSH < 6.5 的老货,就直接全线 ed25519,不必走 RSA-3072 过渡;异构设备多(网络设备、老内核)先画 sshd 版本 × 客户端版本兼容矩阵再定算法。
- 性质与脱敏:这是一次真实迁移的踩坑记录,不是产品评测。文中 IP 为 RFC 5737 文档保留段(203.0.113.x / 198.51.100.x),域名、账号名、邮箱均为示意值;凭据一律不落原文。命令请先在测试资产验证,再用于生产。
收个尾
五个坑连起来看其实是同一件事:堡垒机迁移里最难的不是"装起来",而是"每一层都在假装成功"。 config.txt 被只重启一半的组件无视,ORM 给你一个 id 全零的假对象,authorized_keys 静默粘连,paramiko 和 koko 用两套算法互相打脸,邮件开关存了个字符串假开关。应对的招数也都朴素:全局配置就全组件重启;拿不准的对象回库重取;属主、换行、判重逐条防守再全矩阵复测;算法先画兼容矩阵定公约数;布尔强制真值、密码只走交互输入或密钥。希望这五条能替下一个做换代活的人省下五个深夜。
评论