#!/bin/sh # rdbg v2 目标机启动脚本(模板;由 scripts/v2/render.sh 渲染后发布) # 现场用法:curl -fsSL https://rdbg.rbprer.com | sh(无 curl 时 wget -qO- … | sh); # 正常只输出"正在连接…"和"编号 xxxxxxxx,Ctrl-C 断开"两行。所有文件都在 # mktemp -d 的临时目录里(HOME/XDG_* 也指向它),不写用户目录;任何退出路径 # 都注销登记、停止 upterm、删除临时目录。依赖:curl/wget、ssh-keygen、 # sha256sum、gzip;POSIX sh(dash/ash/bash 均可)。 set -eu BASE_URL=${RDBG_BASE_URL:-https://rdbg.rbprer.com} SSH_FALLBACK=${RDBG_SSH_FALLBACK:-rdbg.rbprer.com:2223} CACERT=${RDBG_CACERT:-} MAX_SECONDS=${RDBG_MAX_SECONDS:-28800} case "$MAX_SECONDS" in ''|*[!0-9]*) MAX_SECONDS=28800 ;; esac HTTP_TIMEOUT=20 # 部署时由 render.sh 写入:网关客户端公钥(目标机 --authorized-keys 的唯一一行)、 # uptermd 主机指纹(WSS 与 SSH 两种连法各一行)、两个架构 upterm 二进制的 sha256。 GATEWAY_PUBKEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIISTDxwAql8v3n8H0NcXBxYxnl8rFwe1k+WkLpXvvhR7' KNOWN_HOSTS_DATA='[rdbg.rbprer.com]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEbJo2i8od5tQklYE1LJwc+RJ5lAMHievYxrvRtWTWb [rdbg.rbprer.com]:2223 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBEbJo2i8od5tQklYE1LJwc+RJ5lAMHievYxrvRtWTWb' SHA256_AMD64='5b30b3667001c45af415edaa2a7a96c9ec433e5e298587149a7658e76e8483de' SHA256_ARM64='d3a8b595c81b317621314ad162a0075b12154b433936c0ee6432a8c3a70322bf' # 运行状态(shell 没有局部变量,跨函数共享的状态集中在这里) CLEANED=0 USE_CURL=0 WGET_CA_FLAG=0 HTTP_CODE=000 TMPD='' UPTERM='' UPTERM_PID='' START_PID='' SNAME='' SESSION_ID='' LEASE='' HB='' HB_REASON='' ARCH='' RAW_ARCH='' WANT_SHA='' MAC='' CODE='' HOSTNAME='' OS='' ARCH_LABEL='' have() { command -v "$1" >/dev/null 2>&1; } die() { printf 'rdbg: %s\n' "$*" >&2; cleanup; exit 1; } # 退出清理:停 upterm 会话 → 杀自己记录的进程 → 注销登记 → 删临时目录。 # 幂等;只按自己记录的 PID 杀进程,绝不用 pkill/killall。 cleanup() { [ "$CLEANED" -eq 1 ] && return 0 CLEANED=1 trap '' INT TERM HUP EXIT if [ -n "$SNAME" ] && [ -x "$UPTERM" ]; then "$UPTERM" session stop "$SNAME" >/dev/null 2>&1 || true fi for pid in "$UPTERM_PID" "$START_PID"; do if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true fi done if [ -n "$LEASE" ] && [ -n "$TMPD" ] && [ -d "$TMPD" ]; then HTTP_TIMEOUT=5 http_post /api/target/unregister "{\"lease\":\"$LEASE\"}" "$TMPD/unregister.out" >/dev/null 2>&1 || true fi [ -n "$TMPD" ] && rm -rf "$TMPD" return 0 } # 8 位十六进制;输入只有本机的 MAC / 主机名 hash8() { if have sha256sum; then h=$(sha256sum) elif have md5sum; then h=$(md5sum) else return 1 fi printf '%s' "${h%% *}" | cut -c1-8 } # 默认路由网卡的 MAC;没有默认路由时退到第一个非 lo 网卡 primary_mac() { dev= if have ip; then dev=$(ip route show default 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -n 1) fi if [ -z "$dev" ] && [ -r /proc/net/route ]; then while read -r idev idest _; do [ "$idest" = "00000000" ] && dev=$idev && break done < /proc/net/route fi if [ -z "$dev" ] && [ -d /sys/class/net ]; then for d in /sys/class/net/*; do [ -e "$d/address" ] && [ "${d##*/}" != "lo" ] && dev=${d##*/} && break done fi [ -n "$dev" ] || return 1 addr=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || return 1 printf '%s' "$addr" | tr '[:upper:]' '[:lower:]' } # 出厂 MAC:物理网卡(type=1)中地址为出厂烧录(addr_assign_type=0)、不是 USB # 网卡、首字节局部位为 0 的最小一个。不随刷镜像、改主机名、换网卡、重启而变; # 找不到时返回失败(调用方退回"联网网卡 MAC + 主机名")。 stable_mac() { for d in /sys/class/net/*; do [ -e "$d/device" ] || continue [ "$(cat "$d/type" 2>/dev/null)" = 1 ] || continue [ "$(cat "$d/addr_assign_type" 2>/dev/null)" = 0 ] || continue case "$(readlink -f "$d/device" 2>/dev/null)" in */usb*) continue ;; esac a=$(tr '[:upper:]' '[:lower:]' < "$d/address" 2>/dev/null) || continue [ "$a" = 00:00:00:00:00:00 ] && continue [ $((0x${a%%:*} & 2)) -eq 0 ] && printf '%s\n' "$a" done | sort | head -n 1 | grep . } # 一次传输(POST 体为空即 GET);正文写 $2,状态码放 HTTP_CODE。 # 返回 0 表示拿到了状态码(4xx/5xx 也算拿到;000 = 连不上或超时)。 http_xfer() { # $1 url $2 输出文件 $3 超时秒 $4 POST 体 url=$1 outf=$2 tmo=$3 body=$4 err=$TMPD/http.err rm -f "$outf" "$err" if [ "$USE_CURL" -eq 1 ]; then if [ -n "$body" ]; then set -- -H 'Content-Type: application/json' --data-binary "$body" else set -- fi [ -n "$CACERT" ] && set -- --cacert "$CACERT" "$@" HTTP_CODE=$(curl -sS --max-time "$tmo" -o "$outf" -w '%{http_code}' "$@" "$url" 2>"$err") || HTTP_CODE=000 else [ -n "$CACERT" ] && [ "$WGET_CA_FLAG" -eq 1 ] && set -- --ca-certificate "$CACERT" [ -n "$body" ] && set -- "$@" --header 'Content-Type: application/json' --post-data "$body" wget "$@" -q -S -T "$tmo" -O "$outf" "$url" 2>"$err" || true # 状态码从 wget -S 的响应头里取(busybox/GNU 都可以) HTTP_CODE=$(sed -n 's|^[[:space:]]*HTTP/[0-9.]*[[:space:]]\{1,\}\([0-9][0-9][0-9]\).*|\1|p' "$err" 2>/dev/null | tail -n 1) [ -n "$HTTP_CODE" ] || HTTP_CODE=000 fi [ "$HTTP_CODE" != 000 ] } # POST JSON 到服务器;响应正文写到 $3 http_post() { http_xfer "$BASE_URL$1" "$3" "$HTTP_TIMEOUT" "$2"; } # 下载工具、关键依赖、测试用 CA(仍然只做校验:curl --cacert / wget # --ca-certificate / SSL_CERT_FILE,没有跳过校验的选项) init_env() { if have curl; then USE_CURL=1 elif have wget; then wget --help 2>&1 | grep -q -- '--ca-certificate' && WGET_CA_FLAG=1 else die "缺少 curl 或 wget" fi have ssh-keygen || die "缺少 ssh-keygen" have sha256sum || die "缺少 sha256sum" if [ -n "$CACERT" ]; then [ -r "$CACERT" ] || die "RDBG_CACERT 指定的 CA 文件不可读:$CACERT" SSL_CERT_FILE=$CACERT # wget(无 --ca-certificate 时)与下载来的 upterm(Go 程序)都读它 export SSL_CERT_FILE fi } # 临时目录 + 信号陷阱 + 隔离的 HOME/XDG:目标机上不写任何用户目录 make_workspace() { TMPD=$(mktemp -d "${TMPDIR:-/tmp}/rdbg.XXXXXX") || die "无法创建临时目录" chmod 700 "$TMPD" trap 'cleanup; exit 130' INT trap 'cleanup; exit 143' TERM trap 'cleanup; exit 129' HUP trap 'cleanup' EXIT HOME=$TMPD/home XDG_STATE_HOME=$TMPD/state XDG_RUNTIME_DIR=$TMPD/run \ XDG_CONFIG_HOME=$TMPD/config XDG_CACHE_HOME=$TMPD/cache export HOME XDG_STATE_HOME XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_CACHE_HOME mkdir -p "$HOME" "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" \ || die "无法创建临时目录结构" chmod 700 "$XDG_RUNTIME_DIR" } # 识别架构并取出内嵌 sha256(下载用名字与登记用的原始值分开) detect_arch() { RAW_ARCH=$(uname -m 2>/dev/null || echo unknown) case "$RAW_ARCH" in x86_64|amd64) ARCH=linux-amd64; WANT_SHA=$SHA256_AMD64 ;; aarch64|arm64) ARCH=linux-arm64; WANT_SHA=$SHA256_ARM64 ;; *) die "不支持的架构:$RAW_ARCH(只支持 x86_64 / aarch64)" ;; esac case "$WANT_SHA" in [0-9a-f][0-9a-f]*) [ "${#WANT_SHA}" -eq 64 ] || die "脚本内嵌的 sha256 无效" ;; *) die "脚本内嵌的 sha256 无效" ;; esac } # 下载 upterm(gzip)并按内嵌 sha256 校验解压后的二进制;任何一步失败都直接退出 download_upterm() { gz=$TMPD/upterm.gz code=000 if http_xfer "$BASE_URL/bin/upterm-$ARCH.gz" "$gz" 300 ''; then code=$HTTP_CODE fi if [ "$code" != 200 ] || [ ! -s "$gz" ]; then reason=$(tail -n 1 "$TMPD/http.err" 2>/dev/null || true) die "下载 /bin/upterm-$ARCH.gz 失败(HTTP $code)${reason:+:$reason}" fi if have gzip; then gzip -dc "$gz" > "$TMPD/upterm.bin" 2>/dev/null || die "upterm 压缩包解压失败" elif have zcat; then zcat "$gz" > "$TMPD/upterm.bin" 2>/dev/null || die "upterm 压缩包解压失败" else die "缺少 gzip/zcat,无法解压 upterm" fi got=$(sha256sum "$TMPD/upterm.bin" 2>/dev/null | cut -d' ' -f1) [ -n "$got" ] || die "无法计算 upterm 的 sha256" [ "$got" = "$WANT_SHA" ] || die "upterm 校验失败(期望 $WANT_SHA,实际 $got),已停止" mv "$TMPD/upterm.bin" "$TMPD/upterm" || die "无法写入 upterm" chmod 700 "$TMPD/upterm" UPTERM=$TMPD/upterm rm -f "$gz" } # 登记用的主机名 / 系统 / 架构:只保留安全字符并限长(服务器侧还会再校验) host_meta() { HOSTNAME=$(hostname 2>/dev/null || cat /proc/sys/kernel/hostname 2>/dev/null || true) [ -n "$HOSTNAME" ] || die "无法获取主机名" HOSTNAME=$(printf '%s' "$HOSTNAME" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64) [ -n "$HOSTNAME" ] || HOSTNAME=unknown OS=$(sed -n 's/^ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' /etc/os-release 2>/dev/null | head -n 1) [ -n "$OS" ] || OS=$(uname -s 2>/dev/null || echo linux) OS=$(printf '%s' "$OS" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._-' '-' | cut -c1-32) [ -n "$OS" ] || OS=linux ARCH_LABEL=$(printf '%s' "$RAW_ARCH" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-32) [ -n "$ARCH_LABEL" ] || ARCH_LABEL=unknown } # 编号 = 出厂 MAC 的哈希;没有出厂 MAC 时退回"联网网卡 MAC + 主机名"。 # 设置 MAC 与 CODE(8 位 hex)。 compute_code() { if mac=$(stable_mac); then MAC=$mac CODE=$(printf '%s\n' "$mac" | hash8) || die "缺少 sha256sum/md5sum" else mac=$(primary_mac) || die "无法获取网卡 MAC" [ -n "$mac" ] || die "无法获取网卡 MAC" MAC=$mac CODE=$(printf '%s\n%s\n' "$mac" "$HOSTNAME" | hash8) || die "缺少 sha256sum/md5sum" fi [ "${#CODE}" -eq 8 ] || die "无法计算编号" case "$MAC" in [0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]) ;; *) die "网卡 MAC 格式异常:$MAC" ;; esac } # 网关公钥白名单(只有网关能连进会话)、uptermd 主机指纹、本机临时身份密钥 write_trust_files() { case "$GATEWAY_PUBKEY" in ssh-*" "*) ;; *) die "脚本内嵌的网关公钥格式异常" ;; esac printf '%s\n' "$GATEWAY_PUBKEY" > "$TMPD/gateway.pub" || die "无法写入网关公钥" printf '%s\n' "$KNOWN_HOSTS_DATA" > "$TMPD/known_hosts" || die "无法写入 known_hosts" ssh-keygen -q -t ed25519 -N '' -C rdbg-target -f "$TMPD/id" >/dev/null 2>&1 || die "无法生成临时密钥" } # 启动一次 upterm(--detach);成功设置 SESSION_ID / SNAME / UPTERM_PID,失败返回非 0 start_upterm() { # $1 = server URL(wss://… 或 ssh://…) rm -f "$TMPD/upterm.json" "$TMPD/upterm.err" # --detach:没有终端也能跑;JSON 输出里有 sessionId 和后台进程 pid "$UPTERM" host --detach --accept --allow-exec --allow-sftp \ --authorized-keys "$TMPD/gateway.pub" --known-hosts "$TMPD/known_hosts" --private-key "$TMPD/id" \ --server "$1" -o json -- /bin/sh > "$TMPD/upterm.json" 2> "$TMPD/upterm.err" < /dev/null & START_PID=$! # 90 秒没有结果即视为失败(现场跨境网络的首连窗口;调用方决定是否换 SSH 兜底) i=0 while [ "$i" -lt 90 ] && kill -0 "$START_PID" 2>/dev/null; do sleep 1 i=$((i + 1)) done if kill -0 "$START_PID" 2>/dev/null; then kill "$START_PID" 2>/dev/null || true wait "$START_PID" 2>/dev/null || true START_PID= printf '连接超时\n' >> "$TMPD/upterm.err" return 1 fi rc=0 wait "$START_PID" 2>/dev/null || rc=$? START_PID= [ "$rc" -eq 0 ] || return 1 # 会话 ID 要进 URL 和服务器参数,只接受安全字符集 SESSION_ID=$(sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMPD/upterm.json" | head -n 1) SNAME=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMPD/upterm.json" | head -n 1) UPTERM_PID=$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$TMPD/upterm.json" | head -n 1) case "$SESSION_ID" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac [ "${#SESSION_ID}" -ge 4 ] && [ "${#SESSION_ID}" -le 64 ] || return 1 } # 先试 WSS(经 443 反代),失败退 SSH 兜底;两条路都失败就退出 start_tunnel() { wss_url="wss://$(printf '%s' "$BASE_URL" | sed -e 's|^[a-zA-Z][a-zA-Z0-9+.-]*://||' -e 's|/.*$||')/tunnel" if start_upterm "$wss_url"; then return 0 fi wss_err=$(tail -n 1 "$TMPD/upterm.err" 2>/dev/null || true) printf 'rdbg: WSS 连接失败%s,改用 SSH 兜底\n' "${wss_err:+($wss_err)}" >&2 # 上一次失败可能留下后台进程,先按记录的 PID 收掉 if [ -n "$UPTERM_PID" ] && kill -0 "$UPTERM_PID" 2>/dev/null; then kill "$UPTERM_PID" 2>/dev/null || true fi UPTERM_PID='' SNAME='' start_upterm "ssh://$SSH_FALLBACK" || die "WSS 与 SSH 兜底都连不上服务器" } # 登记成功设置 LEASE / HB 并返回 0;连不上服务器返回 1;同编号在线返回 2;其余失败 die register() { body=$(printf '{"code":"%s","session_id":"%s","mac":"%s","hostname":"%s","os":"%s","arch":"%s"}' \ "$CODE" "$SESSION_ID" "$MAC" "$HOSTNAME" "$OS" "$ARCH_LABEL") http_post /api/target/register "$body" "$TMPD/register.out" || return 1 case "$HTTP_CODE" in 200) ;; 409) return 2 ;; 429) die "服务器繁忙(在线目标已达上限),请稍后再试" ;; 400) detail=$(sed -n 's/.*"detail"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMPD/register.out" | head -n 1) die "登记被服务器拒绝${detail:+($detail)}" ;; *) die "登记失败(HTTP $HTTP_CODE)" ;; esac LEASE=$(sed -n 's/.*"lease"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]\{32\}\)".*/\1/p' "$TMPD/register.out" | head -n 1) [ -n "$LEASE" ] || die "服务器没有返回 lease" HB=$(sed -n 's/.*"heartbeat"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$TMPD/register.out" | head -n 1) case "$HB" in ''|*[!0-9]*) HB=30 ;; esac if [ "$HB" -lt 1 ] || [ "$HB" -gt 300 ]; then HB=30; fi } # 一次心跳;返回 0 继续、1 网络抖动(下轮重试)、2 服务器要求退出(原因在 HB_REASON) heartbeat_once() { http_post /api/target/heartbeat "{\"lease\":\"$LEASE\"}" "$TMPD/heartbeat.out" || return 1 case "$HTTP_CODE" in 200) ;; 404) HB_REASON=unknown_lease; return 2 ;; *) return 1 ;; esac action=$(sed -n 's/.*"action"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' "$TMPD/heartbeat.out" | head -n 1) case "$action" in ok) return 0 ;; exit) HB_REASON=$(sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p' "$TMPD/heartbeat.out" | head -n 1) [ -n "$HB_REASON" ] || HB_REASON="exit" return 2 ;; *) return 1 ;; esac } # 断开提示(现场靠这些文字判断原因) notify_disconnect() { # $1 reason case "$1" in kicked) printf 'rdbg: 已被服务器断开(踢下线)\n' >&2 ;; idle) printf 'rdbg: 服务器判定空闲,已断开\n' >&2 ;; max_age) printf 'rdbg: 已达到最长在线时间\n' >&2 ;; unknown_lease) printf 'rdbg: 服务器已没有这台机器的登记(可能判定已离线),已断开\n' >&2 ;; *) printf 'rdbg: 服务器要求断开(%s)\n' "$1" >&2 ;; esac } # 保活循环。upterm 断网后会自己重连(会话 ID 不变),所以只以"进程还在不在" # 为存活判据,不要拿"连不上中转"当判据。 heartbeat_loop() { elapsed=0 since_hb=0 while :; do sleep 1 elapsed=$((elapsed + 1)) since_hb=$((since_hb + 1)) if [ -n "$UPTERM_PID" ] && ! kill -0 "$UPTERM_PID" 2>/dev/null; then printf 'rdbg: upterm 已退出(网络长时间不通或会话被停止)\n' >&2 exit 0 fi if [ "$since_hb" -ge "$HB" ]; then since_hb=0 rc=0; heartbeat_once || rc=$? # 心跳断了一阵被服务器判下线(unknown_lease),而隧道还在:重新登记即可, # 编号与会话不变;登记时网络仍不通就下轮再试。被踢、编号被占用才退出。 if [ "$rc" -eq 2 ] && [ "$HB_REASON" = unknown_lease ]; then rc=0; register || rc=$? case "$rc" in 0) printf 'rdbg: 网络恢复,已重新登记\n' >&2; continue ;; 1) continue ;; *) HB_REASON=unknown_lease; rc=2 ;; esac fi if [ "$rc" -eq 2 ]; then notify_disconnect "$HB_REASON" exit 0 fi fi if [ "$elapsed" -ge "$MAX_SECONDS" ]; then notify_disconnect max_age exit 0 fi done } main() { # 未渲染的模板直接拒绝,避免把占位符带到现场 case "$BASE_URL$SSH_FALLBACK$GATEWAY_PUBKEY$KNOWN_HOSTS_DATA" in *@@*) die "脚本未渲染(占位符没有替换),请用 scripts/v2/render.sh 生成后再发布" ;; esac init_env detect_arch make_workspace printf '正在连接,约需半分钟…\n' download_upterm host_meta compute_code write_trust_files start_tunnel rc=0; register || rc=$? [ "$rc" -eq 1 ] && die "无法连接服务器登记(网络不通或服务器不可用)" if [ "$rc" -eq 2 ]; then printf '这台机器已在线,编号 %s,无需重连\n' "$CODE" exit 0 fi printf '编号 %s,Ctrl-C 断开\n' "$CODE" heartbeat_loop } main "$@"