跳板服务器
通过反向 SSH 隧道和跳板服务器,让内网用户无需 VPN 即可通过公网中转服务器稳定连接工作服务器。
动机:优化连接稳定性,绕开 VPN 连接要求。
架构概述¶
用户电脑 跳板服务器 (公网) 工作服务器 (内网)
┌─────────────┐
zhangsan ──── ssh ────> :10001│ │
lisi ──── ssh ────> :10002│ 反向隧道 │──── autossh ────> localhost:22
wangwu ──── ssh ────> :10003│ │
└─────────────┘
核心设计:
- systemd 模板实例
tunnel@端口.service,每个端口一个独立进程 - 单个端口故障不影响其他用户
- 增删用户只需启停对应的 systemd 实例,无需编辑配置文件
| 位置 | 角色 | 说明 |
|---|---|---|
| 跳板服务器 | 专用 tunnel 用户,纯管道 | 一次性配置,后续不动 |
| 工作服务器 | systemd 模板 + autossh,每端口一个实例 | 增减用户只在此操作 |
| 用户电脑 | 各连不同端口,用各自账户登录 | 只需配置 SSH config |
密钥与认证设计¶
系统中有三类密钥,各司其职:
| 密钥 | 持有者 | 用途 |
|---|---|---|
隧道密钥 tunnel_key | 工作服务器 | autossh 认证到跳板服务器,建立反向隧道 |
管理密钥 management_key | 工作服务器 | 远程操作跳板服务器,推送/删除用户公钥 |
| 用户密钥 × N | 各用户自己 | 登录工作服务器 + ProxyJump 经过跳板 |
用户公钥的分发是整个设计的核心——同一把公钥写入两台服务器,作用不同:
用户提供公钥给管理员
│
▼
add-user.sh
│
├──> 工作服务器: ~用户名/.ssh/authorized_keys
│ (用于 SSH 登录)
│
└──> 跳板服务器: /home/tunnel/.ssh/authorized_keys
加 restrict,port-forwarding,permitopen="localhost:端口"
(限制为仅能转发到该用户的隧道端口)
用户连接时的认证流程:
- SSH 客户端用用户私钥认证到跳板服务器的
tunnel用户(仅允许端口转发) - 通过隧道到达工作服务器的 22 端口
- SSH 客户端用同一个私钥认证到工作服务器的用户账户
整个过程使用同一把用户密钥完成两次认证。
前置条件¶
- 一台具有公网 IP 的服务器作为跳板(如云服务器)
- 工作服务器能通过 SSH 连接到跳板服务器
- 管理员具有两台服务器的 root 权限
部署步骤¶
第一步:跳板服务器 — 一次性配置¶
SSH 登录跳板服务器,执行以下操作:
# 创建 tunnel 用户(无 shell 登录权限)
sudo useradd -m -s /usr/sbin/nologin tunnel
sudo mkdir -p /home/tunnel/.ssh
sudo chmod 700 /home/tunnel/.ssh
如果使用直连模式(非 ProxyJump),还需要在 /etc/ssh/sshd_config 中添加:
然后重启 sshd:sudo systemctl restart sshd
ProxyJump 模式(推荐)
ProxyJump 模式下隧道端口仅绑定跳板服务器的 loopback 接口,不暴露公网,更安全。大多数场景推荐此模式。
第二步:工作服务器 — 安装¶
-
将脚本目录(含
setup.sh、tunnel.conf、tunnel@.service等)复制到工作服务器 -
编辑
tunnel.conf,填写跳板服务器信息: -
运行安装脚本:
脚本会自动完成:
- 创建
tunnel-runner系统用户(最小权限运行隧道) - 在
/etc/autossh/生成隧道密钥和管理密钥 - 安装 systemd 模板到
/etc/systemd/system/ - 获取跳板服务器的主机密钥
- 创建端口分配表
- 启动初始隧道实例
- 创建
-
按照安装脚本输出的提示,在跳板服务器上完成密钥配置:
# 在跳板服务器上执行 # 1. 写入隧道公钥(restrict 限制为仅端口转发) echo 'restrict,port-forwarding,permitopen="localhost:22" ssh-ed25519 AAAA... tunnel-main' | \ sudo tee /home/tunnel/.ssh/authorized_keys # 2. 将管理公钥加入 root 的 authorized_keys(用于远程推送用户公钥) echo 'ssh-ed25519 AAAA... tunnel-management' | \ sudo tee -a /root/.ssh/authorized_keys # 3. 设置权限 sudo chown -R tunnel:tunnel /home/tunnel/.ssh sudo chmod 600 /home/tunnel/.ssh/authorized_keys
第三步:验证¶
# 在工作服务器上检查隧道状态
systemctl status tunnel@10001
# 查看所有隧道实例
systemctl list-units 'tunnel@*'
# 查看端口分配表
cat /etc/autossh/port-registry.txt
# 从外部连接测试
ssh my-server
用户管理¶
添加用户¶
脚本会自动完成以下操作:
- 在工作服务器创建系统用户(如不存在)
- 写入用户公钥到
~/.ssh/authorized_keys - 通过管理密钥将公钥推送到跳板服务器(带
restrict,port-forwarding,permitopen限制) - 更新端口分配表
/etc/autossh/port-registry.txt - 启动并启用对应的 systemd 隧道实例
完成后,将以下 SSH 配置发给用户(请替换实际信息):
Host my-server
HostName localhost
Port 10001
ProxyJump tunnel@203.0.113.50
User zhangsan
ServerAliveInterval 30
也可以通过 --key 参数直接传入公钥字符串:
删除用户¶
# 保留用户主目录和数据
sudo ./remove-user.sh zhangsan
# 同时删除用户主目录
sudo ./remove-user.sh zhangsan --delete-home
脚本会执行:
- 停止并禁用该端口的隧道实例
- 从跳板服务器删除对应的 authorized_keys 条目(匹配
permitopen端口) - 从端口分配表移除记录
单独推送/更新公钥¶
如果添加用户时未提供密钥,或需要更新密钥:
sudo ./push-key.sh zhangsan --key-file /tmp/zhangsan_new.pub
# 或
sudo ./push-key.sh zhangsan --key "ssh-ed25519 AAAA... zhangsan@new-laptop"
systemd 模板管理¶
隧道使用 systemd 模板实例管理,定义如下:
# /etc/systemd/system/tunnel@.service
[Unit]
Description=Reverse SSH Tunnel - port %i
After=network-online.target ssh.service
Wants=network-online.target
[Service]
Type=simple
User=tunnel-runner
EnvironmentFile=/etc/autossh/tunnel.conf
ExecStart=/usr/bin/autossh -M 0 -N \
-R %i:localhost:22 \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "Port=${RELAY_PORT}" \
-o "StrictHostKeyChecking=yes" \
-o "UserKnownHostsFile=/etc/autossh/known_hosts" \
-o "ExitOnForwardFailure=yes" \
-o "IdentitiesOnly=yes" \
-i /etc/autossh/tunnel_key \
${RELAY_USER}@${RELAY_HOST}
Restart=always
RestartSec=10
StartLimitIntervalSec=300
StartLimitBurst=5
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/etc/autossh
RuntimeDirectory=tunnel-%i
SyslogIdentifier=tunnel@%i
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
常用操作:
systemctl status tunnel@10001 # 查看单个隧道状态
systemctl restart tunnel@10001 # 重启单个隧道
systemctl stop tunnel@10001 # 停止
systemctl list-units 'tunnel@*' # 查看所有实例
journalctl -u tunnel@10001 -f # 实时查看日志
健康监控¶
建议配置 cron 定期检查隧道状态:
健康检查脚本会遍历端口分配表中的所有实例,检查 systemd 服务状态,并将故障信息写入 syslog(polyu-tunnel-health 标签)。
查看健康日志:
端口分配表¶
位置:/etc/autossh/port-registry.txt
# 端口 用户名 创建日期 备注
10001 zhangsan 2026-01-15 管理员
10002 lisi 2026-01-16 研究员
10003 wangwu 2026-01-20 访问学者
安全措施¶
| 措施 | 说明 |
|---|---|
| systemd 模板实例 | 每端口独立进程,单点故障不影响他人 |
专用系统用户 tunnel-runner | 不以 root 运行,最小权限原则 |
restrict + permitopen | 跳板服务器限制 tunnel 用户仅可端口转发,无法开 shell;即使密钥泄露也只能转发到 localhost:22 |
StrictHostKeyChecking=yes | 预存主机密钥,防止中间人攻击 |
ExitOnForwardFailure=yes | 端口绑定失败立即报错,不静默运行 |
跳板服务器被攻破的影响
攻击者获得跳板服务器控制权后,可以**通过网络直连工作服务器的 SSH 端口**,但**仍需有效的账户凭证(私钥或密码)才能登录**。SSH 连接是端到端加密的,跳板无法解密或劫持已有会话。
主要风险:
- 攻击者可对工作服务器进行 SSH 暴力破解或漏洞利用
- 可中断隧道造成拒绝服务
缓解措施:
- 跳板服务器保持最小化部署
- 工作服务器使用强密钥认证并禁用密码登录
- 使用 iptables 限制 tunnel 用户只能访问隧道端口:
连接模式对比¶
| 模式 | 安全性 | 配置复杂度 | 说明 |
|---|---|---|---|
| ProxyJump(推荐) | 高 | 低 | 隧道端口仅绑定 loopback,用户通过 SSH ProxyJump 中转 |
| 直连 | 较低 | 中 | 需开启 GatewayPorts=clientspecified,端口暴露公网 |
ProxyJump 模式下用户 SSH config 示例:
Host my-server
HostName localhost
Port 10001
ProxyJump tunnel@203.0.113.50
User zhangsan
ServerAliveInterval 30
直连模式需将 tunnel.conf 中 BIND_PREFIX 改为 0.0.0.0:,用户 SSH config 中 HostName 直接填跳板服务器地址。
服务器本身有公网 IP?
如果目标服务器本身有公网 IP,只是需要通过跳板来绕过 IP 白名单,不需要反向隧道。直接用标准 SSH ProxyJump 即可:
Host jump-server
HostName 100.100.100.100
User <跳板账户>
IdentityFile "<私钥路径>"
Host work-server
HostName 200.200.200.200
User <目标账户>
IdentityFile "<私钥路径>"
ProxyJump jump-server
命令行等价写法:ssh -J 跳板账户@100.100.100.100 目标账户@200.200.200.200
脚本源码¶
以下脚本取自实际部署,已脱敏。请根据实际环境修改服务名称、跳板服务器地址等。
setup.sh — 首次安装
#!/usr/bin/env bash
# ============================================================
# Reverse SSH Tunnel — 首次安装
# 需 sudo 执行,仅运行一次
# ============================================================
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "[!] 请用 sudo 执行"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
AUTOSSH_DIR="/etc/autossh"
SERVICE_SRC="${SCRIPT_DIR}/tunnel@.service"
ENV_SRC="${SCRIPT_DIR}/tunnel.conf"
echo "===== Reverse SSH Tunnel 安装 ====="
echo ""
# ---- 1. 创建专用系统用户 ----
echo "[1/8] 创建 tunnel-runner 系统用户 ..."
if id tunnel-runner &>/dev/null; then
echo " ✓ 已存在"
else
useradd -r -s /usr/sbin/nologin -d "${AUTOSSH_DIR}" tunnel-runner
echo " ✓ 已创建"
fi
# ---- 2. 创建目录 ----
echo "[2/8] 创建 ${AUTOSSH_DIR}/ ..."
mkdir -p "${AUTOSSH_DIR}"
# ---- 3. 生成隧道密钥 ----
echo "[3/8] 生成隧道密钥 ..."
if [ -f "${AUTOSSH_DIR}/tunnel_key" ]; then
echo " ✓ 隧道密钥已存在,跳过"
else
ssh-keygen -t ed25519 -f "${AUTOSSH_DIR}/tunnel_key" -N "" -C "tunnel-main"
echo " ✓ 已生成"
fi
# ---- 4. 生成管理密钥(用于推送/删除用户公钥到跳板服务器)----
echo "[4/8] 生成管理密钥 ..."
if [ -f "${AUTOSSH_DIR}/management_key" ]; then
echo " ✓ 管理密钥已存在,跳过"
else
ssh-keygen -t ed25519 -f "${AUTOSSH_DIR}/management_key" -N "" -C "tunnel-management"
echo " ✓ 已生成"
fi
# ---- 5. 获取跳板服务器主机密钥 ----
echo "[5/8] 获取跳板服务器主机密钥 ..."
source "${ENV_SRC}"
if [ -f "${AUTOSSH_DIR}/known_hosts" ] && grep -q "${RELAY_HOST}" "${AUTOSSH_DIR}/known_hosts"; then
echo " ✓ known_hosts 已存在"
else
ssh-keyscan -p "${RELAY_PORT}" "${RELAY_HOST}" > "${AUTOSSH_DIR}/known_hosts" 2>/dev/null
LINES=$(wc -l < "${AUTOSSH_DIR}/known_hosts")
if [ "${LINES}" -eq 0 ]; then
echo " ✗ 无法获取主机密钥,检查跳板服务器是否可达"
exit 1
fi
echo " ✓ 获取 ${LINES} 个密钥"
fi
# ---- 6. 安装配置文件 ----
echo "[6/8] 安装配置文件 ..."
cp "${ENV_SRC}" "${AUTOSSH_DIR}/tunnel.conf"
cp "${SERVICE_SRC}" /etc/systemd/system/tunnel@.service
echo " ✓ tunnel.conf + tunnel@.service 已安装"
if [ ! -f "${AUTOSSH_DIR}/port-registry.txt" ]; then
TODAY=$(date '+%Y-%m-%d')
ADMIN_USER=$(logname 2>/dev/null || echo "admin")
cat > "${AUTOSSH_DIR}/port-registry.txt" << EOF
# 端口分配表
# 格式:端口 用户名 创建日期 备注
10001 ${ADMIN_USER} ${TODAY} 管理员
EOF
echo " ✓ 端口分配表已创建"
else
echo " ✓ 端口分配表已存在"
fi
# ---- 7. 设置权限 ----
echo "[7/8] 设置文件权限 ..."
chown tunnel-runner:tunnel-runner "${AUTOSSH_DIR}/tunnel_key"
chmod 600 "${AUTOSSH_DIR}/tunnel_key"
chown root:root "${AUTOSSH_DIR}/management_key"
chmod 600 "${AUTOSSH_DIR}/management_key"
chown root:root "${AUTOSSH_DIR}/tunnel_key.pub" "${AUTOSSH_DIR}/management_key.pub" \
"${AUTOSSH_DIR}/known_hosts" "${AUTOSSH_DIR}/tunnel.conf" "${AUTOSSH_DIR}/port-registry.txt"
chmod 644 "${AUTOSSH_DIR}/tunnel_key.pub" "${AUTOSSH_DIR}/management_key.pub" \
"${AUTOSSH_DIR}/known_hosts" "${AUTOSSH_DIR}/tunnel.conf" "${AUTOSSH_DIR}/port-registry.txt"
echo " ✓ 权限已设置"
# ---- 8. 停旧进程、启动服务 ----
echo "[8/8] 停旧进程并启动服务 ..."
OLD_PIDS=$(pgrep -f "autossh.*-R" 2>/dev/null || true)
if [ -n "${OLD_PIDS}" ]; then
echo " 停止旧 autossh 进程: ${OLD_PIDS}"
kill ${OLD_PIDS} 2>/dev/null || true
sleep 1
fi
systemctl daemon-reload
FIRST_PORT=$(awk '/^[0-9]/{print $1; exit}' "${AUTOSSH_DIR}/port-registry.txt")
if [ -n "${FIRST_PORT}" ]; then
systemctl enable "tunnel@${FIRST_PORT}"
systemctl start "tunnel@${FIRST_PORT}"
sleep 2
if systemctl is-active --quiet "tunnel@${FIRST_PORT}"; then
echo " ✓ tunnel@${FIRST_PORT} 已启动"
else
echo " ✗ 启动失败(公钥可能尚未添加到跳板服务器)"
echo " 查看日志: journalctl -u tunnel@${FIRST_PORT} --no-pager -n 20"
fi
fi
echo ""
echo "===== 安装完成 ====="
echo ""
echo "【下一步】在跳板服务器上执行以下命令:"
echo ""
echo " ─── 1. 创建 tunnel 用户 ───"
echo " sudo useradd -m -s /usr/sbin/nologin tunnel"
echo " sudo mkdir -p /home/tunnel/.ssh"
echo ""
echo " ─── 2. 写入隧道公钥(用来建隧道的)───"
echo " echo 'restrict,port-forwarding,permitopen=\"localhost:22\" $(cat ${AUTOSSH_DIR}/tunnel_key.pub)' | \\"
echo " sudo tee /home/tunnel/.ssh/authorized_keys"
echo ""
echo " ─── 3. 将管理公钥加入 root 的 authorized_keys ───"
echo " echo '$(cat ${AUTOSSH_DIR}/management_key.pub)' | \\"
echo " sudo tee -a /root/.ssh/authorized_keys"
echo ""
echo " sudo chown -R tunnel:tunnel /home/tunnel/.ssh"
echo " sudo chmod 700 /home/tunnel/.ssh"
echo " sudo chmod 600 /home/tunnel/.ssh/authorized_keys"
add-user.sh — 添加用户
#!/usr/bin/env bash
# ============================================================
# 新增隧道用户 + 推送公钥到跳板服务器
# 用法: sudo ./add-user.sh <用户名> --key-file <公钥文件> | --key <公钥字符串> [--port 端口] [--remark 备注]
# ============================================================
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "[!] 请用 sudo 执行"
exit 1
fi
# ---- 解析参数 ----
USERNAME=""
PUBKEY_FILE=""
PUBKEY_INLINE=""
PORT=""
REMARK=""
while [ $# -gt 0 ]; do
case "$1" in
--key-file)
PUBKEY_FILE="${2:?--key-file 需要文件路径}"
shift 2
;;
--key)
PUBKEY_INLINE="${2:?--key 需要公钥字符串}"
shift 2
;;
--port)
PORT="${2:?--port 需要端口号}"
shift 2
;;
--remark)
REMARK="${2:?--remark 需要备注文本}"
shift 2
;;
-*)
echo "[!] 未知选项: $1"
exit 1
;;
*)
if [ -z "${USERNAME}" ]; then
USERNAME="$1"
else
echo "[!] 多余参数: $1"
exit 1
fi
shift
;;
esac
done
if [ -z "${USERNAME}" ]; then
echo "用法: sudo ./add-user.sh <用户名> --key-file <公钥文件> | --key <公钥字符串> [--port 端口] [--remark 备注]"
exit 1
fi
if [ -n "${PUBKEY_FILE}" ] && [ -n "${PUBKEY_INLINE}" ]; then
echo "[!] --key-file 和 --key 不能同时使用"
exit 1
fi
# ---- 加载配置 ----
source /etc/autossh/tunnel.conf
REGISTRY="/etc/autossh/port-registry.txt"
# ---- 输入校验 ----
if ! [[ "${USERNAME}" =~ ^[a-z_][a-z0-9_-]*$ ]]; then
echo "[!] 用户名不合法,仅允许小写字母、数字、下划线、连字符"
exit 1
fi
if grep -qP "^\d+\s+${USERNAME}\b" "${REGISTRY}" 2>/dev/null; then
echo "[!] 用户 ${USERNAME} 已存在于端口分配表:"
grep -P "^\d+\s+${USERNAME}\b" "${REGISTRY}"
exit 1
fi
# 校验公钥
if [ -n "${PUBKEY_INLINE}" ]; then
PUBKEY_CONTENT=$(echo "${PUBKEY_INLINE}" | grep -v '^#' | grep -v '^$' | head -1)
if ! echo "${PUBKEY_CONTENT}" | grep -qE '^(ssh-(ed25519|rsa|ecdsa)|ecdsa-sha2)'; then
echo "[!] 公钥字符串格式不正确"
exit 1
fi
elif [ -n "${PUBKEY_FILE}" ]; then
if [ ! -f "${PUBKEY_FILE}" ]; then
echo "[!] 公钥文件不存在: ${PUBKEY_FILE}"
exit 1
fi
PUBKEY_CONTENT=$(grep -v '^#' "${PUBKEY_FILE}" | grep -v '^$' | head -1)
if ! echo "${PUBKEY_CONTENT}" | grep -qE '^(ssh-(ed25519|rsa|ecdsa)|ecdsa-sha2)'; then
echo "[!] 文件内容不像 SSH 公钥: ${PUBKEY_FILE}"
exit 1
fi
else
PUBKEY_CONTENT=""
echo "[*] 未提供公钥,将只创建用户和隧道,不推送公钥到跳板服务器"
echo " 后续可用: sudo ./push-key.sh ${USERNAME} --key-file <公钥文件>"
fi
# 自动分配端口
if [ -z "${PORT}" ]; then
MAX_PORT=$(awk '/^[0-9]/{print $1}' "${REGISTRY}" | sort -n | tail -1)
MAX_PORT=${MAX_PORT:-10000}
PORT=$((MAX_PORT + 1))
echo "[*] 自动分配端口: ${PORT}"
fi
if ! [[ "${PORT}" =~ ^[0-9]+$ ]] || [ "${PORT}" -lt 1024 ] || [ "${PORT}" -gt 65535 ]; then
echo "[!] 端口号必须在 1024-65535 之间"
exit 1
fi
if grep -qP "^${PORT}\s" "${REGISTRY}" 2>/dev/null; then
echo "[!] 端口 ${PORT} 已被占用:"
grep -P "^${PORT}\s" "${REGISTRY}"
exit 1
fi
# ---- 创建系统用户 ----
if ! id "${USERNAME}" &>/dev/null; then
echo "[*] 创建系统用户 ${USERNAME} ..."
useradd -m -s /bin/bash "${USERNAME}"
USER_HOME=$(eval echo "~${USERNAME}")
mkdir -p "${USER_HOME}/.ssh"
touch "${USER_HOME}/.ssh/authorized_keys"
chmod 700 "${USER_HOME}/.ssh"
chmod 600 "${USER_HOME}/.ssh/authorized_keys"
chown -R "${USERNAME}:${USERNAME}" "${USER_HOME}/.ssh"
echo " ✓ 用户已创建"
else
echo "[*] 用户 ${USERNAME} 已存在,跳过创建"
USER_HOME=$(eval echo "~${USERNAME}")
mkdir -p "${USER_HOME}/.ssh"
touch "${USER_HOME}/.ssh/authorized_keys"
chmod 700 "${USER_HOME}/.ssh"
chmod 600 "${USER_HOME}/.ssh/authorized_keys"
chown -R "${USERNAME}:${USERNAME}" "${USER_HOME}/.ssh"
fi
# ---- 写入公钥到工作服务器 ----
if [ -n "${PUBKEY_CONTENT}" ]; then
USER_HOME=$(eval echo "~${USERNAME}")
if grep -qF "${PUBKEY_CONTENT}" "${USER_HOME}/.ssh/authorized_keys" 2>/dev/null; then
echo "[*] 公钥已存在于 ${USERNAME} 的 authorized_keys"
else
echo "${PUBKEY_CONTENT}" >> "${USER_HOME}/.ssh/authorized_keys"
chown "${USERNAME}:${USERNAME}" "${USER_HOME}/.ssh/authorized_keys"
echo "[✓] 公钥已写入 ${USER_HOME}/.ssh/authorized_keys"
fi
fi
# ---- 推送公钥到跳板服务器 ----
if [ -n "${PUBKEY_CONTENT}" ]; then
echo "[*] 推送公钥到跳板服务器 ..."
RELAY_AK_ENTRY="restrict,port-forwarding,permitopen=\"localhost:${PORT}\" ${PUBKEY_CONTENT}"
SSH_CMD="ssh -i ${MGMT_KEY} \
-o Port=${RELAY_PORT} \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/autossh/known_hosts \
-o IdentitiesOnly=yes \
-o ConnectTimeout=15 \
${RELAY_MGMT_USER}@${RELAY_HOST}"
if ${SSH_CMD} "grep -qF '${PUBKEY_CONTENT}' /home/tunnel/.ssh/authorized_keys 2>/dev/null"; then
echo " ✓ 公钥已存在于跳板服务器"
else
${SSH_CMD} "echo '${RELAY_AK_ENTRY}' >> /home/tunnel/.ssh/authorized_keys" && \
echo " ✓ 公钥已推送到跳板服务器 tunnel authorized_keys" || \
echo " [!] 推送失败 — 管理密钥可能未添加到跳板服务器 root 的 authorized_keys"
fi
fi
# ---- 更新端口分配表 ----
TODAY=$(date '+%Y-%m-%d')
printf "%s\t%s\t%s\t%s\n" "${PORT}" "${USERNAME}" "${TODAY}" "${REMARK}" >> "${REGISTRY}"
echo "[✓] 端口分配表已更新"
# ---- 启动隧道实例 ----
echo "[*] 启动 tunnel@${PORT} ..."
systemctl enable "tunnel@${PORT}" 2>/dev/null
systemctl start "tunnel@${PORT}"
sleep 2
if systemctl is-active --quiet "tunnel@${PORT}"; then
echo "[✓] 隧道已启动"
else
echo "[✗] 隧道启动失败:"
journalctl -u "tunnel@${PORT}" --no-pager -n 10
exit 1
fi
# ---- 输出客户端配置 ----
echo ""
echo "===== 用户 ${USERNAME} 已添加 (端口 ${PORT}) ====="
echo ""
echo "将以下配置发给 ${USERNAME},添加到 ~/.ssh/config:"
echo ""
echo " Host my-server"
echo " HostName localhost"
echo " Port ${PORT}"
echo " ProxyJump tunnel@${RELAY_HOST}"
echo " User ${USERNAME}"
echo " ServerAliveInterval 30"
remove-user.sh — 删除用户
#!/usr/bin/env bash
# ============================================================
# 删除隧道用户 + 从跳板服务器清除公钥
# 用法: sudo ./remove-user.sh <用户名> [--delete-home]
# ============================================================
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "[!] 请用 sudo 执行"
exit 1
fi
USERNAME="${1:?用法: sudo ./remove-user.sh <用户名> [--delete-home]}"
DELETE_HOME="${2:-}"
source /etc/autossh/tunnel.conf
REGISTRY="/etc/autossh/port-registry.txt"
# 查找端口
PORT=$(awk -v u="${USERNAME}" '$2 == u {print $1}' "${REGISTRY}")
if [ -z "${PORT}" ]; then
echo "[!] 用户 ${USERNAME} 不在端口分配表中"
exit 1
fi
echo "[*] 用户 ${USERNAME} — 端口 ${PORT}"
echo "[!] 确认移除?(y/N)"
read -r CONFIRM
if [ "${CONFIRM}" != "y" ] && [ "${CONFIRM}" != "Y" ]; then
echo "已取消"
exit 0
fi
# ---- 停止并禁用隧道实例 ----
echo "[*] 停止 tunnel@${PORT} ..."
systemctl disable --now "tunnel@${PORT}" 2>/dev/null || true
echo " ✓ 已停止"
# ---- 从跳板服务器删除公钥 ----
echo "[*] 从跳板服务器删除公钥 (匹配 permitopen 端口 ${PORT}) ..."
SSH_CMD="ssh -i ${MGMT_KEY} \
-o Port=${RELAY_PORT} \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/autossh/known_hosts \
-o IdentitiesOnly=yes \
-o ConnectTimeout=15 \
${RELAY_MGMT_USER}@${RELAY_HOST}"
# 匹配包含 permitopen="localhost:PORT" 的行并删除
if ${SSH_CMD} "sed -i '/permitopen=\"localhost:${PORT}\"/d' /home/tunnel/.ssh/authorized_keys" 2>/dev/null; then
echo " ✓ 已从跳板服务器删除"
else
echo " [!] 删除失败或无匹配条目 — 管理密钥可能未配置"
fi
# ---- 从端口分配表移除 ----
sed -i "/^\s*${PORT}\s\+/d" "${REGISTRY}"
echo " ✓ 端口分配表已更新"
# ---- 清理用户 ----
if [ "${DELETE_HOME}" = "--delete-home" ]; then
echo "[*] 删除用户 ${USERNAME} 及主目录 ..."
userdel -r "${USERNAME}" 2>/dev/null && echo " ✓ 已删除" || echo " ! 用户可能不存在"
else
echo "[*] 保留 ${USERNAME} 的系统账户和数据"
echo " 如需删除: sudo userdel -r ${USERNAME}"
fi
echo ""
echo "===== ${USERNAME} 已从隧道移除 (端口 ${PORT}) ====="
push-key.sh — 推送/更新公钥
#!/usr/bin/env bash
# ============================================================
# 单独推送/更新用户公钥(用于 add-user 时未提供密钥的场景)
# 用法: sudo ./push-key.sh <用户名> --key-file <公钥文件>
# sudo ./push-key.sh <用户名> --key <公钥字符串>
# ============================================================
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "[!] 请用 sudo 执行"
exit 1
fi
USERNAME=""
PUBKEY_FILE=""
PUBKEY_INLINE=""
# 第一个非选项参数是用户名
while [ $# -gt 0 ]; do
case "$1" in
--key-file)
PUBKEY_FILE="${2:?--key-file 需要文件路径}"
shift 2
;;
--key)
PUBKEY_INLINE="${2:?--key 需要公钥字符串}"
shift 2
;;
-*)
echo "[!] 未知选项: $1"
exit 1
;;
*)
if [ -z "${USERNAME}" ]; then
USERNAME="$1"
else
echo "[!] 多余参数: $1"
exit 1
fi
shift
;;
esac
done
if [ -z "${USERNAME}" ]; then
echo "用法: sudo ./push-key.sh <用户名> --key-file <公钥文件>"
echo " sudo ./push-key.sh <用户名> --key <公钥字符串>"
exit 1
fi
if [ -z "${PUBKEY_FILE}" ] && [ -z "${PUBKEY_INLINE}" ]; then
echo "[!] 请提供 --key-file 或 --key"
exit 1
fi
if [ -n "${PUBKEY_FILE}" ] && [ -n "${PUBKEY_INLINE}" ]; then
echo "[!] --key-file 和 --key 不能同时使用"
exit 1
fi
source /etc/autossh/tunnel.conf
REGISTRY="/etc/autossh/port-registry.txt"
# 查找端口
PORT=$(awk -v u="${USERNAME}" '$2 == u {print $1}' "${REGISTRY}")
if [ -z "${PORT}" ]; then
echo "[!] 用户 ${USERNAME} 不在端口分配表中"
exit 1
fi
# 获取公钥内容
if [ -n "${PUBKEY_INLINE}" ]; then
PUBKEY_CONTENT=$(echo "${PUBKEY_INLINE}" | grep -v '^#' | grep -v '^$' | head -1)
if ! echo "${PUBKEY_CONTENT}" | grep -qE '^(ssh-(ed25519|rsa|ecdsa)|ecdsa-sha2)'; then
echo "[!] 公钥字符串格式不正确"
exit 1
fi
else
if [ ! -f "${PUBKEY_FILE}" ]; then
echo "[!] 公钥文件不存在: ${PUBKEY_FILE}"
exit 1
fi
PUBKEY_CONTENT=$(grep -v '^#' "${PUBKEY_FILE}" | grep -v '^$' | head -1)
if ! echo "${PUBKEY_CONTENT}" | grep -qE '^(ssh-(ed25519|rsa|ecdsa)|ecdsa-sha2)'; then
echo "[!] 文件内容不像 SSH 公钥"
exit 1
fi
fi
# ---- 写入工作服务器 authorized_keys ----
USER_HOME=$(eval echo "~${USERNAME}")
if [ -d "${USER_HOME}/.ssh" ]; then
if grep -qF "${PUBKEY_CONTENT}" "${USER_HOME}/.ssh/authorized_keys" 2>/dev/null; then
echo "[*] 公钥已存在于工作服务器 authorized_keys"
else
echo "${PUBKEY_CONTENT}" >> "${USER_HOME}/.ssh/authorized_keys"
chown "${USERNAME}:${USERNAME}" "${USER_HOME}/.ssh/authorized_keys"
echo "[✓] 公钥已写入工作服务器 ${USERNAME} 的 authorized_keys"
fi
fi
# ---- 推送到跳板服务器 ----
echo "[*] 推送公钥到跳板服务器 (端口 ${PORT}) ..."
RELAY_AK_ENTRY="restrict,port-forwarding,permitopen=\"localhost:${PORT}\" ${PUBKEY_CONTENT}"
SSH_CMD="ssh -i ${MGMT_KEY} \
-o Port=${RELAY_PORT} \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/autossh/known_hosts \
-o IdentitiesOnly=yes \
-o ConnectTimeout=15 \
${RELAY_MGMT_USER}@${RELAY_HOST}"
if ${SSH_CMD} "grep -qF '${PUBKEY_CONTENT}' /home/tunnel/.ssh/authorized_keys 2>/dev/null"; then
echo " ✓ 公钥已存在于跳板服务器"
else
${SSH_CMD} "echo '${RELAY_AK_ENTRY}' >> /home/tunnel/.ssh/authorized_keys" && \
echo "[✓] 公钥已推送到跳板服务器" || \
echo "[!] 推送失败 — 检查管理密钥是否已添加到跳板服务器"
fi
echo ""
echo "===== 完成 ====="
echo "用户 ${USERNAME} (端口 ${PORT}) 的 SSH config:"
echo ""
echo " Host my-server"
echo " HostName localhost"
echo " Port ${PORT}"
echo " ProxyJump tunnel@${RELAY_HOST}"
echo " User ${USERNAME}"
echo " ServerAliveInterval 30"