跳转至

跳板服务器

通过反向 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:端口"
             (限制为仅能转发到该用户的隧道端口)

用户连接时的认证流程:

  1. SSH 客户端用用户私钥认证到跳板服务器的 tunnel 用户(仅允许端口转发)
  2. 通过隧道到达工作服务器的 22 端口
  3. 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 中添加:

GatewayPorts clientspecified

然后重启 sshd:sudo systemctl restart sshd

ProxyJump 模式(推荐)

ProxyJump 模式下隧道端口仅绑定跳板服务器的 loopback 接口,不暴露公网,更安全。大多数场景推荐此模式。

第二步:工作服务器 — 安装

  1. 将脚本目录(含 setup.shtunnel.conftunnel@.service 等)复制到工作服务器

  2. 编辑 tunnel.conf,填写跳板服务器信息:

    # tunnel.conf — systemd 模板实例的环境变量
    RELAY_HOST=203.0.113.50    # 跳板服务器 IP 或域名
    RELAY_PORT=22               # 跳板服务器 SSH 端口
    RELAY_USER=tunnel           # 跳板服务器上的隧道用户
    
    # 反向隧道绑定地址 — 留空使用 ProxyJump 模式
    BIND_PREFIX=
    
    # 管理密钥(用于推送/删除用户公钥到跳板服务器)
    RELAY_MGMT_USER=root
    MGMT_KEY=/etc/autossh/management_key
    
  3. 运行安装脚本:

    sudo ./setup.sh
    

    脚本会自动完成:

    • 创建 tunnel-runner 系统用户(最小权限运行隧道)
    • /etc/autossh/ 生成隧道密钥和管理密钥
    • 安装 systemd 模板到 /etc/systemd/system/
    • 获取跳板服务器的主机密钥
    • 创建端口分配表
    • 启动初始隧道实例
  4. 按照安装脚本输出的提示,在跳板服务器上完成密钥配置:

    # 在跳板服务器上执行
    
    # 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

用户管理

添加用户

sudo ./add-user.sh zhangsan \
    --key-file /tmp/zhangsan.pub \
    --port 10001 \
    --remark "研究人员"

脚本会自动完成以下操作:

  1. 在工作服务器创建系统用户(如不存在)
  2. 写入用户公钥到 ~/.ssh/authorized_keys
  3. 通过管理密钥将公钥推送到跳板服务器(带 restrict,port-forwarding,permitopen 限制)
  4. 更新端口分配表 /etc/autossh/port-registry.txt
  5. 启动并启用对应的 systemd 隧道实例

完成后,将以下 SSH 配置发给用户(请替换实际信息):

Host my-server
    HostName localhost
    Port 10001
    ProxyJump tunnel@203.0.113.50
    User zhangsan
    ServerAliveInterval 30

也可以通过 --key 参数直接传入公钥字符串:

sudo ./add-user.sh lisi \
    --key "ssh-ed25519 AAAA... lisi@laptop" \
    --port 10002 \
    --remark "RA"

删除用户

# 保留用户主目录和数据
sudo ./remove-user.sh zhangsan

# 同时删除用户主目录
sudo ./remove-user.sh zhangsan --delete-home

脚本会执行:

  1. 停止并禁用该端口的隧道实例
  2. 从跳板服务器删除对应的 authorized_keys 条目(匹配 permitopen 端口)
  3. 从端口分配表移除记录

单独推送/更新公钥

如果添加用户时未提供密钥,或需要更新密钥:

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 定期检查隧道状态:

# 每 5 分钟检查一次
*/5 * * * * /opt/tunnel/health-check.sh

健康检查脚本会遍历端口分配表中的所有实例,检查 systemd 服务状态,并将故障信息写入 syslog(polyu-tunnel-health 标签)。

查看健康日志:

grep "polyu-tunnel-health" /var/log/syslog

端口分配表

位置:/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 用户只能访问隧道端口:
sudo iptables -A OUTPUT -p tcp --match multiport --dports 10001:10010 \
    -o lo -m owner ! --uid-owner tunnel -j DROP

连接模式对比

模式 安全性 配置复杂度 说明
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.confBIND_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"