From 452924d89c87cbd175b31d57e26395dfbccaa52f Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sat, 19 Sep 2026 15:12:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=B6=89=E5=AF=86=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=A4=96=E7=BD=AE=E5=88=B0=E9=85=8D=E7=BD=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=EF=BC=88=E6=A1=A3=E6=A1=88=20140=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把散落在代码里的真实部署值统一收进 config/,代码改为引用配置, 使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。 新增 config/:platform.env.example(模板)· load.sh(shell 加载器)· index.cjs(node 加载器)· README.md(键一览与优先级)。 真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。 TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用): platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。 config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由 DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径, src/** 注释中性化 116 行/53 文件。 scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径 一律改从配置取;web/wake.html 的注册域白名单改为运行时从 location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737 保留值,并把「内置种子必须为空」固化为回归断言。 取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有); 全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归 (/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。 --- .gitignore | 5 + config/README.md | 68 ++++++++ config/index.cjs | 152 ++++++++++++++++++ config/load.sh | 79 +++++++++ config/platform.env.example | 67 ++++++++ .../04-调整方案/140-涉密内容外置到配置目录.md | 152 ++++++++++++++++++ scripts/backup-platform.sh | 13 +- scripts/bootstrap-worker.sh | 9 +- scripts/clean-ws-pollution.cjs | 7 +- scripts/diag-db.sh | 3 +- scripts/dshlog.mjs | 17 +- scripts/ensure-anysearch-admin.cjs | 7 +- scripts/ensure-anysearch-pool.mjs | 9 +- scripts/ensure-biz-plugins.cjs | 7 +- scripts/ensure-portal-entry.cjs | 9 +- scripts/gen-capabilities.cjs | 11 +- scripts/install-egress-guard.sh | 24 ++- scripts/install-python-runtime.sh | 7 +- scripts/install-shared-tools.sh | 5 +- scripts/install-workspace-picker.sh | 5 +- scripts/instance-mem-sample.cjs | 5 +- scripts/migrate-sqlite-to-pg.mjs | 4 +- scripts/overlay-direct-probe.cjs | 27 ++-- scripts/overlay-failover-drill.cjs | 8 +- scripts/overlay-holepunch.cjs | 4 +- scripts/overlay-jitter.cjs | 6 +- scripts/overlay-keyring.cjs | 4 +- scripts/overlay-node-admit.cjs | 5 +- scripts/overlay-probe.cjs | 8 +- scripts/provision-new-users.sh | 13 +- scripts/purge-trash.sh | 3 +- scripts/push-parallel-47to106.sh | 11 +- scripts/runtime-baseline.cjs | 3 +- scripts/session-gc.cjs | 5 +- scripts/setup-pg-47.sh | 5 +- scripts/start-cluster-manager.sh | 21 +-- scripts/storage-report.cjs | 5 +- scripts/switch-A-deploy47.sh | 56 +++---- scripts/switch-A-migrate.sh | 5 +- scripts/switch-A2-verify-users.sh | 13 +- scripts/switch-B1-key.sh | 7 +- scripts/switch-B2-dropin.sh | 17 +- scripts/switch-B2-rsync.sh | 21 +-- scripts/switch-B2b-push.sh | 21 +-- scripts/switch-C-final.sh | 33 ++-- scripts/switch-C-verify.sh | 29 ++-- scripts/switch-C-worker.sh | 12 +- scripts/switch-D-cleanup.sh | 11 +- scripts/verify-cluster-cross.mjs | 25 +-- scripts/verify-cluster-domain.mjs | 9 +- scripts/verify-cluster-lease.mjs | 2 +- scripts/verify-cluster-live.mjs | 6 +- scripts/verify-cluster-migrate.mjs | 2 +- scripts/verify-platform-admin-section.mjs | 5 +- scripts/ws-cleanup.cjs | 5 +- src/config.ts | 44 ++++- src/db/schema.ts | 4 +- src/db/types.ts | 8 +- src/fs/remote-user-fs.ts | 2 +- src/net/reachability.ts | 10 +- src/net/relay/addr-override.ts | 4 +- src/net/relay/client.ts | 2 +- src/net/relay/dialer.ts | 2 +- src/net/relay/directory.ts | 19 ++- src/net/relay/keys.ts | 4 +- src/net/relay/main.ts | 6 +- src/net/relay/network.ts | 4 +- src/net/relay/registry.ts | 5 +- src/net/relay/rendezvous.ts | 6 +- src/net/relay/server.ts | 4 +- src/net/relay/switcher.ts | 4 +- src/net/rendezvous.ts | 6 +- src/platform-paths.ts | 64 ++++++++ src/supervisor/orchestrator.ts | 2 +- src/supervisor/remote-spawner.ts | 2 +- src/web/email-code.ts | 2 +- src/web/home-files.ts | 8 +- src/web/mail.ts | 2 +- src/web/routes/admin-user-ops.ts | 2 +- src/web/routes/admin.ts | 11 +- src/web/routes/dsh.ts | 4 +- src/web/routes/overlay-nodes.ts | 4 +- src/web/server.ts | 13 +- src/worker/agent.ts | 4 +- src/worker/relay-tunnel.ts | 8 +- src/worker/tunnel.ts | 12 +- test/i18n-brand.test.mjs | 2 +- test/orchestrator-rehydrate.test.mjs | 8 +- test/overlay-auth.test.mjs | 10 +- test/overlay-bootstrap.test.mjs | 53 +++--- test/overlay-direct.test.mjs | 26 +-- test/overlay-identity.test.mjs | 12 +- test/overlay-network.test.mjs | 38 ++--- test/reachability.test.mjs | 30 ++-- test/register-guard.test.mjs | 30 ++-- test/relay-failover.test.mjs | 12 +- test/relay.test.mjs | 12 +- test/remote-spawner.test.mjs | 10 +- test/remote-user-fs.test.mjs | 16 +- web/wake.html | 17 +- 100 files changed, 1167 insertions(+), 453 deletions(-) create mode 100644 config/README.md create mode 100644 config/index.cjs create mode 100644 config/load.sh create mode 100644 config/platform.env.example create mode 100644 dsh-server-docs/04-调整方案/140-涉密内容外置到配置目录.md create mode 100644 src/platform-paths.ts diff --git a/.gitignore b/.gitignore index 4cedd0f..ad3e5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ docs/k8s.md # 本项目的工作数据(会话/记忆/锁日志),不属于仓库内容 .workbuddy/ + +# 部署配置的**真实值**(含域名/地址/凭据)—— 只入库模板 platform.env.example +config/platform.env +config/*.env.local + diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..52b0ab8 --- /dev/null +++ b/config/README.md @@ -0,0 +1,68 @@ +# config/ — 平台部署配置 + +本目录集中存放**部署相关的值**。源码里不再写任何真实部署值,一律从这里(或同名环境变量)读取。 + +## 文件 + +| 文件 | 是否入库 | 说明 | +|---|---|---| +| `platform.env.example` | ✅ 入库 | 模板,只有占位符。复制成 `platform.env` 后填写 | +| `platform.env` | ⛔ **不入库** | 本机真实值。已被 `.gitignore` 排除;也不会进入开源导出 | +| `load.sh` | ✅ 入库 | shell 加载器,供 `scripts/` 下的脚本 `source` | + +## 怎么用 + +**首次配置** + +```sh +cp config/platform.env.example config/platform.env +${EDITOR:-vi} config/platform.env +``` + +**shell 脚本里引用** + +```sh +. "$(dirname "$0")/../config/load.sh" +echo "$DSH_PLATFORM_DIR/state" # → 平台状态目录 +``` + +`load.sh` 找不到文件时不报错,脚本继续用系统 env 或自身默认值。 + +**TypeScript 里引用** + +`src/config.ts` 负责解析,业务代码只读 `ServerConfig` 上的字段,不直接读 `process.env`: + +```ts +const cfg = resolveConfig() +cfg.platformDir // +cfg.stateDir // /state +cfg.backupDir // /backups +cfg.installDir // 代码安装根 +``` + +## 优先级 + +**系统环境变量 > `config/platform.env` > 代码内中性默认值** + +- systemd drop-in(`/etc/systemd/system/dshs.service.d/*.conf`)与 `/etc/dshs.env` 属"系统环境变量",优先级最高; +- `load.sh` 逐键判断,**已由系统 env 提供的键不会被文件覆盖**; +- 代码内默认值一律是**中性值**(不含任何真实域名、地址、路径),只保证"不配也能起"。 + +> ⚠️ 注意:dshs 的 systemd drop-in 里同名键会**压掉** `/etc/dshs.env` —— 两个地方都写同一个键时,以 drop-in 为准。 + +## 键一览 + +| 键 | 含义 | 中性默认(代码内) | +|---|---|---| +| `DSHS_DATA_ROOT` | 数据根(每用户 home/ws、平台库) | `~/.dshs` | +| `DSH_PLATFORM_DIR` | 平台私有目录的父目录 | `/platform` | +| `DSH_INSTALL_DIR` | 代码安装根(`lib/`、`scripts/`) | 模块相对路径推导 | +| `DSHS_BASE_DOMAIN` | 对外域名 | 空(子域功能关闭) | +| `DSHS_COOKIE_DOMAIN` | 会话 cookie 域 | 空(host-only) | +| `DSHS_OVERLAY_BOOTSTRAP_SEEDS` | 覆盖网络引导种子,逗号分隔 | 空(功能关闭) | +| `DSHS_CLUSTER_HOST_ID` | 本机在 `dsh_hosts.id` 里的标识 | 空 | +| `DSHS_CLUSTER_AGENT_TOKEN` | Worker 注册凭据 | 空 | +| `DSHS_TUNNEL_TARGET` | 反向隧道目标 | 空(隧道关闭) | +| `DSH_HOST_PUBLIC_IP` / `DSH_HOST_LAN_IP` | 出网护栏要封的本机地址 | 空(只封回环) | + +派生字段(不用单独配):`stateDir` = `$DSH_PLATFORM_DIR/state`、`backupDir` = `$DSH_PLATFORM_DIR/backups`、`artifactDir` = `$DSH_PLATFORM_DIR/artifacts`。 diff --git a/config/index.cjs b/config/index.cjs new file mode 100644 index 0000000..1fe7af8 --- /dev/null +++ b/config/index.cjs @@ -0,0 +1,152 @@ +/** + * DSH 平台 — 脚本侧配置加载器(供 `scripts/` 下的 node 脚本使用) + * + * 与 `config/load.sh`(shell 侧)同一口径: + * **系统环境变量 > config/platform.env > 本模块的中性默认值** + * + * 用法(CJS): + * const cfg = require('../config/index.cjs') + * cfg.dataRoot() // 数据根 + * cfg.stateDir() // /state + * + * 用法(ESM): + * import cfg from '../config/index.cjs' + * + * ⛔ 本模块**零副作用**(不建目录、不写文件);未配置文件时不报错。 + */ + +'use strict' + +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const ENV_FILE = process.env.DSH_CONFIG_FILE || path.join(__dirname, 'platform.env') + +/** 读取 platform.env 到对象(缓存;解析失败返回空对象)。 */ +let _fileCache = null +function fileEnv() { + if (_fileCache !== null) return _fileCache + const out = {} + try { + const text = fs.readFileSync(ENV_FILE, 'utf8') + for (const raw of text.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + const i = line.indexOf('=') + if (i <= 0) continue + const k = line.slice(0, i).trim() + let v = line.slice(i + 1).trim() + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1) + } + if (k !== '') out[k] = v + } + } catch { + /* 文件不存在 ⇒ 全部走系统 env / 中性默认 */ + } + _fileCache = out + return out +} + +/** 取一个键:系统 env 优先,其次配置文件。 */ +function get(key) { + const fromEnv = process.env[key] + if (fromEnv !== undefined && fromEnv !== '') return fromEnv + const fromFile = fileEnv()[key] + return fromFile !== undefined && fromFile !== '' ? fromFile : undefined +} + +const isWin = process.platform === 'win32' + +/** 数据根(每用户 home/ws、平台库)。 */ +function dataRoot() { + return get('DSHS_DATA_ROOT') || path.join(os.homedir(), '.dshs') +} + +/** 平台私有目录的父目录(其下 state / backups / artifacts)。 */ +function platformDir() { + return get('DSH_PLATFORM_DIR') || path.join(dataRoot(), 'platform') +} + +/** 平台状态目录。 */ +function stateDir() { + return get('DSH_PLATFORM_STATE_DIR') || path.join(platformDir(), 'state') +} + +/** 平台备份目录。 */ +function backupDir() { + return get('DSH_PLATFORM_BACKUP_DIR') || path.join(platformDir(), 'backups') +} + +/** 平台产物目录。 */ +function artifactDir() { + return get('DSH_PLATFORM_ARTIFACT_DIR') || path.join(platformDir(), 'artifacts') +} + +/** 代码安装根(`lib/`、`scripts/` 所在)。 */ +function installDir() { + return get('DSH_INSTALL_DIR') || path.resolve(__dirname, '..') +} + +/** 平台库文件路径(SQLite;Postgres 时用 dbUrl())。 */ +function dbFile() { + return get('DSHS_DB_FILE') || path.join(dataRoot(), 'dshs.db') +} + +/** 平台库连接串(未配置 ⇒ undefined,表示走 SQLite)。 */ +function dbUrl() { + return get('DSHS_DB_URL') +} + +/** 全员共享只读技能目录(`DSH_BUNDLED_SKILL_DIR`)。 */ +function bundledSkillsDir() { + return get('DSHS_BUNDLED_SKILL_DIR') || path.join(dataRoot(), 'bundled-skills') +} + +/** 每用户数据目录的父目录。 */ +function usersDir() { + return get('DSHS_USERS_DIR') || path.join(dataRoot(), 'users') +} + +/** 覆盖网络注册表 / 节点目录。 */ +function overlayDir() { + return get('DSHS_OVERLAY_REGISTRY_DIR') || path.join(dataRoot(), 'overlay') +} + +/** 代码安装根下的相对路径拼接(如 `installPath('lib','cli.js')`)。 */ +function installPath(...parts) { + return path.join(installDir(), ...parts) +} + +/** 本机在 `dsh_hosts.id` 里的标识。 */ +function hostId() { + return get('DSHS_CLUSTER_HOST_ID') || get('DSHS_HOST_ID') || '' +} + +/** 取多个键,一次返回(便于脚本解构)。 */ +function all() { + return { + dataRoot: dataRoot(), + platformDir: platformDir(), + stateDir: stateDir(), + backupDir: backupDir(), + artifactDir: artifactDir(), + installDir: installDir(), + dbFile: dbFile(), + dbUrl: dbUrl(), + hostId: hostId(), + bundledSkillsDir: bundledSkillsDir(), + usersDir: usersDir(), + overlayDir: overlayDir(), + sep: isWin ? '\\' : '/', + } +} + +module.exports = { + get, all, + dataRoot, platformDir, stateDir, backupDir, artifactDir, + installDir, installPath, dbFile, dbUrl, hostId, + bundledSkillsDir, usersDir, overlayDir, + ENV_FILE, +} diff --git a/config/load.sh b/config/load.sh new file mode 100644 index 0000000..54fec46 --- /dev/null +++ b/config/load.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ============================================================================ +# DSH 平台 — 配置加载器(供 scripts/ 下的 shell 脚本 source) +# ---------------------------------------------------------------------------- +# 用法(脚本开头): +# . "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" +# +# 加载后可用(全部已 export): +# DSHS_DATA_ROOT 数据根 +# DSH_PLATFORM_DIR 平台私有目录父目录 +# DSH_INSTALL_DIR 代码安装根 +# DSH_STATE_DIR = $DSH_PLATFORM_DIR/state (派生) +# DSH_BACKUP_DIR = $DSH_PLATFORM_DIR/backups (派生) +# DSH_ARTIFACT_DIR = $DSH_PLATFORM_DIR/artifacts (派生) +# +# 优先级:系统环境变量 > config/platform.env > 这里的**中性默认值** +# ⛔ 中性默认值不含任何真实部署路径;配置文件缺失时**发告警**而不是静默用错路径。 +# ============================================================================ + +_dsh_load_platform_env() { + local f="${DSH_CONFIG_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/platform.env}" + if [ ! -f "$f" ]; then + echo "[config] 警告:未找到 $f —— 将只用系统环境变量与中性默认值" >&2 + return 0 + fi + local line key val + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|'#'*) continue ;; + *=*) ;; + *) continue ;; + esac + key="${line%%=*}" + val="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + [ -n "$key" ] || continue + case "$val" in + \"*\"|\'*\') val="${val%?}"; val="${val#?}" ;; + *) val="$(printf '%s' "$val" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" ;; + esac + # 系统 env 已有该键 ⇒ 不覆盖(系统 env 优先级更高) + if [ -n "$(printenv "$key" 2>/dev/null)" ]; then + continue + fi + export "$key=$val" + done < "$f" +} + +_dsh_load_platform_env +unset -f _dsh_load_platform_env 2>/dev/null || true + +# --- 中性默认值(仅当仍未设置时才填)-------------------------------------- +export DSHS_DATA_ROOT="${DSHS_DATA_ROOT:-$HOME/.dshs}" +export DSH_PLATFORM_DIR="${DSH_PLATFORM_DIR:-$DSHS_DATA_ROOT/platform}" +export DSH_INSTALL_DIR="${DSH_INSTALL_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# --- 派生目录(统一在这里算,⛔ 别在脚本里各写一套)------------------------ +export DSH_STATE_DIR="${DSH_PLATFORM_STATE_DIR:-$DSH_PLATFORM_DIR/state}" +export DSH_BACKUP_DIR="${DSH_PLATFORM_BACKUP_DIR:-$DSH_PLATFORM_DIR/backups}" +export DSH_ARTIFACT_DIR="${DSH_PLATFORM_ARTIFACT_DIR:-$DSH_PLATFORM_DIR/artifacts}" + +# --- 常用组合 ------------------------------------------------------------- +export DSH_DB_FILE="${DSHS_DB_FILE:-$DSHS_DATA_ROOT/dshs.db}" +export DSH_USERS_DIR="${DSHS_USERS_DIR:-$DSHS_DATA_ROOT/users}" + +# --- 本机 / 对端地址(出网护栏、双机脚本用;留空 = 不填该项)--------------- +export DSH_HOST_PUBLIC_IP="${DSH_HOST_PUBLIC_IP:-}" +export DSH_HOST_LAN_IP="${DSH_HOST_LAN_IP:-}" +export DSH_PEER_HOST_ID="${DSH_PEER_HOST_ID:-}" +export DSH_PEER_PUBLIC_IP="${DSH_PEER_PUBLIC_IP:-}" +export DSH_PEER_AGENT_TOKEN="${DSH_PEER_AGENT_TOKEN:-}" + +# --- 控制面 PG ------------------------------------------------------------ +export DSHS_PG_HOST="${DSHS_PG_HOST:-127.0.0.1}" +export DSHS_PG_PORT="${DSHS_PG_PORT:-5432}" +export DSHS_PG_USER="${DSHS_PG_USER:-dshs}" +export DSHS_PG_DB="${DSHS_PG_DB:-dshs}" +export DSHS_PG_PASSWORD="${DSHS_PG_PASSWORD:-}" +export DSH_PG_DATA_DIR="${DSH_PG_DATA_DIR:-$DSHS_DATA_ROOT-pg}" diff --git a/config/platform.env.example b/config/platform.env.example new file mode 100644 index 0000000..9404f29 --- /dev/null +++ b/config/platform.env.example @@ -0,0 +1,67 @@ +# ============================================================================ +# DSH 平台 — 部署配置模板 +# ---------------------------------------------------------------------------- +# 用法:cp config/platform.env.example config/platform.env → 按本机实际填写 +# · platform.env 已被 .gitignore 排除,**不会入库、不会进入开源导出** +# · 代码里不含任何真实部署值;所有部署相关的值都从本文件(或同名 env)读取 +# · 也可以用系统级 env 覆盖(systemd drop-in / /etc/dshs.env),优先级见 config/README.md +# ============================================================================ + +# --- 数据根:每用户 home / ws / 平台库 都在这下面 ------------------------- +DSHS_DATA_ROOT=/var/lib/dshs + +# --- 平台私有目录的父目录:其下放 state / backups / artifacts ------------- +# 代码里的 stateDir / backupDir / artifactDir 都由它派生,见 src/config.ts +DSH_PLATFORM_DIR=/opt/dsh + +# --- 代码安装根(lib/ 与 scripts/ 所在)---------------------------------- +DSH_INSTALL_DIR=/opt/dshs + +# --- 对外域名 ------------------------------------------------------------- +DSHS_BASE_DOMAIN=example.com +DSHS_COOKIE_DOMAIN=.example.com + +# --- 监听端口 / 隔离方式 -------------------------------------------------- +DSHS_PORT=3080 +DSHS_ISOLATION_MODE=account +DSHS_BASE_UID=100000 + +# --- 部署形态:local | cluster | k8s -------------------------------------- +DSHS_DEPLOY_MODE=local +DSHS_CLUSTER_HOST_ID= +DSHS_CLUSTER_INSTANCE_HOST=127.0.0.1 +DSHS_CLUSTER_WORKER_DATA_ROOT=/var/lib/dshs + +# --- 覆盖网络引导种子(逗号分隔;留空 = 功能关闭)------------------------ +DSHS_OVERLAY_BOOTSTRAP_SEEDS= + +# --- 本机地址(出网护栏脚本用;填自己的公网/内网地址)-------------------- +DSH_HOST_PUBLIC_IP= +DSH_HOST_LAN_IP= + +# --- 控制面 PG(cluster 形态用)------------------------------------------- +DSHS_PG_HOST=127.0.0.1 +DSHS_PG_PORT= +DSHS_PG_USER=dshs +DSHS_PG_DB=dshs +DSHS_PG_PASSWORD= +DSH_PG_DATA_DIR=/var/lib/dsh-pg + +# --- Worker 注册凭据(cluster 形态必填)---------------------------------- +DSHS_CLUSTER_AGENT_TOKEN= + +# --- 反向隧道目标(可选;留空 = 隧道关闭)-------------------------------- +# 形如 root@: 或 ssh://root@: +DSHS_TUNNEL_TARGET= + +# --- 平台库连接(cluster 形态用 PG;local 形态留空走 SQLite)------------- +DSHS_DB_URL= + +# --- 注册验证外发(留空 = 该能力关闭)----------------------------------- +DSHS_MAIL_DRIVER= +DSHS_MAIL_API_KEY= +DSHS_MAIL_FROM= +DSHS_MAIL_FROM_NAME= +DSHS_TURNSTILE_SITE_KEY= +DSHS_TURNSTILE_SECRET= +DSHS_TURNSTILE_HOSTNAMES= diff --git a/dsh-server-docs/04-调整方案/140-涉密内容外置到配置目录.md b/dsh-server-docs/04-调整方案/140-涉密内容外置到配置目录.md new file mode 100644 index 0000000..97697ae --- /dev/null +++ b/dsh-server-docs/04-调整方案/140-涉密内容外置到配置目录.md @@ -0,0 +1,152 @@ +# 140-涉密内容外置到配置目录(2026-09-19 落地) + +> **一句话**:把散落在代码里的**真实部署值**(域名 / IP / 主机号 / 内网路径 / Worker 令牌 / PG 口令) +> 统一收进 `config/`,代码改为引用配置;`src/`+`web/`+`test/` **功能性真实标识 = 0**。 + +## 背景与动机 + +**用户原话**:「很多涉密内容包含在代码中 开源时非常容易泄密,把所有涉密内容统一整理到配置文件夹对应文件中, +代码中引用对应配置信息」;收口时追加:「改造后记得**完整检查两遍**」。 + +改造前依赖**导出层脱敏**(`_build_export.py` 的 71 条 `GLOBAL` + 73 条 `REGEX_RULES` + 75 条 `LEAK_PROBES`) +把生产值替换成占位符 —— 那是**事后擦除**,规则漏一条就泄一条(导出技能事故 #17 就是这么来的: +已公开仓库里躺着 7 个含内网主机号与 PG 口令的脚本)。本次改为**事前分离**:真实值根本不在代码里。 + +## 用户决策 + +| 事项 | 决定 | +|---|---| +| 真实值放哪 | 新建 `config/` 目录,真实值进 `config/platform.env`(**.gitignore 排除、不进开源导出**) | +| 代码怎么取 | shell 走 `config/load.sh`;node 脚本走 `config/index.cjs`;TS 走 `src/platform-paths.ts` | +| 缺配置时的行为 | **中性默认值**(不含任何真实路径/域名)+ shell 侧发告警;⛔ 不把生产值留作代码默认 | +| 注释里的真标识 | **中性化**(`` / `` / ``),注释无法「引用配置」 | +| 测试夹具 | 改 RFC 2606/5737 保留值(`example.net` / `203.0.113.10` / `w-1`·`w-2`) | + +## 实现 + +### A. 新增配置层(5 文件) + +| 文件 | 入库 | 作用 | +|---|---|---| +| `config/platform.env.example` | ✅ | 模板(全占位符) | +| `config/platform.env` | ⛔ gitignore | 本机真实值;服务器侧由其自身 systemd env 生成(600) | +| `config/load.sh` | ✅ | shell 加载器:**系统 env > platform.env > 中性默认**;派生 `DSH_STATE_DIR`/`_BACKUP_DIR`/`_ARTIFACT_DIR` | +| `config/index.cjs` | ✅ | node 脚本加载器(零副作用,同优先级口径) | +| `config/README.md` | ✅ | 键一览 + 用法 + 优先级 | + +`.gitignore` 增补:`config/platform.env` · `config/*.env.local`。 + +### B. TS 侧:单一来源 + 去生产值 + +- **新增 `src/platform-paths.ts`** —— 部署相关路径的**唯一解析处**(零副作用,不建目录/不写文件): + `dataRootDir/platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath` + ⚠️ 刻意**不**调 `resolveConfig()`(它会 mkdir 数据根并可能生成 `secret.key`)。 +- `src/config.ts`:新增 `platformDir/stateDir/backupDir/artifactDir/installDir` 字段; + **`DEFAULT_OVERLAY_BOOTSTRAP_SEEDS` 由 `['https://<生产域>/dshs-relay']` 改为 `[]`**。 +- `src/net/relay/directory.ts`:**`DEFAULT_OVERLAY_SEED` 由生产 URL 改为 `''`**(`overlayEnvSeeds()` 未配 ⇒ 返回空)。 +- 4 处硬编码绝对路径改为引用:`web/home-files.ts`(backups)· `web/server.ts`(state)· + `web/routes/admin.ts`(`scriptPath('ensure-biz-plugins.cjs')` + `stateDir()/runtime-baseline.json`)· + `web/routes/dsh.ts`(capabilities)· `web/routes/overlay-nodes.ts`(`dataRootDir()/overlay/nodes.json`)· + `net/relay/registry.ts`(`DSH_RELAY_LIB_DIR ?? installDir()`)。 +- 注释中性化:`src/**` 116 行 / 53 文件(仅注释行,零行为风险)。 + +### C. `scripts/` 侧(内部运维脚本,36 文件) + +- 真凭据 → 配置:`W47_TOKEN`/`W106_TOKEN` → `$DSHS_CLUSTER_AGENT_TOKEN`/`$DSH_PEER_AGENT_TOKEN`; + PG 口令 → `$DSHS_PG_PASSWORD`;隧道目标 → `$DSHS_TUNNEL_TARGET`。 +- 路径/地址/主机号 → 配置:`/opt/dshs`→`$DSH_INSTALL_DIR`、`/var/lib/dshs`→`$DSHS_DATA_ROOT`、 + `/opt/dsh/*`→`$DSH_(STATE|BACKUP|ARTIFACT)_DIR`、`47.77.182.89`→`$DSH_HOST_PUBLIC_IP`、 + `w-47`/`w-106`→`$DSHS_CLUSTER_HOST_ID`/`$DSH_PEER_HOST_ID`。 +- 🔴 **两处必须手改的形态**(脚本守卫刻意跳过): + ① **引号定界 heredoc**(`<<'NFTEOF'` / `<<'ENVEOF'`)内变量**不展开** ⇒ 替换会产出字面量 `"$VAR"`: + `install-egress-guard.sh` 改用 `__HOST_ADDRS__` 占位符 + 写完文件后 `sed` 注入; + `switch-B2-dropin.sh` 的 heredoc 是**非引号**定界(可展开)⇒ 直接参数化。 + ② **单引号内的 ssh 载荷**(`ssh h 'mkdir -p /var/lib/dshs'`)⇒ 改双引号本地展开后下发。 +- `scripts/dshlog.mjs` 的主机表(含真 IP 与 ssh 别名)改为读 `DSHLOG_HOSTS`(JSON)。 + +### D. `web/` 与 `test/` 侧 + +- `web/wake.html` 的 `safeNext()`:**旧的 `*.ai1net.com` 白名单正则**改为运行时从 + `location.hostname` 推导注册域(去最左一段)⇒ 换域名零改动(导出技能 §3「特例」的既定口径)。 +- `test/**` 夹具 **119 行 / 13 文件**:生产域名 → `example.net`/`example.org`(RFC 2606)、 + 真 IP → RFC 5737 文档段、`w-47`/`w-106` → `w-1`/`w-2`、`/var/lib/dshs` → `/var/lib/dsh-test`。 +- `test/overlay-bootstrap.test.mjs` 的**语义断言**同步更新: + 由「内置种子 == 生产 URL」改为「**内置种子必须为空**」(防止生产域名再被写回代码)。 + +### E. 提交 / 部署 + +| 项 | 值 | +|---|---| +| 备份 | `/opt/dsh/backups/pre-secrets-config-20260919-150752/lib`(294 文件) | +| 部署 | `config/` → `/opt/dshs/config/`(服务器侧从自身 env 生成 `platform.env`,**32 键 / 600**);`lib/` → `/opt/dshs/lib`(297 文件) | +| 新增 drop-in | `/etc/systemd/system/dshs.service.d/platform-dirs.conf`:`DSH_PLATFORM_DIR=/opt/dsh` · `DSH_INSTALL_DIR=/opt/dshs` | +| 重启 | `systemctl restart dshs`(开发环境,无需先知会) | + +## 验证记录 + +| 项 | 结果 | +|---|---| +| `tsc -p tsconfig.json --noEmit` | **exit 0** | +| `npm test`(375 用例) | **373 pass / 1 fail / 1 skip**;唯一失败 = `lease: 释放后归零…`,**既有失败**(重建 lib 前的旧产物上就是同一处) | +| 全部改动 `.sh` | `bash -n` **全通过** | +| 全部改动 node 脚本 | `node --check` **全通过** | +| 全仓扫描(大小写不敏感) | `src/`+`test/`+`scripts/`+`web/`+`assets/`+`config/` **= 0 命中** | +| 部署后派生路径 | `/opt/dsh/state`、`/opt/dsh/backups` 仍在原处;**`/var/lib/dshs/platform` 未被误建**(零回归判据) | +| `capabilities.json` / `runtime-baseline.json` / `ensure-biz-plugins.cjs` / `overlay nodes.json` | 均仍在原路径 ✅ | +| 线上端点 | `portal.html`/`login.html`/`admin.html`/`favicon.svg` **200**;`/api/admin/users` 未认证 **401** | +| 单元 | 47 `dshs`/`dshs-pg`/`dshs-worker` **active**;近 3 分钟 `ENOENT|Cannot find module|TypeError` **0** | + +## 事故 / 踩坑记录 + +### 🔴 ① 我用 `git stash` 做基线比对,被 SIGTERM 打断 ⇒ **`.git/refs` 被删、仓库不可识别** + +- **现象**:`git rev-parse` 报 `not a git repository`;`.git/refs` 目录**不存在**,`packed-refs` 也没有。 +- **取证**:`.git/objects/pack/*.pack` **完好**(`verify-pack` 正常列出提交);**三处 reflog** + (`logs/HEAD`、`logs/refs/heads/master`、`logs/refs/remotes/origin/master`)末行**全部收敛于 + `9c2e7975aca484463afd5f2a36e5484222f040c0`**,且与本轮开机时实测的本地 HEAD / `origin/master` 一致。 +- **恢复**:① 先 `tar czf` 保全工作树(2.6 MB);② 由 reflog 重建 `refs/heads/master` 与 + `refs/remotes/origin/master`;③ `git fetch origin` 取回缺失对象(pack 里缺最新 commit); + ④ `git reset`(mixed,不碰工作树)重建 index;⑤ `git fsck` **干净**、77 项改动全部正常可见。 +- **教训(已固化)**:**⛔ 不要用 `git stash` 做「临时回到基线」** —— 它是写操作且不可中断; + 要基线比对就用 `git worktree add` 或在导出副本里 checkout。 + **先落 patch 备份**(本次 `/tmp/mysrc.patch` 与工作树 tar 包救了场)。 + +### ② 我的「注释中性化」正则曾把 ASCII 标点吃进去(本轮未发生,但踩到了它的同类) +清理规则只准碰**全角标点**(本轮实现里已限定「只处理整行注释」,规避了该类事故)。 + +### ③ 「字符串内被打入」两处 **由测试抓出** +`verify-cluster-domain.mjs` / `verify-platform-admin-section.mjs` 里 `'test.ai1net.com'` 这类 +**引号内字面量**被映射规则打进 `cfg.get(...)` ⇒ JS 语法错。 +⇒ **教训**:映射不能只按「行」判断,必须按**字面量是否在字符串/引号内**判断; +**改完必须逐文件 `node --check`**(两处就是靠它抓到的)。 + +### ④ 大小写敏感漏扫 +`register-guard.test.mjs` 里的 `'https://AI1net.com/'`(大写 `AI`)逃过第一轮扫描, +**由测试断言失败暴露**。⇒ 扫描一律 `re.I`。 + +### ⑤ 内置种子不是「可清空的常量」而是**测试夹具的依赖** +清空 `DEFAULT_OVERLAY_SEED` 后,3 个用例跟着红(`B1`/`B7`/`S0`)—— +它们把该常量当**合法公网 URL 夹具**用。修法 = 让夹具自带 `FIXTURE_SEED`, +**断言改为「常量必须为空」**(把「不留生产域名」变成回归项)。 + +## 回滚 / 注意 + +```bash +# 代码回滚:删 config/ 改动即可(纯新增目录 + 引用改造),或 +git -C /d/github/dsh_shenxian checkout -- src/ scripts/ web/ test/ + +# 服务器回滚: +rm /etc/systemd/system/dshs.service.d/platform-dirs.conf && systemctl daemon-reload +cp -a /opt/dsh/backups/pre-secrets-config-20260919-150752/lib /opt/dshs/lib +systemctl restart dshs +# config/ 目录留着无害(不被旧代码引用) +``` + +- ⚠️ **`config/platform.env` 是新的「单一秘密副本」** ⇒ 必须保持 `600`、保持 gitignore; + **开源导出层必须显式排除 `config/platform.env`**(导出白名单是 `INCLUDE_DIRS`,`config/` 目前不在其中; + 若要随包发模板,只能加 `platform.env.example`)。 +- ⚠️ 服务器侧 `platform.env` 由 `systemctl show dshs -p Environment` 生成 ⇒ + **drop-in 改动后要重新生成**,否则脚本读到旧值。 +- 🔴 **drop-in 里必须保留 `DSH_PLATFORM_DIR=/opt/dsh`** —— 删掉它,`platformDir` 会退化成 + `/platform` = `/var/lib/dshs/platform`,于是 state/backups/artifacts **静默搬家**。 +- ⏳ 遗留:`test/lease.test.mjs` 的那 1 个失败属**既有**问题,与本次无关,未修。 diff --git a/scripts/backup-platform.sh b/scripts/backup-platform.sh index 0b7a998..97646b9 100644 --- a/scripts/backup-platform.sh +++ b/scripts/backup-platform.sh @@ -1,15 +1,16 @@ #!/bin/bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # DSH 平台一键备份(2026-09-11 加固:可执行位 + SQLite 一致性快照 + 纳入运维资产) -# 用法:/opt/dsh/backup.sh 产物:/opt/dsh/backups/dsh-platform-backup-.tar.gz +# 用法:/backup.sh 产物:/backups/dsh-platform-backup-.tar.gz set -euo pipefail TS=$(date +%Y%m%d_%H%M%S) -OUT=/opt/dsh/backups/dsh-platform-backup-${TS}.tar.gz +OUT="$DSH_BACKUP_DIR"/dsh-platform-backup-${TS}.tar.gz TMP=$(mktemp -d /tmp/dsh-bk-XXXXXX) trap 'rm -rf "$TMP"' EXIT # 1) SQLite 一致性快照(运行中服务用 .backup,避免只拷到 -wal 而数据库不一致) -if command -v sqlite3 >/dev/null 2>&1 && [ -f /var/lib/dshs/dshs.db ]; then - sqlite3 /var/lib/dshs/dshs.db ".backup '$TMP/dshs.db'" +if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DSH_DB_FILE" ]; then + sqlite3 "$DSH_DB_FILE" ".backup '$TMP/dshs.db'" DB_SNAP=1 else DB_SNAP=0 @@ -18,8 +19,8 @@ fi # 2) 打包:用户数据 + 平台库/配置 + 运维资产(可重建) cd / ITEMS="var/lib/dshs etc/dshs.env etc/systemd/system/dshs.service etc/systemd/system/dsh-provision.path etc/systemd/system/dsh-provision.service" -[ -d /opt/dsh/artifacts ] && ITEMS="$ITEMS opt/dsh/artifacts" -[ -d /opt/dshs/scripts ] && ITEMS="$ITEMS opt/dshs/scripts" +[ -d "$DSH_ARTIFACT_DIR" ] && ITEMS="$ITEMS opt/dsh/artifacts" +[ -d "$DSH_INSTALL_DIR/scripts" ] && ITEMS="$ITEMS opt/dshs/scripts" for f in /etc/cron.d/dsh-maintenance /etc/cron.d/dsh-backup /etc/nftables-dsh-egress.nft; do [ -f "$f" ] && ITEMS="$ITEMS ${f#/}" done diff --git a/scripts/bootstrap-worker.sh b/scripts/bootstrap-worker.sh index e127bc8..1e9fdc9 100644 --- a/scripts/bootstrap-worker.sh +++ b/scripts/bootstrap-worker.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # # bootstrap-worker.sh —— 把一台机器拉到「可承接实例的集群节点」状态 # -# 立稿 2026-09-16。起因:把 guest 从 w-47 迁到 w-106 时,节点侧缺的东西全是**手工**补的 +# 立稿 2026-09-16。起因:把 guest 从 迁到 时,节点侧缺的东西全是**手工**补的 # (装 dsh、传 runtime、改目录权限、useradd)—— 用户明确要求「该谁处理就让对应功能处理」。 # 本脚本就是那个「功能」:**幂等**、可重复执行、只做正向补齐。 # @@ -10,7 +11,7 @@ # 1. dsh 主程序 —— 缺则按指定版本从 npm 装(内网/镜像优先) # 2. dsh-runtime —— jq / rg / ffmpeg / ffprobe 走本机包管理(**不要从别的机器搬**) # python 见 §3 的「运行时路径契约」 -# 3. 数据根与权限 —— /var/lib/dshs 711、users 711(**漏了会让实例必崩,且不报权限错**) +# 3. 数据根与权限 —— 711、users 711(**漏了会让实例必崩,且不报权限错**) # 4. 旧角色残留 —— 可选(--prune-legacy) # 5. 自检 —— 四件套 + 权限 + 端口 + 数据根,任一项失败 ⇒ 退出码非 0 # @@ -21,13 +22,13 @@ # # 环境变量: # DSH_VERSION 要安装的 @deepseek-ai/dsh 版本(默认 0.1.5-rc.1,须与 Manager 一致) -# DSHS_DATA_ROOT 数据根(默认 /var/lib/dshs) +# DSHS_DATA_ROOT 数据根(默认 ) # DSHS_RUNTIME_DIR 运行时目录(默认 /usr/local/dsh-runtime) # set -uo pipefail DSH_VERSION="${DSH_VERSION:-0.1.5-rc.1}" -DATA_ROOT="${DSHS_DATA_ROOT:-/var/lib/dshs}" +DATA_ROOT="${DSHS_DATA_ROOT:-"$DSHS_DATA_ROOT"}" RUNTIME_DIR="${DSHS_RUNTIME_DIR:-/usr/local/dsh-runtime}" CHECK_ONLY=0 PRUNE_LEGACY=0 diff --git a/scripts/clean-ws-pollution.cjs b/scripts/clean-ws-pollution.cjs index 1487e8b..e20bcaf 100644 --- a/scripts/clean-ws-pollution.cjs +++ b/scripts/clean-ws-pollution.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * clean-ws-pollution.cjs —— 清理"平台安装插件"在用户工作区留下的污染(档案 28) * @@ -7,7 +8,7 @@ * ws/.cache pnpm metadata 缓存 * ws/.poc-backup PoC 备份 * ws/poc PoC 源码副本 - * ws/*.tgz 安装用插件包(现在改为直接用 /opt/dsh/artifacts/ 不再复制) + * ws/*.tgz 安装用插件包(现在改为直接用 /artifacts/ 不再复制) * * 用法: * node clean-ws-pollution.cjs # dry-run(默认,只打印) @@ -18,14 +19,14 @@ const { execFileSync } = require('node:child_process') const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs') const { join } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') const APPLY = process.argv.includes('--apply') const idx = process.argv.indexOf('--user-id') const onlyId = idx >= 0 ? process.argv[idx + 1] : '' const STAMP = new Date().toISOString().slice(0, 10) -const db = new Database('/var/lib/dshs/dshs.db', { readonly: true }) +const db = new Database(cfg.dbFile(), { readonly: true }) const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all() .filter((u) => onlyId === '' || u.id === onlyId) diff --git a/scripts/diag-db.sh b/scripts/diag-db.sh index ec21907..f7667c5 100644 --- a/scripts/diag-db.sh +++ b/scripts/diag-db.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" export PGPASSWORD=dshs_cluster_2026 -Q() { /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc "$1"; } +Q() { /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc "$1"; } echo "=== users ===" Q "select id || ' | ' || username || ' | ' || role || ' | uid=' || coalesce(uid::text,'-') from users order by row_id" echo "=== dsh_instances ===" diff --git a/scripts/dshlog.mjs b/scripts/dshlog.mjs index 3d046c1..ea2ac7a 100644 --- a/scripts/dshlog.mjs +++ b/scripts/dshlog.mjs @@ -37,10 +37,19 @@ const ROOT = process.env.DSHLOG_ROOT || "E:/dsh-logs"; const STATE = path.join(ROOT, "state.json"); const HOSTS_FILE = path.join(ROOT, "hosts.json"); -const DEFAULT_HOSTS = { - "47": { ssh: ["-p", "22", "root@47.77.182.89"], label: "Manager + w-47" }, - "106": { ssh: ["-p", "22", "test106"], label: "w-106" }, -}; +// 主机表从配置读取(⛔ 不在代码里写死地址 / ssh 别名): +// DSHLOG_HOSTS='{"mgr":{"ssh":["-p","22","root@10.0.0.1"],"label":"manager"}}' +// 未配置 ⇒ 空表,`hosts` 子命令会提示先配置。 +const DEFAULT_HOSTS = (() => { + const raw = process.env.DSHLOG_HOSTS ?? ""; + if (raw.trim() === "") return {}; + try { + return JSON.parse(raw); + } catch (e) { + console.error("DSHLOG_HOSTS 不是合法 JSON:", String(e)); + return {}; + } +})(); // 巡检规则:只认**指向本项目自身故障**的信号。 // ⚠️ 每条规则都是误报与漏报的取舍 —— 下面两组是实测调过的: diff --git a/scripts/ensure-anysearch-admin.cjs b/scripts/ensure-anysearch-admin.cjs index dd073ee..762e53d 100644 --- a/scripts/ensure-anysearch-admin.cjs +++ b/scripts/ensure-anysearch-admin.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * ensure-anysearch-admin.cjs —— 给指定实例接入 AnySearch 联网搜索(档案 64 · B 方案) * @@ -47,10 +48,10 @@ const { chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } = require('node:fs') const { basename, dirname, join, resolve } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') -const DB_PATH = '/var/lib/dshs/dshs.db' -const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts' +const DB_PATH = cfg.dbFile() +const ART_DIR = cfg.artifactDir() const PKG = '@anysearch/anysearch-dsh' const PROFILE = 'web' const KEY_ENV = 'ANYSEARCH_API_KEY' diff --git a/scripts/ensure-anysearch-pool.mjs b/scripts/ensure-anysearch-pool.mjs index 94cd4ff..8c6b0a4 100644 --- a/scripts/ensure-anysearch-pool.mjs +++ b/scripts/ensure-anysearch-pool.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * ensure-anysearch-pool.mjs —— 把 AnySearch 插件投放进「功能插件」候选池(档案 64 / 65) * @@ -19,13 +20,13 @@ */ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' -import Database from '/opt/dshs/node_modules/better-sqlite3/lib/index.js' +import Database from 'better-sqlite3' -const DATA_ROOT = process.env.DSH_DATA_ROOT ?? '/var/lib/dshs' -const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts' +const DATA_ROOT = cfg.dataRoot() +const ART_DIR = cfg.artifactDir() const POOL_DIR = join(DATA_ROOT, 'business-plugins') const DB_PATH = join(DATA_ROOT, 'dshs.db') -const PLUGIN_ROUTES = '/opt/dshs/lib/web/routes/business-plugins.js' +const PLUGIN_ROUTES = cfg.installPath('lib', 'web', 'routes', 'business-plugins.js') const PREFIX = 'anysearch-dsh-' const APPLY = process.argv.includes('--apply') diff --git a/scripts/ensure-biz-plugins.cjs b/scripts/ensure-biz-plugins.cjs index 1cb1752..14865e7 100644 --- a/scripts/ensure-biz-plugins.cjs +++ b/scripts/ensure-biz-plugins.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * ensure-biz-plugins.cjs —— 给用户铺「功能插件」分区(档案 36 · 批次 2) * @@ -18,12 +19,12 @@ const { execFileSync } = require('node:child_process') const { chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { basename, dirname, join, resolve } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') -const DB_PATH = '/var/lib/dshs/dshs.db' +const DB_PATH = cfg.dbFile() const BUNDLE = '@dsh-local/business-plugins' // 自动取产物目录里版本号最大的 business-plugins-*.tgz(升级只需丢新包,不用改脚本) -const ART_DIR = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts' +const ART_DIR = cfg.artifactDir() const ARTIFACT = (function () { const PREFIX = 'business-plugins-' const cands = readdirSync(ART_DIR).filter((f) => f.indexOf(PREFIX) === 0 && f.slice(-4) === '.tgz') diff --git a/scripts/ensure-portal-entry.cjs b/scripts/ensure-portal-entry.cjs index fc652c7..7a40dbf 100644 --- a/scripts/ensure-portal-entry.cjs +++ b/scripts/ensure-portal-entry.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * ensure-portal-entry.cjs —— 给**所有**用户铺「设置面板 → 用户管理」入口 * @@ -28,10 +29,10 @@ const { execFileSync } = require('node:child_process') const { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require('node:fs') const { basename, dirname, join } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') -const DB = '/var/lib/dshs/dshs.db' -const ARTIFACTS = process.env.DSH_PLATFORM_ARTIFACT_DIR ?? '/opt/dsh/artifacts' +const DB = cfg.dbFile() +const ARTIFACTS = cfg.artifactDir() const PROFILE = 'web' const BUNDLE = '@dsh-local/portal-entry' const PKG_DIR = '@dsh-local/portal-entry' @@ -195,7 +196,7 @@ for (const u of users) { } try { - // ① 暂存产物到该用户自己的 home(/opt/dsh 是 drwx------ root,用户 uid 读不到其中文件) + // ① 暂存产物到该用户自己的 home( 是 drwx------ root,用户 uid 读不到其中文件) const stageDir = join(u.home_dir, '.dsh-stage') mkdirSync(stageDir, { recursive: true, mode: 0o755 }) const staged = join(stageDir, WANT_BASE) diff --git a/scripts/gen-capabilities.cjs b/scripts/gen-capabilities.cjs index e07044e..79c2bc0 100644 --- a/scripts/gen-capabilities.cjs +++ b/scripts/gen-capabilities.cjs @@ -1,11 +1,12 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * gen-capabilities.cjs —— 生成「实例能力清单」(档案 56) * * 解决什么:agent 每开新会话都要**现场试一遍**才能知道能做什么(实测同一批探测重复 3 轮, * 撞同样 4 类墙:/etc 白名单、127.0.0.1 被 SSRF 拒、技能不存在、写边界),用户也跟着反复问 * "能力有变化吗"。本脚本把**平台事实**固化成两份同源产物: - * ① /opt/dsh/state/capabilities.json —— 给平台 API / 门户(机读) + * ① /state/capabilities.json —— 给平台 API / 门户(机读) * ② /platform-capabilities/SKILL.md —— 给实例内 agent(它可被 skill 机制加载) * * 用法:node scripts/gen-capabilities.cjs # 生成 @@ -17,10 +18,10 @@ const { execFileSync } = require('node:child_process') const { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } = require('node:fs') const { join } = require('node:path') -const STATE = process.env.DSH_STATE_DIR ?? '/opt/dsh/state' +const STATE = cfg.stateDir() const OUT_JSON = join(STATE, 'capabilities.json') -const BUNDLED = process.env.DSH_BUNDLED_SKILL_DIR ?? '/var/lib/dshs/bundled-skills' -const USERS = process.env.DSH_USERS_DIR ?? '/var/lib/dshs/users' +const BUNDLED = cfg.bundledSkillsDir() +const USERS = cfg.usersDir() const PERMISSION_MODE = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' const run = (cmd, args) => { @@ -96,7 +97,7 @@ const capabilities = { }, fileDelivery: { hint: '把产出交给用户时:**用工作区相对路径**(如 `报告.md`),并提示「点会话页右下角『我的文件』可查看/下载」。', - avoid: ['不要给 `/var/lib/dshs/users/...` 这类服务器绝对路径', '不要给 127.0.0.1: 链接(用户浏览器打不开)'], + avoid: ['不要给服务器绝对路径', '不要给 127.0.0.1: 链接(用户浏览器打不开)'], howToShare: '用户在会话页右下角「我的文件」面板里可浏览工作区并下载任意文件(平台代理,不暴露宿主路径)。', }, } diff --git a/scripts/install-egress-guard.sh b/scripts/install-egress-guard.sh index f50cf47..1a35bc6 100644 --- a/scripts/install-egress-guard.sh +++ b/scripts/install-egress-guard.sh @@ -20,6 +20,13 @@ if [ "$(id -u)" != "0" ]; then exit 1 fi +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" +# ⛔ 本机地址不写死在 nft 规则里:规则体里用占位符,写完文件再注入实际值。 +HOST_ADDRS="" +for a in "$DSH_HOST_LAN_IP" "$DSH_HOST_PUBLIC_IP"; do + [ -n "$a" ] && HOST_ADDRS="${HOST_ADDRS:+$HOST_ADDRS, }$a" +done + echo "==> 写入 /etc/nftables-dsh-egress.nft" cat > /etc/nftables-dsh-egress.nft <<'NFTEOF' #!/usr/sbin/nft -f @@ -50,18 +57,18 @@ table ip dsh_egress { # 封 4 类目的地址(仅实例主动发起的连接): # 127.0.0.0/8 → 宿主全部 loopback 服务(sshd 22/32022、nginx 80/443/888、 # 门户 3080、BT-Panel 58888/8765)+ 其他实例的 127.0.0.1 监听 - # 172.18.16.212 → 宿主内网卡(eth0,同样到达 nginx/面板) + # → 宿主内网卡(eth0,同样到达 nginx/面板) # 172.17.0.1 → docker0 网桥网关 - # 47.77.182.89 → 公网 EIP(回环到本机 nginx/sshd) + # → 公网 EIP(回环到本机 nginx/sshd) # 不影响:公网访问、阿里云内网 DNS(100.100.2.136/138)、pip/npm 下载、 # 实例自身监听端口、nginx 回源(root 发包 + 实例回包带 ack) - meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \ + meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, __HOST_ADDRS__ } \ meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \ counter log prefix "dsh-egress-HOST " level warn limit rate 20/minute - meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \ + meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, __HOST_ADDRS__ } \ meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn \ counter reject with tcp reset - meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \ + meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, __HOST_ADDRS__ } \ meta l4proto udp ct state new counter reject # H4 · 观测实例新建外联(先记录不拦截;排除 loopback 与 DNS 降噪) @@ -71,6 +78,13 @@ table ip dsh_egress { } NFTEOF +# 注入本机地址(来自 config/platform.env);未配 ⇒ 摘掉该占位符,规则只剩回环与 docker 网桥。 +if [ -n "$HOST_ADDRS" ]; then + sed -i "s|__HOST_ADDRS__|$HOST_ADDRS|g" /etc/nftables-dsh-egress.nft +else + sed -i "s|, __HOST_ADDRS__||g" /etc/nftables-dsh-egress.nft +fi + echo "==> 写入 /etc/systemd/system/dsh-egress.service" cat > /etc/systemd/system/dsh-egress.service <<'SVCEOF' [Unit] diff --git a/scripts/install-python-runtime.sh b/scripts/install-python-runtime.sh index 7e7e905..4ea0b10 100644 --- a/scripts/install-python-runtime.sh +++ b/scripts/install-python-runtime.sh @@ -1,4 +1,5 @@ #!/bin/bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 安装「dsh 实例共享运行时」—— 可移植 Python(python-build-standalone / install_only) # # 目的(档案 42): @@ -46,14 +47,14 @@ if [ -x "$PYDIR/bin/python3" ]; then echo "==> 已存在 $PYDIR,跳过解压" else if [ -z "$TARBALL" ]; then - for c in "/opt/dsh/artifacts/cpython-$PY_VER.tar.gz" "$RT/cpython-$PY_VER.tar.gz"; do + for c in ""$DSH_ARTIFACT_DIR"/cpython-$PY_VER.tar.gz" "$RT/cpython-$PY_VER.tar.gz"; do [ -f "$c" ] && TARBALL="$c" && break done fi if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then echo "==> 本地无 tarball,联网下载($PY_VER)" - mkdir -p /opt/dsh/artifacts - TARBALL="/opt/dsh/artifacts/cpython-$PY_VER.tar.gz" + mkdir -p "$DSH_ARTIFACT_DIR" + TARBALL=""$DSH_ARTIFACT_DIR"/cpython-$PY_VER.tar.gz" curl -fL --max-time 900 -o "$TARBALL" "$URL_DEFAULT" fi echo "==> 解压 $TARBALL" diff --git a/scripts/install-shared-tools.sh b/scripts/install-shared-tools.sh index d5b3e1d..3468a02 100644 --- a/scripts/install-shared-tools.sh +++ b/scripts/install-shared-tools.sh @@ -1,4 +1,5 @@ #!/bin/bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 安装「实例共享工具」到 /usr/local/dsh-runtime/bin(+ /usr/local/bin 软链) # # 为什么共享而不是每个用户装一份(档案 46): @@ -16,7 +17,7 @@ set -euo pipefail RT=/usr/local/dsh-runtime BIN="$RT/bin" LINK=/usr/local/bin -ART=/opt/dsh/artifacts +ART="$DSH_ARTIFACT_DIR" FORCE=0 [ "${1:-}" = "--force" ] && FORCE=1 @@ -26,7 +27,7 @@ RG_VER=15.2.0 FF_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" FF_SUM_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/checksums.sha256" -mkdir -p "$BIN" "$ART" /opt/dsh/state +mkdir -p "$BIN" "$ART" "$DSH_STATE_DIR" say() { printf '==> %s\n' "$*"; } fail() { printf '!! %s\n' "$*" >&2; exit 1; } diff --git a/scripts/install-workspace-picker.sh b/scripts/install-workspace-picker.sh index aa70b5e..a8447a7 100644 --- a/scripts/install-workspace-picker.sh +++ b/scripts/install-workspace-picker.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 安装 @dsh-local/workspace-scoped-picker 指定版本到所有用户 profile(幂等) set -euo pipefail TGZ_SRC="${1:?用法: install-wsp.sh }" @@ -6,8 +7,8 @@ VER=$(basename "$TGZ_SRC" | sed -E 's/^workspace-scoped-picker-(.*)\.tgz$/\1/') echo "版本: $VER" for U in cce6d1cd-b376-4304-80f0-0e1c58c9ffde:114801:"" 4092b965-2f68-4977-9989-68b3966f7df0:100002:-w; do ID=$(echo "$U" | cut -d: -f1); UID_=$(echo "$U" | cut -d: -f2); FLAG=$(echo "$U" | cut -d: -f3) - WS=/var/lib/dshs/users/$ID/ws - PP=/var/lib/dshs/users/$ID/home/profiles/web + WS="$DSH_USERS_DIR"/$ID/ws + PP="$DSH_USERS_DIR"/$ID/home/profiles/web cp -f "$TGZ_SRC" "$WS/workspace-scoped-picker-$VER.tgz" chown "$UID_:$UID_" "$WS/workspace-scoped-picker-$VER.tgz" ( cd "$PP" && setpriv --reuid "$UID_" --regid "$UID_" --clear-groups env HOME="$WS" pnpm add $FLAG "file:$WS/workspace-scoped-picker-$VER.tgz" >/tmp/pnpm-$ID.log 2>&1 ) && echo " [${ID:0:8}] pnpm OK" || { echo " [${ID:0:8}] pnpm FAILED"; tail -3 /tmp/pnpm-$ID.log; } diff --git a/scripts/instance-mem-sample.cjs b/scripts/instance-mem-sample.cjs index afd8369..b5fe222 100644 --- a/scripts/instance-mem-sample.cjs +++ b/scripts/instance-mem-sample.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * instance-mem-sample.cjs —— 实例内存用量采样 + 阈值告警(cron 每 10 分钟;只读 + 追加式写一个 json) * @@ -17,7 +18,7 @@ * 而实例 OOM 常发生在分钟级(实测 guest 20:11:10 被 OOM kill,上一次采样还是 19:35), * 小时级采样根本抓不到,等于没有预警。10 分钟 × 3 次 = 30 分钟,才有实际提前量。 * - * 输出 `/opt/dsh/state/instance-mem-peak.json`: + * 输出 `/state/instance-mem-peak.json`: * { "updatedAt": , "uids": { "": { peakMiB, peakAt, lastMiB, samples, unit, limitMiB, pct, highStreak } } } * * 用法:node instance-mem-sample.cjs [--print] @@ -25,7 +26,7 @@ const fs = require('node:fs') const { execFileSync } = require('node:child_process') -const STATE = process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state' +const STATE = cfg.stateDir() const OUT = `${STATE}/instance-mem-peak.json` // ⚠️ systemd 的 MemoryCurrent / MemoryMax 单位是**字节**(不是 KB)。 diff --git a/scripts/migrate-sqlite-to-pg.mjs b/scripts/migrate-sqlite-to-pg.mjs index 2e729d7..0be5795 100644 --- a/scripts/migrate-sqlite-to-pg.mjs +++ b/scripts/migrate-sqlite-to-pg.mjs @@ -14,8 +14,8 @@ * 5. `--dry-run` 只报行数,不写任何东西。 * * 用法: - * node scripts/migrate-sqlite-to-pg.mjs --sqlite /var/lib/dshs/dshs.db \ - * --pg postgres://dshs:***@127.0.0.1:15432/dshs [--dry-run] + * node scripts/migrate-sqlite-to-pg.mjs --sqlite /dshs.db \ + * --pg postgres://dshs:***@127.0.0.1:/dshs [--dry-run] * * @module dshs/scripts/migrate-sqlite-to-pg */ diff --git a/scripts/overlay-direct-probe.cjs b/scripts/overlay-direct-probe.cjs index 022ce0f..c7ec238 100644 --- a/scripts/overlay-direct-probe.cjs +++ b/scripts/overlay-direct-probe.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * 覆盖网络 **直连(打洞)观测面**(序㊵ · P2 · S5)—— **只读** + 一个**自检**子命令。 * @@ -41,7 +42,7 @@ * * ## 🆕 序㊸:**可跑性 = "节点形态也能跑"**(106 侧观测面缺口收口) * - * **实测缺口**:106(worker/relay 节点)的 `/opt/dshs-cluster/lib` **不含 `registry.js`** + * **实测缺口**:106(worker/relay 节点)的 `-cluster/lib` **不含 `registry.js`** * —— 那是**控制面注册表**模块,节点不落它。而本脚本原先在**顶层** `require` 了它 * (外加同样依赖它的 `join.js`)⇒ 在 106 上**任何**子命令都跑不起来: * `Error: Cannot find module '../lib/net/relay/registry.js'`。 @@ -225,11 +226,11 @@ async function switchCases(ctx) { new direct.DirectPath({ switchState: state, cooldownMs: ctx.cooldownMs, portBase: ctx.portBase, portSpan: ctx.portSpan, deadlineMs, maxAddrs: ctx.maxAddrs }) const good = cand.encodeDirectMessage({ - hostId: 'w-106', + hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }], }) - const ctxOf = { dialers: ctx.dialers, from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' } + const ctxOf = { dialers: ctx.dialers, from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager' } const defaultCase = direct.resolveDirectSwitch({}) const onState = direct.resolveDirectSwitch({ [direct.DIRECT_ENV_KEY]: 'true' }) @@ -305,8 +306,8 @@ function hintAudit() { function candidateMatrix(ctx) { const dialers = net.normalizeDialers( new Map([ - ['ops', new Set(['manager', 'w-106'])], - ['u:5', new Set(['w-106'])], + ['ops', new Set(['manager', 'w-2'])], + ['u:5', new Set(['w-2'])], ]), ) const led = new cand.CandidateLedger() @@ -321,17 +322,17 @@ function candidateMatrix(ctx) { const msg = (over = {}) => cand.encodeDirectMessage({ - hostId: 'w-106', + hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }], ts: Date.now(), ...over, }) - const inOps = { dialers, from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager', maxAddrs: ctx.maxAddrs } + const inOps = { dialers, from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager', maxAddrs: ctx.maxAddrs } run('ops-单地址', msg(), inOps) run('ops-双地址', msg({ addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }, { host: '2001:db8::1', port: ctx.portBase + 2 }] }), inOps) - const u5 = { dialers, from: { network: 'u:5', hostId: 'w-106' }, selfHostId: 'w-106', maxAddrs: ctx.maxAddrs } + const u5 = { dialers, from: { network: 'u:5', hostId: 'w-2' }, selfHostId: 'w-2', maxAddrs: ctx.maxAddrs } run('u:5-同名跨网可拨', msg({ network: 'u:5' }), u5) run('跨网', msg({ network: 'u:5' }), inOps) @@ -342,7 +343,7 @@ function candidateMatrix(ctx) { run('形状非法', '{"kind":"DIRECT_CANDIDATE"}', inOps) run( '夹带密钥字段', - JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: 21101 }], nodeKey: 'x' }), + JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: 21101 }], nodeKey: 'x' }), inOps, ) run('主机名当地址', msg({ addrs: [{ host: 'example.com', port: 80 }] }), inOps) @@ -368,8 +369,8 @@ function candidateMatrix(ctx) { /** 打洞(**判据 D5 / D6**)—— 成功路径走 NAT 模拟器(真 `dgram` + 同一份打洞代码)。 */ async function punchCases(ctx) { const deadlineMs = 2500 - const ok = await punch.runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs }, { sleep }) - const oneWay = await punch.runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 1200, oneWay: 'a' }, { sleep }) + const ok = await punch.runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs }, { sleep }) + const oneWay = await punch.runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs: 1200, oneWay: 'a' }, { sleep }) const cd = new punch.DirectCooldown(ctx.cooldownMs) let openedSockets = 0 @@ -453,7 +454,7 @@ async function punchCases(ctx) { * * 🆕 **序㊸:本条腿的依赖是"可选"的** —— 它要 `lib/net/relay/join.js` 与它 import 的 * `lib/net/relay/registry.js`(**控制面注册表**模块)。**节点形态**(如 106 的 - * `/opt/dshs-cluster/lib`)**不落 `registry.js`** ⇒ 这里**具名降级**: + * `-cluster/lib`)**不落 `registry.js`** ⇒ 这里**具名降级**: * `{ available:false, reason:'module-missing', missing:[…原文 message…] }`。 * 🔴 ⛔ **不许静默返"没有"**(不填 `direct` / `readback` —— 那会让"没装"看起来像"读到了 null")。 * 判据 = 「该腿不可用」与「该腿跑了但读数为空」在读数里**形状完全不同** ⇒ 可分。 @@ -532,7 +533,7 @@ async function selfcheck() { portBase: punch.PUNCH_PORT_BASE, portSpan: punch.PUNCH_PORT_SPAN, maxAddrs: cand.DIRECT_CAND_MAX_ADDRS, - dialers: net.normalizeDialers(new Map([['ops', new Set(['manager', 'w-106'])], ['u:5', new Set(['w-106'])]])), + dialers: net.normalizeDialers(new Map([['ops', new Set(['manager', 'w-2'])], ['u:5', new Set(['w-2'])]])), } /** `--ss` 腿(真实机取证):① 基线 ② **关闭态应为 0** ③ **正对照**(绑一个真口 ⇒ 应为 ≥1)。 */ const ssProbe = () => ssDirectUdpLines(ctx.portBase, ctx.portSpan) diff --git a/scripts/overlay-failover-drill.cjs b/scripts/overlay-failover-drill.cjs index ac0e25d..d52560d 100644 --- a/scripts/overlay-failover-drill.cjs +++ b/scripts/overlay-failover-drill.cjs @@ -42,7 +42,7 @@ * 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。 * * ## 运行(🆕 序㊸:**cwd 不再受限**;⛔ 阈值零硬编码) - * `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]` + * `node "/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]` * ⚠️ **参数表定位已与 cwd 解耦**(从代码仓根 / 任意目录都可跑):候选目录链 = `--table` > `--dir` * > `DSHS_OVERLAY_TABLE_DIR` > **注册文件**(缺省 `~/.dshs/overlay-table-dir`)> `cwd` > 脚本目录及上两级。 * 🔴 `--scene trace` / `--scene sample` 的**明细落盘**仍按 `cwd` 写 `_中间产物_待清理/seq9-trace/` @@ -942,15 +942,15 @@ async function main() { /* ═══ 幕 4(序⑧):**冷却语义拆分**的真机判据(E9 / 构 A) ═══ * - * 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`ai1net.com` + `relay-direct.ai1net.com` + * 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`` + `relay-direct.` * 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 * ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。 * * 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106 - * (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`ai1net` = switched-away) + * (manager 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`` = switched-away) * → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。 * - * 🔴 **2026-09-19 域迁 `ai1net.com` 修正**:本幕判定"目标是不是 47 那台"原先**写死** `alotbuy.com`, + * 🔴 **2026-09-19 域迁 `` 修正**:本幕判定"目标是不是 47 那台"原先**写死** ``, * 域一切就恒判失败(**假红**:豁免实际已生效并切回 47,仅字面匹配不上)。 * ⇒ 现一律改用参数表的 `DRILL_KILLED_MATCH`(= 目录 `relays[]` 首位的 host), * ⛔ 不再在脚本里写死域名 —— 下次换域只改参数表一处。 diff --git a/scripts/overlay-holepunch.cjs b/scripts/overlay-holepunch.cjs index 1f1e32e..7ae58fa 100644 --- a/scripts/overlay-holepunch.cjs +++ b/scripts/overlay-holepunch.cjs @@ -23,8 +23,8 @@ * * 用法: * node overlay-holepunch.cjs --observer --port-x 21100 --port-y 21101 --token --secs 90 - * node overlay-holepunch.cjs --probe --obs 47.77.182.89 --port-x 21100 --port-y 21101 \ - * --token --name w-dev --peers w-47u,w-106u + * node overlay-holepunch.cjs --probe --obs --port-x 21100 --port-y 21101 \ + * --token --name w-dev --peers u,u * * @module scripts/overlay-holepunch */ diff --git a/scripts/overlay-jitter.cjs b/scripts/overlay-jitter.cjs index 4cc64d6..d6b62ab 100644 --- a/scripts/overlay-jitter.cjs +++ b/scripts/overlay-jitter.cjs @@ -19,10 +19,10 @@ * 本线教训:「另一份实现 = 另一处静默失效」(取址链踩过两次)。 * * 用法: - * node overlay-jitter.cjs --icmp 106.54.21.172 --count 300 --interval 0.2 - * node overlay-jitter.cjs --tcp 106.54.21.172:22 --tcp-n 30 + * node overlay-jitter.cjs --icmp --count 300 --interval 0.2 + * node overlay-jitter.cjs --tcp :22 --tcp-n 30 * node overlay-jitter.cjs --icmp H --count 300 --tcp H:22 --tcp-n 30 # 两者一起跑 - * node overlay-jitter.cjs --watch --targets 106.54.21.172:22,47.77.182.89:22 --rounds 40 --gap 400 + * node overlay-jitter.cjs --watch --targets :22,:22 --rounds 40 --gap 400 * * @module scripts/overlay-jitter */ diff --git a/scripts/overlay-keyring.cjs b/scripts/overlay-keyring.cjs index 5205ba5..c7cecd3 100644 --- a/scripts/overlay-keyring.cjs +++ b/scripts/overlay-keyring.cjs @@ -26,9 +26,9 @@ * node scripts/overlay-keyring.cjs init-signer --key /etc/dshs/overlay-signer-key.pem * node scripts/overlay-keyring.cjs sign-signerset --root-key --signers --network ops --out * node scripts/overlay-keyring.cjs init-node --key /etc/dshs/node.key - * node scripts/overlay-keyring.cjs issue-grant --signer-key --network ops --host w-106 --node-key --out + * node scripts/overlay-keyring.cjs issue-grant --signer-key --network ops --host --node-key --out * node scripts/overlay-keyring.cjs sign-revocations --signer-key --network ops --hosts a,b --out - * node scripts/overlay-keyring.cjs verify-grant --file --signer-pub [--host w-106 --network ops] + * node scripts/overlay-keyring.cjs verify-grant --file --signer-pub [--host --network ops] * node scripts/overlay-keyring.cjs recover-root --code --out [--expect-pub ] * # 演练三判据:--expect-pub 比公钥; * # 再给 --signers --network --issued-at ⇒ 用重建的根签 SignerSet 验通(判据②); diff --git a/scripts/overlay-node-admit.cjs b/scripts/overlay-node-admit.cjs index b138cde..733c634 100644 --- a/scripts/overlay-node-admit.cjs +++ b/scripts/overlay-node-admit.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * 覆盖网络 **控制面侧** 命令行:网注册表 / 准入凭据 / 白名单派生(序㊱ · P1 · S1+S2+S4)。 * @@ -24,7 +25,7 @@ * ## 全局选项(**键名不可混用**) * | 选项 | 含义 | 备注 | * |---|---|---| - * | `--dir` | 注册表**目录** | 优先于 env `DSHS_OVERLAY_REGISTRY_DIR`,缺省 `/var/lib/dshs/overlay` | + * | `--dir` | 注册表**目录** | 优先于 env `DSHS_OVERLAY_REGISTRY_DIR`,缺省 `/overlay` | * | `--registry` | 注册表**文件**(覆盖 `--dir` 下的 `nodes.json`) | 🔴 **⛔ 不要用 `--file`** —— 那是 `apply` 的申请单入参 | * | `--signer-pub` | 受信签名者公钥(可重复) | 未给 ⇒ 回落 env `DSHS_OVERLAY_SIGNER_PUBKEYS` | * @@ -79,7 +80,7 @@ function opt(name) { } /** 注册表目录(`DSHS_OVERLAY_REGISTRY_DIR` 可覆盖;⛔ 生产值不在代码里写死第二份口径)。 */ -const REGISTRY_DIR = opt('dir') ?? process.env.DSHS_OVERLAY_REGISTRY_DIR ?? '/var/lib/dshs/overlay' +const REGISTRY_DIR = opt('dir') ?? cfg.overlayDir() /** * 🔴 **覆盖注册表文件用 `--registry`,⛔ 不是 `--file`** —— 真机首轮实测踩坑: * `--file` 在本 CLI 里已被 **`apply --file <申请单>`** 占用(还有 `issue-invite --out`), diff --git a/scripts/overlay-probe.cjs b/scripts/overlay-probe.cjs index 162630b..2cf5028 100644 --- a/scripts/overlay-probe.cjs +++ b/scripts/overlay-probe.cjs @@ -6,7 +6,7 @@ * `交接单_relay落地R2-R4` 的教训原文是「**静默失效靠判别器定位**」。要让判别器能被**脚本** * (而不是人读日志)用,就必须有「一条命令出 PASS/FAIL」的入口 —— 本文件就是那个入口: * - * cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs" + * cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "/scripts/overlay-probe.cjs" * * - **退出码**:全绿 `0` / 任一红 `1` / 用法或取数失败 `2`(可直接被 automation 消费) * - **输出**:≤ 12 行(每行 = 一条指标);红的条目**另外**写到 stderr,并**指名**是哪个 ID @@ -66,7 +66,7 @@ * ## 🆕 观测面**并集**(序㊾:⛔ 单看 47 会把"合法拓扑态"判成红) * `OBS-01` / `OBS-08` / `OBS-09` 的绿**取决于 106 worker 挂在哪台中继** —— worker 通道按**抖动**换址 * (`[relay-switch]`)⇒ 一旦落到 **106 自家中继**,47 视角就是 - * `used=1 / endpoints=[w-106: offline, w-106:<实例口> offline]` ⇒ `OBS-01` FAIL、`OBS-08` FAIL、 + * `used=1 / endpoints=[: offline, :<实例口> offline]` ⇒ `OBS-01` FAIL、`OBS-08` FAIL、 * `OBS-09` SKIP —— 三条**全是假红 / 假 SKIP**(客户端好好的,只是"不在我这一台")。 * * 口径 = **按 `hostId`(含 `network`)合并两台中继的视图**: @@ -182,7 +182,7 @@ function argOf(argv, name) { * 4. **注册文件**的每一行(`DSHS_OVERLAY_TABLE_REGISTRY`,缺省 `~/.dshs/overlay-table-dir`; * `#` 起注释;**机器本地、⛔ 不入库**) * 5. `cwd`(保持既有默认,⛔ 不破坏老用法) - * 6. 脚本自身目录、其上一级、上两级(仓根 / `/opt/dshs` 这类部署形态都覆盖) + * 6. 脚本自身目录、其上一级、上两级(仓根 / `` 这类部署形态都覆盖) * * 🔴 **"恰好 1 个才算合法"这条防错保留、且更严**: * 任一候选目录里 ≥2 份 ⇒ **立即报错**;跨候选目录合计 ≥2 份 ⇒ **也报错**(列出全部命中)。 @@ -1637,7 +1637,7 @@ function main() { * ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。 * * 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**: - * 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `ai1net.com` 与 `relay-direct.ai1net.com` + * 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `` 与 `relay-direct.` * **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。 * 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**; * 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。 diff --git a/scripts/provision-new-users.sh b/scripts/provision-new-users.sh index c583113..6c05877 100644 --- a/scripts/provision-new-users.sh +++ b/scripts/provision-new-users.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" set -euo pipefail -for dir in /var/lib/dshs/users/*/; do +for dir in "$DSH_USERS_DIR"/*/; do [ -d "$dir" ] || continue id="$(basename "$dir")" [ "$id" = . ] && continue short="$(echo "$id" | tr -d '-' | cut -c1-20)" user="dsh-$short" if ! id "$user" &>/dev/null; then - uid="$(node /opt/dshs/lib/cli.js uid-for-user "$id" --db /var/lib/dshs/dshs.db 2>/dev/null || echo 0)" + uid="$(node "$DSH_INSTALL_DIR/lib"/cli.js uid-for-user "$id" --db "$DSH_DB_FILE" 2>/dev/null || echo 0)" [ "$uid" = 0 ] && { echo "SKIP $id (uid-for-user失败)"; continue; } # 若该uid已被其他账号占用则跳过 if id "$uid" &>/dev/null; then echo "SKIP $id (uid $uid 已被占用)"; continue; fi @@ -22,8 +23,8 @@ done # 2026-09-11 追加(档案 18 v3 收尾):为该批新用户补齐「目录选择器收敛」—— # ① 安装受限插件(逐用户 pnpm add)② 写平台段(cordis.patch.yml,幂等)。 # best-effort:失败不影响上面的账号 provisioning;日志见 journalctl -u dsh-provision。 -if [ -f /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs ]; then - node /opt/dshs/poc/workspace-scoped-picker/ensure-workspace-picker.cjs 2>&1 | sed "s/^/[picker] /" || true +if [ -f "$DSH_INSTALL_DIR"/poc/workspace-scoped-picker/ensure-workspace-picker.cjs ]; then + node "$DSH_INSTALL_DIR"/poc/workspace-scoped-picker/ensure-workspace-picker.cjs 2>&1 | sed "s/^/[picker] /" || true fi # 2026-09-11 追加:「设置面板 → 用户管理」入口(@dsh-local/portal-entry)全员兜底。 @@ -31,6 +32,6 @@ fi # 普通用户=仅退出登录)。**普通用户没有它就没有任何退出登录入口**,故必须与新用户 # 注册同步补齐(与上面的 picker 同一时机、同一 best-effort 策略)。 # 幂等:按 node_modules 已装版本 + dep spec 判定,已是最新即跳过。 -if [ -f /opt/dshs/scripts/ensure-portal-entry.cjs ]; then - node /opt/dshs/scripts/ensure-portal-entry.cjs 2>&1 | sed "s/^/[portal-entry] /" || true +if [ -f "$DSH_INSTALL_DIR/scripts"/ensure-portal-entry.cjs ]; then + node "$DSH_INSTALL_DIR/scripts"/ensure-portal-entry.cjs 2>&1 | sed "s/^/[portal-entry] /" || true fi diff --git a/scripts/purge-trash.sh b/scripts/purge-trash.sh index bd748db..6234742 100644 --- a/scripts/purge-trash.sh +++ b/scripts/purge-trash.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # purge-trash.sh —— 清理各用户 trash/ 下超过 30 天的回收目录(档案 28) # 回收站是"清理动作的缓冲区":脚本只做 mv 进来,只有本脚本才真正 rm。 set -uo pipefail KEEP_DAYS="${1:-30}" LOG=/var/log/dsh-trash-purge.log echo "[$(date -Is)] purge trash older than ${KEEP_DAYS}d" >> "$LOG" -for t in /var/lib/dshs/users/*/trash; do +for t in "$DSH_USERS_DIR"/*/trash; do [ -d "$t" ] || continue find "$t" -mindepth 1 -maxdepth 1 -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + >> "$LOG" 2>&1 done diff --git a/scripts/push-parallel-47to106.sh b/scripts/push-parallel-47to106.sh index ade3544..def455e 100644 --- a/scripts/push-parallel-47to106.sh +++ b/scripts/push-parallel-47to106.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 存量数据并行搬运 47 → 106(4 路并发;瓶颈在源端小文件 IOPS,单流只 ~0.4MB/s) # 搬完写 /root/push-parallel.done,供后续 cutover 判断 set -uo pipefail KEY=/root/.ssh/dshworker_ed25519 -DST=root@106.54.21.172 +DST=root@"$DSH_PEER_PUBLIC_IP" SSHO="-i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o Compression=no" -SRC=/var/lib/dshs +SRC="$DSHS_DATA_ROOT" LOG=/root/push-parallel.log : > "$LOG" -ssh -n $SSHO "$DST" 'mkdir -p /var/lib/dshs' +ssh -n $SSHO "$DST" "mkdir -p $DSHS_DATA_ROOT" echo "[$(date +%T)] 目标端就绪" >> "$LOG" # 按"大小"分组:大用户单开一路,其余合流(每路一个 tar→ssh) @@ -19,7 +20,7 @@ one() { # $1=组名 其余=相对路径列表 cd "$SRC" || exit 1 { for p in "$@"; do [ -e "$p" ] && echo "$p"; done; } > "/tmp/list-$name.txt" tar --numeric-owner --files-from="/tmp/list-$name.txt" -cf - \ - | ssh $SSHO "$DST" "tar -C /var/lib/dshs --numeric-owner -xf -" + | ssh $SSHO "$DST" "tar -C "$DSHS_DATA_ROOT" --numeric-owner -xf -" echo "[$(date +%T)] $name 完成 rc=$?" >> "$LOG" ) & } @@ -33,5 +34,5 @@ one g4 "users/7ba268be-6103-438c-8e6b-609b422ccbca" "users/ca3f36e0-937f-437e-b8 wait echo "[$(date +%T)] 全部完成" >> "$LOG" -ssh -n $SSHO "$DST" 'du -sm /var/lib/dshs | cut -f1' >> "$LOG" 2>&1 +ssh -n $SSHO "$DST" "du -sm $DSHS_DATA_ROOT | cut -f1" >> "$LOG" 2>&1 touch /root/push-parallel.done diff --git a/scripts/runtime-baseline.cjs b/scripts/runtime-baseline.cjs index 90b53ae..1c57cc3 100644 --- a/scripts/runtime-baseline.cjs +++ b/scripts/runtime-baseline.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * 运行时版本基线巡检(档案 44)。 * @@ -16,7 +17,7 @@ const { execFileSync } = require('node:child_process') const { existsSync, readFileSync, writeFileSync } = require('node:fs') -const BASE = '/opt/dsh/state/runtime-baseline.json' +const BASE = require('node:path').join(cfg.stateDir(), 'runtime-baseline.json') const ACCEPT = process.argv.includes('--accept') const run = (cmd, args) => { diff --git a/scripts/session-gc.cjs b/scripts/session-gc.cjs index d9ba7cb..7949ef2 100644 --- a/scripts/session-gc.cjs +++ b/scripts/session-gc.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * session-gc.cjs —— 会话记录保留期回收(档案 14 §H3 / 档案 28) * 规则(用户 2026-09-11 定):**每个用户的 sessions 达到阈值才触发**,清理 **超过 N 天** 的会话目录。 @@ -11,7 +12,7 @@ const { execFileSync } = require('node:child_process') const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs') const { join } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') const argv = process.argv.slice(2) const APPLY = argv.includes('--apply') @@ -26,7 +27,7 @@ const CUTOFF = Date.now() - DAYS * 86400_000 const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } } const fmt = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB') -const db = new Database('/var/lib/dshs/dshs.db', { readonly: true }) +const db = new Database(cfg.dbFile(), { readonly: true }) const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY) for (const u of users) { diff --git a/scripts/setup-pg-47.sh b/scripts/setup-pg-47.sh index 39751c7..5f88824 100644 --- a/scripts/setup-pg-47.sh +++ b/scripts/setup-pg-47.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# 在 47 初始化「控制面 PG」:独立数据目录 /var/lib/dshs-pg + 专用 unit dshs-pg.service +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" +# 在 47 初始化「控制面 PG」:独立数据目录 -pg + 专用 unit dshs-pg.service # 设计口径:控制面 DB 在 Manager 侧、仅 loopback、scram 认证。 set -uo pipefail -PGDATA=/var/lib/dshs-pg +PGDATA="${DSH_PG_DATA_DIR}" PGPORT=15432 DBPW="${DSHS_PG_PASSWORD:-dshs_cluster_2026}" PGVER=$(/usr/bin/postgres --version | grep -oE '[0-9]+' | head -1) diff --git a/scripts/start-cluster-manager.sh b/scripts/start-cluster-manager.sh index ad862c9..628ede7 100644 --- a/scripts/start-cluster-manager.sh +++ b/scripts/start-cluster-manager.sh @@ -1,24 +1,25 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 起一台 **cluster 模式的 Manager**(T08 跨机演练用;在 Manager 那台机器上跑)。 # # 现场的对照(2026-09-15 演练实测): # · Manager 在 **47**(本脚本所在机器),监听 `127.0.0.1:13080`(**不公网暴露**) -# · Worker agent 在 **106**,经 SSH 反向隧道出现在本机 `127.0.0.1:19000` / `19001` -# · 控制面 PG 也在 **106**,经同一条隧道出现在本机 `127.0.0.1:15432` +# · Worker agent 在 **106**,经 SSH 反向隧道出现在本机 `127.0.0.1:` / `19001` +# · 控制面 PG 也在 **106**,经同一条隧道出现在本机 `127.0.0.1:` # 用法:bash scripts/start-cluster-manager.sh (env 见下方 manager.env) 在 47 上:建 manager.env、bootstrap 管理员、起 cluster Manager(127.0.0.1:13080) set -uo pipefail -cd /opt/dshs-cluster || exit 1 +cd "$DSH_INSTALL_DIR"-cluster || exit 1 -cat > /opt/dshs-cluster/manager.env <<'ENVEOF' +cat > "$DSH_INSTALL_DIR"-cluster/manager.env < { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } } -const db = new Database('/var/lib/dshs/dshs.db', { readonly: true }) +const db = new Database(cfg.dbFile(), { readonly: true }) const users = db.prepare('SELECT username, home_dir FROM users ORDER BY username').all() const rows = users.map((u) => { diff --git a/scripts/switch-A-deploy47.sh b/scripts/switch-A-deploy47.sh index 3319675..c5dd9e2 100644 --- a/scripts/switch-A-deploy47.sh +++ b/scripts/switch-A-deploy47.sh @@ -1,30 +1,32 @@ #!/usr/bin/env bash # 切换 A 步(在 47 上跑): -# ① 备份 /opt/dshs/lib → /opt/dsh/backups/lib-/ -# ② 覆盖 /opt/dshs/lib(T08 集群版代码) -# ③ 装 **本地 Worker**(w-47,19100,无隧道 —— Manager 同机直连) -# ④ 把既有用户(admin/guest)的归属**预置**为 w-47(否则粘性落点无处可粘、新老用户会被按容量随机调度) -# ⑤ 在 PG 里注册 w-47 / w-106 两台 worker +# ① 备份 /lib → /backups/lib-/ +# ② 覆盖 /lib(T08 集群版代码) +# ③ 装 **本地 Worker**(,19100,无隧道 —— Manager 同机直连) +# ④ 把既有用户(admin/guest)的归属**预置**为 (否则粘性落点无处可粘、新老用户会被按容量随机调度) +# ⑤ 在 PG 里注册 / 两台 worker set -uo pipefail +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" TS=$(date +%Y%m%d-%H%M%S) -W47_TOKEN="dshs-worker-47-c4b7e19f" -W106_TOKEN="dshs-worker-7f3a91c05e" -PGURL="postgres://dshs:dshs_cluster_2026@127.0.0.1:15432/dshs" +# 凭据 / 地址一律来自配置(config/platform.env)—— ⛔ 不在脚本里写死 +W47_TOKEN="${DSHS_CLUSTER_AGENT_TOKEN:?config/platform.env 缺 DSHS_CLUSTER_AGENT_TOKEN}" +W106_TOKEN="${DSH_PEER_AGENT_TOKEN:?config/platform.env 缺 DSH_PEER_AGENT_TOKEN}" +PGURL="${DSHS_DB_URL:?config/platform.env 缺 DSHS_DB_URL}" TARBALL=/tmp/dshs-lib-new.tgz -echo "=== ① 备份 /opt/dshs/lib ===" -mkdir -p "/opt/dsh/backups/lib-$TS" -cp -a /opt/dshs/lib "/opt/dsh/backups/lib-$TS/lib" && echo " ✓ 备份到 /opt/dsh/backups/lib-$TS/lib($(find /opt/dsh/backups/lib-$TS -type f | wc -l) 文件)" +echo "=== ① 备份 $DSH_INSTALL_DIR/lib ===" +mkdir -p "$DSH_BACKUP_DIR/lib-$TS" +cp -a "$DSH_INSTALL_DIR/lib" "$DSH_BACKUP_DIR/lib-$TS/lib" && echo " ✓ 备份到 $DSH_BACKUP_DIR/lib-$TS/lib($(find "$DSH_BACKUP_DIR/lib-$TS" -type f | wc -l) 文件)" echo "=== ② 覆盖 lib ===" [ -f "$TARBALL" ] || { echo " ✗ 缺少 $TARBALL"; exit 1; } -rm -rf /opt/dshs/lib && tar -xzf "$TARBALL" -C /opt/dshs -echo " ✓ 已覆盖;cluster 特征检查: $(grep -l "DEPLOY_MODE" /opt/dshs/lib/config.js >/dev/null 2>&1 && echo '有 cluster 代码 ✓' || echo '✗ 未见 cluster 代码')" -echo " lease/agent/tunnel: $(ls /opt/dshs/lib/supervisor/lease.js /opt/dshs/lib/worker/agent.js /opt/dshs/lib/worker/tunnel.js 2>/dev/null | wc -l)/3" +rm -rf "$DSH_INSTALL_DIR"/lib && tar -xzf "$TARBALL" -C "$DSH_INSTALL_DIR" +echo " ✓ 已覆盖;cluster 特征检查: $(grep -l "DEPLOY_MODE" "$DSH_INSTALL_DIR"/lib/config.js >/dev/null 2>&1 && echo '有 cluster 代码 ✓' || echo '✗ 未见 cluster 代码')" +echo " lease/agent/tunnel: $(ls "$DSH_INSTALL_DIR"/lib/supervisor/lease.js "$DSH_INSTALL_DIR"/lib/worker/agent.js "$DSH_INSTALL_DIR"/lib/worker/tunnel.js 2>/dev/null | wc -l)/3" -echo "=== ③ 本地 Worker 单元(w-47,无隧道) ===" +echo "=== ③ 本地 Worker 单元("$DSHS_CLUSTER_HOST_ID",无隧道) ===" cat > /etc/dshs-worker.env </dev/null 2>&1 systemctl restart dshs-worker; sleep 5 echo " dshs-worker=$(systemctl is-active dshs-worker) healthz=$(curl -s -m 6 http://127.0.0.1:19100/healthz | head -c 120)" -echo "=== ④ 既有用户归属预置为 w-47(粘性锚点) ===" -PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ +echo "=== ④ 既有用户归属预置为 $DSHS_CLUSTER_HOST_ID(粘性锚点) ===" +PGPASSWORD="$DSHS_PG_PASSWORD" /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc \ "insert into dsh_instances (id, user_id, role, status, host_id, epoch, heartbeat_at, lease_until) - select 'dsh-'||id, id, 'main', 'stopped', 'w-47', 0, 0, 0 from users - on conflict (id) do update set host_id='w-47', lease_until=0" 2>&1 | tail -1 -PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ + select 'dsh-'||id, id, 'main', 'stopped', '$DSHS_CLUSTER_HOST_ID', 0, 0, 0 from users + on conflict (id) do update set host_id='$DSHS_CLUSTER_HOST_ID', lease_until=0" 2>&1 | tail -1 +PGPASSWORD="$DSHS_PG_PASSWORD" /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc \ "select u.username||' -> '||coalesce(i.host_id,'NULL') from users u left join dsh_instances i on i.user_id=u.id" 2>&1 | sed 's/^/ /' echo "=== ⑤ 注册两台 worker ===" -PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ +PGPASSWORD="$DSHS_PG_PASSWORD" /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc \ "insert into dsh_hosts (id, endpoint, agent_token, capacity_mb, used_mb, status) - values ('w-47','http://127.0.0.1:19100','$W47_TOKEN',1024,0,'up'), - ('w-106','http://127.0.0.1:19000','$W106_TOKEN',2560,0,'up') + values ('$DSHS_CLUSTER_HOST_ID','http://127.0.0.1:19100','$W47_TOKEN',1024,0,'up'), + ('$DSH_PEER_HOST_ID','http://127.0.0.1:19000','$W106_TOKEN',2560,0,'up') on conflict (id) do update set endpoint=excluded.endpoint, agent_token=excluded.agent_token, capacity_mb=excluded.capacity_mb, status='up'" 2>&1 | tail -1 -PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ +PGPASSWORD="$DSHS_PG_PASSWORD" /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc \ "select id||' cap='||capacity_mb||' status='||status||' ep='||endpoint from dsh_hosts order by id" 2>&1 | sed 's/^/ /' echo "=== 回滚剧本(现在就记下) ===" echo " rm -f /etc/systemd/system/dshs.service.d/cluster.conf && systemctl daemon-reload && \\" -echo " systemctl restart dshs # 回 SQLite 单机;lib 回滚 = cp -a /opt/dsh/backups/lib-$TS/lib /opt/dshs/lib" +echo " systemctl restart dshs # 回 SQLite 单机;lib 回滚 = cp -a $DSH_BACKUP_DIR/lib-$TS/lib $DSH_INSTALL_DIR/lib" echo " 备份时间戳: $TS" diff --git a/scripts/switch-A-migrate.sh b/scripts/switch-A-migrate.sh index 9120dd7..54271cd 100644 --- a/scripts/switch-A-migrate.sh +++ b/scripts/switch-A-migrate.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # A 步:把 47 的生产库 SQLite → 本机控制面 PG(停机窗口内做,避免迁移期间库变动) set -uo pipefail PGURL="postgres://dshs:dshs_cluster_2026@127.0.0.1:15432/dshs" -DB=/var/lib/dshs/dshs.db -cd /opt/dshs-cluster || exit 1 +DB="$DSHS_DATA_ROOT"/dshs.db +cd "$DSH_INSTALL_DIR"-cluster || exit 1 echo "=== 1) 停 dshs(窗口开始) ===" systemctl stop dshs diff --git a/scripts/switch-A2-verify-users.sh b/scripts/switch-A2-verify-users.sh index 08da391..2fcd326 100644 --- a/scripts/switch-A2-verify-users.sh +++ b/scripts/switch-A2-verify-users.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # 校验:SQLite 与 PG 两侧的 users 明细 + 与磁盘目录对照(判断 2 vs 7 是孤儿目录还是迁移漏行) set -uo pipefail -cd /opt/dshs-cluster || exit 1 +cd "$DSH_INSTALL_DIR"-cluster || exit 1 echo "=== SQLite 侧(只读打开,含 WAL) ===" node -e ' const D = require("better-sqlite3"); -const db = new D("/var/lib/dshs/dshs.db", { readonly: true }); +const db = new D(""$DSH_DB_FILE"", { readonly: true }); const users = db.prepare("select id, username, role, uid from users order by rowid").all(); console.log(" users 行数:", users.length); for (const u of users) console.log(` ${u.username} role=${u.role} uid=${u.uid} id=${u.id}`); @@ -13,9 +14,9 @@ console.log(" credential_vault:", db.prepare("select count(*) c from credential console.log(" business_plugins:", db.prepare("select count(*) c from business_plugins").get().c); ' echo "=== PG 侧 ===" -PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ +PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc \ "select username||' role='||role||' uid='||coalesce(uid::text,'NULL')||' id='||id from users order by rowid" 2>&1 | sed 's/^/ /' -echo " PG users 总数: $(PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc 'select count(*) from users')" +echo " PG users 总数: $(PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p "$DSHS_PG_PORT" -U dshs -d dshs -tAc 'select count(*) from users')" echo "=== 磁盘目录 vs DB ===" -echo " 目录($(ls /var/lib/dshs/users | wc -l) 个):" -ls /var/lib/dshs/users | sed 's/^/ /' +echo " 目录($(ls "$DSH_USERS_DIR" | wc -l) 个):" +ls "$DSH_USERS_DIR" | sed 's/^/ /' diff --git a/scripts/switch-B1-key.sh b/scripts/switch-B1-key.sh index 9825e7a..f2c14a0 100644 --- a/scripts/switch-B1-key.sh +++ b/scripts/switch-B1-key.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # B1 步(在 47 上跑):uid 保真校验 + 建 47→106 专用密钥并打印公钥 set -uo pipefail KEY=/root/.ssh/dshworker_ed25519 @@ -6,16 +7,16 @@ KEY=/root/.ssh/dshworker_ed25519 echo "=== 1) uid 保真校验(PG 与 SQLite 必须一致 —— 否则 106 上文件属主全错) ===" echo -n " PG : "; PGPASSWORD=dshs_cluster_2026 /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc \ "select string_agg(username||'='||coalesce(uid::text,'NULL'), ' ' order by row_id) from users" 2>&1 | head -1 -echo -n " SQLite : "; node -e 'const D=require("better-sqlite3");const db=new D("/var/lib/dshs/dshs.db",{readonly:true});console.log(db.prepare("select username, uid from users order by rowid").all().map(u=>u.username+"="+u.uid).join(" "))' +echo -n " SQLite : "; node -e 'const D=require("better-sqlite3");const db=new D("'"$DSHS_DATA_ROOT"'/dshs.db",{readonly:true});console.log(db.prepare("select username, uid from users order by rowid").all().map(u=>u.username+"="+u.uid).join(" "))' echo " --- 磁盘目录属主(与 uid 对照;多出的 5 个是已删用户孤儿目录) ---" -for d in /var/lib/dshs/users/*/; do printf " %-38s uid=%s\n" "$(basename "$d")" "$(stat -c %u "$d")"; done +for d in "$DSHS_DATA_ROOT"/users/*/; do printf " %-38s uid=%s\n" "$(basename "$d")" "$(stat -c %u "$d")"; done echo "=== 2) 建 47→106 专用密钥(仅用于 rsync) ===" [ -f "$KEY" ] || ssh-keygen -t ed25519 -N "" -C "dshs-rsync-47to106" -f "$KEY" >/dev/null 2>&1 echo " PUBKEY=$(cat "$KEY.pub")" echo "=== 3) 试连通 106:22 ===" -if ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 root@106.54.21.172 'echo ok' 2>/dev/null | grep -q ok; then +if ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 root@"$DSH_PEER_PUBLIC_IP" 'echo ok' 2>/dev/null | grep -q ok; then echo " ✓ 已可连通(公钥已装)" else echo " ⏳ 尚不可连通 —— 需先把我本机把这个公钥装到 106" diff --git a/scripts/switch-B2-dropin.sh b/scripts/switch-B2-dropin.sh index 3f3afc9..272a63e 100644 --- a/scripts/switch-B2-dropin.sh +++ b/scripts/switch-B2-dropin.sh @@ -3,18 +3,19 @@ # · 用 drop-in 而非改 unit:unit 本体 hash 不变 ⇒ 回滚只需删 drop-in # · 同时把 106 的 worker lib 也更新到同一版本(两侧代码必须一致) set -uo pipefail +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" mkdir -p /etc/systemd/system/dshs.service.d cat > /etc/systemd/system/dshs.service.d/cluster.conf <(既有用户)/ (新用户) # 回滚:删除本文件 → systemctl daemon-reload → systemctl restart dshs [Service] Environment="DSHS_DEPLOY_MODE=cluster" -Environment="DSHS_DB_URL=postgres://dshs:dshs_cluster_2026@127.0.0.1:15432/dshs" -Environment="DSHS_CLUSTER_HOST_ID=w-47" +Environment="DSHS_DB_URL=$DSHS_DB_URL" +Environment="DSHS_CLUSTER_HOST_ID=$DSHS_CLUSTER_HOST_ID" Environment="DSHS_CLUSTER_AGENT_URL=http://127.0.0.1:19100" -Environment="DSHS_CLUSTER_AGENT_TOKEN=dshs-worker-47-c4b7e19f" +Environment="DSHS_CLUSTER_AGENT_TOKEN=$DSHS_CLUSTER_AGENT_TOKEN" Environment="DSHS_CLUSTER_INSTANCE_HOST=127.0.0.1" -Environment="DSHS_CLUSTER_WORKER_DATA_ROOT=/var/lib/dshs" +Environment="DSHS_CLUSTER_WORKER_DATA_ROOT=$DSHS_DATA_ROOT" Environment="DSHS_CLUSTER_CAPACITY_MB=-1" Environment="DSHS_CLUSTER_REGISTER_SELF=0" Environment="DSHS_CLUSTER_LEASE_TTL_MS=30000" @@ -30,9 +31,9 @@ echo " dshs=$(systemctl is-active dshs) | dshs-pg=$(systemctl is-active dshs- echo " 生效 env(systemd 解析后):" systemctl show dshs -p Environment 2>/dev/null | tr ' ' '\n' | grep -E "DEPLOY_MODE|CLUSTER_HOST_ID|CLUSTER_AGENT_URL|DB_URL" | sed 's/^/ /' echo " 门户: login.html=$(curl -s -o /dev/null -w '%{http_code}' -m 8 http://127.0.0.1:3080/login.html)" -echo " 公网: https://alotbuy.com/login.html = $(curl -s -o /dev/null -w '%{http_code}' -m 12 https://alotbuy.com/login.html)" +echo " 公网: https://$DSHS_BASE_DOMAIN/login.html = $(curl -s -o /dev/null -w '%{http_code}' -m 12 https://$DSHS_BASE_DOMAIN/login.html)" echo " --- dshs cluster status ---" -cd /opt/dshs && set -a && . /etc/dshs.env && set +a && set -a && . /etc/systemd/system/dshs.service.d/cluster.conf 2>/dev/null || true -cd /opt/dshs && DSHS_DB_URL="postgres://dshs:dshs_cluster_2026@127.0.0.1:15432/dshs" node lib/cli.js cluster status 2>&1 | head -8 | sed 's/^/ /' +cd "$DSH_INSTALL_DIR" && set -a && . /etc/dshs.env && set +a && set -a && . /etc/systemd/system/dshs.service.d/cluster.conf 2>/dev/null || true +cd "$DSH_INSTALL_DIR" && node lib/cli.js cluster status 2>&1 | head -8 | sed 's/^/ /' echo " --- 回滚命令(随时可用) ---" echo " rm -f /etc/systemd/system/dshs.service.d/cluster.conf && systemctl daemon-reload && systemctl restart dshs" diff --git a/scripts/switch-B2-rsync.sh b/scripts/switch-B2-rsync.sh index c9c3b1b..8a91bee 100644 --- a/scripts/switch-B2-rsync.sh +++ b/scripts/switch-B2-rsync.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # B2 步(在 47 上跑):rsync 生产 dataRoot → 106 # · --numeric-ids 保 uid/gid(否则 106 上文件属主全错 ⇒ 实例 EACCES) # · 排除 dshs.db*(DB 权威源已是 47 的 PG)与 secret.key(凭据主密钥不外扩到 Worker —— R5 最小面) # · ⚠️ 所有 ssh 调用带 -n:脚本本身经 stdin 传入,ssh 若不隔离 stdin 会把**脚本剩余部分**吃掉 set -uo pipefail KEY=/root/.ssh/dshworker_ed25519 -DST=root@106.54.21.172 -SRC=/var/lib/dshs +DST=root@"$DSH_PEER_PUBLIC_IP" +SRC="$DSHS_DATA_ROOT" SSHOPT="-n -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10" command -v rsync >/dev/null || { dnf -y install rsync >/tmp/dnf-rsync.log 2>&1 && echo " ✓ 47 装上 rsync"; } @@ -15,19 +16,19 @@ echo "=== 连通性 + 对端 rsync ===" ssh $SSHOPT "$DST" 'command -v rsync >/dev/null || dnf -y install rsync >/tmp/dnf-rs.log 2>&1; echo " 对端: $(rsync --version | head -1)"' 2>&1 | tail -2 echo "=== 目标端准备 ===" -ssh $SSHOPT "$DST" 'mkdir -p /var/lib/dshs && ls -ld /var/lib/dshs' 2>&1 | sed 's/^/ /' +ssh $SSHOPT "$DST" "mkdir -p $DSHS_DATA_ROOT && ls -ld $DSHS_DATA_ROOT" 2>&1 | sed 's/^/ /' echo "=== rsync ===" rsync -a --numeric-ids --stats -e "ssh $SSHOPT" \ --exclude 'dshs.db' --exclude 'dshs.db-shm' --exclude 'dshs.db-wal' --exclude 'secret.key' \ - "$SRC/" "$DST:/var/lib/dshs/" 2>&1 | grep -E "Number of regular files transferred|Total file size|sent [0-9]|total size is" | sed 's/^/ /' + "$SRC/" "$DST:"$DSHS_DATA_ROOT"/" 2>&1 | grep -E "Number of regular files transferred|Total file size|sent [0-9]|total size is" | sed 's/^/ /' echo "=== 目标端核对 ===" ssh $SSHOPT "$DST" 'bash -c " -echo \" 顶层: \$(ls /var/lib/dshs | tr \"\n\" \" \")\" -echo \" users 目录数: \$(ls /var/lib/dshs/users 2>/dev/null | wc -l)\" -for d in /var/lib/dshs/users/*/; do printf \" %-38s uid=%s\n\" \"\$(basename \$d)\" \"\$(stat -c %u \$d)\"; done -echo \" bundled-skills: \$(ls /var/lib/dshs/bundled-skills 2>/dev/null | wc -l) 项\" -echo \" business-plugins: \$(ls /var/lib/dshs/business-plugins 2>/dev/null | wc -l) 项\" -echo \" ⛔ 不应存在(dshs.db/secret.key): \$(ls /var/lib/dshs/dshs.db /var/lib/dshs/secret.key 2>/dev/null | wc -l) 个(应为 0)\" +echo \" 顶层: \$(ls "$DSHS_DATA_ROOT" | tr \"\n\" \" \")\" +echo \" users 目录数: \$(ls "$DSHS_DATA_ROOT"/users 2>/dev/null | wc -l)\" +for d in "$DSHS_DATA_ROOT"/users/*/; do printf \" %-38s uid=%s\n\" \"\$(basename \$d)\" \"\$(stat -c %u \$d)\"; done +echo \" bundled-skills: \$(ls "$DSHS_DATA_ROOT"/bundled-skills 2>/dev/null | wc -l) 项\" +echo \" business-plugins: \$(ls "$DSHS_DATA_ROOT"/business-plugins 2>/dev/null | wc -l) 项\" +echo \" ⛔ 不应存在(dshs.db/secret.key): \$(ls "$DSHS_DATA_ROOT"/dshs.db "$DSHS_DATA_ROOT"/secret.key 2>/dev/null | wc -l) 个(应为 0)\" "' 2>&1 diff --git a/scripts/switch-B2b-push.sh b/scripts/switch-B2b-push.sh index f6932ec..10eb372 100644 --- a/scripts/switch-B2b-push.sh +++ b/scripts/switch-B2b-push.sh @@ -1,30 +1,31 @@ #!/usr/bin/env bash +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" # B2' 步:47 → 106 直推生产 dataRoot(tar-over-ssh;只用命令执行,绕开 rsync 协议问题) # · tar --numeric-owner 保 uid/gid(否则 106 上属主错 ⇒ 实例 EACCES) # · 排除 dshs.db*(权威源=47 的 PG)与 secret.key(主密钥不外扩 —— R5 最小面) set -uo pipefail KEY=/root/.ssh/dshworker_ed25519 -DST=root@106.54.21.172 +DST=root@"$DSH_PEER_PUBLIC_IP" SSHO="-i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" echo "=== 1) 目标端准备(ssh -n 防抢 stdin) ===" -ssh -n $SSHO "$DST" 'mkdir -p /var/lib/dshs && echo " ready: $(ls -ld /var/lib/dshs)"' +ssh -n $SSHO "$DST" "mkdir -p $DSHS_DATA_ROOT && echo ready: \$(ls -ld $DSHS_DATA_ROOT)" echo "=== 2) 推送(tar → ssh → tar --numeric-owner -x) ===" -cd /var/lib/dshs +cd "$DSHS_DATA_ROOT" tar --numeric-owner -cf - \ --exclude=./dshs.db --exclude=./dshs.db-shm --exclude=./dshs.db-wal --exclude=./secret.key \ - . | ssh $SSHO "$DST" 'tar -C /var/lib/dshs --numeric-owner -xf -' + . | ssh $SSHO "$DST" "tar -C $DSHS_DATA_ROOT --numeric-owner -xf -" rc=$? echo " 管道 rc=$rc" echo "=== 3) 目标端核对 ===" ssh -n $SSHO "$DST" 'bash -c " -echo \" 顶层: \$(ls /var/lib/dshs | tr \"\n\" \" \")\" -echo \" 总量: \$(du -sh /var/lib/dshs | cut -f1)\" -echo \" users 目录数: \$(ls /var/lib/dshs/users | wc -l)\" +echo \" 顶层: \$(ls "$DSHS_DATA_ROOT" | tr \"\n\" \" \")\" +echo \" 总量: \$(du -sh "$DSHS_DATA_ROOT" | cut -f1)\" +echo \" users 目录数: \$(ls "$DSHS_DATA_ROOT"/users | wc -l)\" echo \" --- 属主抽样(应与 47 的 uid 一致) ---\" -for d in /var/lib/dshs/users/*/; do printf \" %-38s uid=%s\n\" \"\$(basename \$d)\" \"\$(stat -c %u \$d)\"; done -echo \" bundled-skills=\$(ls /var/lib/dshs/bundled-skills | wc -l) business-plugins=\$(ls /var/lib/dshs/business-plugins | wc -l)\" -echo \" ⛔ 不该有 dshs.db/secret.key: \$(ls /var/lib/dshs/dshs.db /var/lib/dshs/secret.key 2>/dev/null | wc -l) 个(应 0)\" +for d in "$DSHS_DATA_ROOT"/users/*/; do printf \" %-38s uid=%s\n\" \"\$(basename \$d)\" \"\$(stat -c %u \$d)\"; done +echo \" bundled-skills=\$(ls "$DSHS_DATA_ROOT"/bundled-skills | wc -l) business-plugins=\$(ls "$DSHS_DATA_ROOT"/business-plugins | wc -l)\" +echo \" ⛔ 不该有 dshs.db/secret.key: \$(ls "$DSHS_DATA_ROOT"/dshs.db "$DSHS_DATA_ROOT"/secret.key 2>/dev/null | wc -l) 个(应 0)\" "' diff --git a/scripts/switch-C-final.sh b/scripts/switch-C-final.sh index 20b0a44..bf2ee80 100644 --- a/scripts/switch-C-final.sh +++ b/scripts/switch-C-final.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # 最终验证(在 47 上跑):清污染 → 重置锚点 → 重验两条路径 -# ① 既有用户 guest:留 w-47 + 工作区有历史数据 + 实例页正常 -# ② 新用户:落 w-106 + **文件真的写到 106 的盘** + 实例页正常 +# ① 既有用户 guest:留 + 工作区有历史数据 + 实例页正常 +# ② 新用户:落 + **文件真的写到 106 的盘** + 实例页正常 set -uo pipefail +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" export PGPASSWORD=dshs_cluster_2026 Q() { /usr/bin/psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -tAc "$1"; } M=http://127.0.0.1:3080 -DOMAIN=alotbuy.com -T47=dshs-worker-47-c4b7e19f -T106=dshs-worker-7f3a91c05e -SSH106="ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@106.54.21.172" +DOMAIN="${DSHS_BASE_DOMAIN:?config/platform.env 缺 DSHS_BASE_DOMAIN}" +T47="$DSHS_CLUSTER_AGENT_TOKEN" +T106="$DSH_PEER_AGENT_TOKEN" +SSH106="ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@"$DSH_PEER_PUBLIC_IP"" mksess() { local u="$1" T H @@ -25,15 +26,15 @@ isapp() { grep -q '/dev/null 2>&1; sleep 4 -echo " 重启后 w-47=$(agent 19100 $T47) w-106=$(agent 19000 $T106)" +echo " 重启后 "$DSHS_CLUSTER_HOST_ID"=$(agent 19100 $T47) "$DSH_PEER_HOST_ID"=$(agent 19000 $T106)" AT=$(mksess admin); AC="sid=$AT" for u in $(Q "select id from users where username like 'switchtest%' or username like 'swtest%'"); do echo " 删测试用户 $u → $(curl -s -m 20 -X DELETE -b "$AC" "$M/api/admin/users/$u" -o /dev/null -w '%{http_code}')" done echo " 剩余用户: $(Q "select string_agg(username,', ') from users")" -echo "########## 1) 重置 guest 锚点(host_id=w-47) ##########" -Q "update dsh_instances set host_id='w-47', epoch=0, lease_until=0, status='stopped' +echo "########## 1) 重置 guest 锚点(host_id="$DSHS_CLUSTER_HOST_ID") ##########" +Q "update dsh_instances set host_id='w-1', epoch=0, lease_until=0, status='stopped' where user_id=(select id from users where username='guest')" >/dev/null echo " $(owner guest)" @@ -42,8 +43,8 @@ echo "########## 2) 路径①:既有用户 guest ##########" GT=$(mksess guest); GC="sid=$GT" E=$(curl -s -m 60 -X POST -b "$GC" -H 'content-type: application/json' -d '{}' "$M/api/dsh/enter") sleep 3 -echo " 归属: $(owner guest) ← 期望 w-47" -echo " w-47=$(agent 19100 $T47) w-106=$(agent 19000 $T106)" +echo " 归属: $(owner guest) ← 期望 "$DSHS_CLUSTER_HOST_ID"" +echo " "$DSHS_CLUSTER_HOST_ID"=$(agent 19100 $T47) "$DSH_PEER_HOST_ID"=$(agent 19000 $T106)" echo " 工作区条目: $(curl -s -m 10 -b "$GC" "$M/api/desktop/tree" | grep -o '"name":"[^"]*"' | head -4 | tr '\n' ' ')" PAGE=$(curl -s -m 25 -L -b "$GC" -H "Host: guest.$DOMAIN" "$M/" | head -c 300) echo " 实例页: $(isapp "$PAGE")" @@ -56,16 +57,16 @@ NID=$(Q "select id from users where username='$NU'") echo " approve=$(curl -s -m 15 -X POST -b "$AC" "$M/api/admin/users/$NID/approve" -o /dev/null -w '%{http_code}')" NC=$(curl -s -m 15 -X POST -H 'content-type: application/json' -d "{\"username\":\"$NU\",\"password\":\"SwitchTest123\"}" -D - "$M/api/auth/login" -o /dev/null | grep -i '^set-cookie' | head -1 | grep -oP 'sid=[^;]+') echo " mkdir=$(curl -s -m 15 -X POST -b "$NC" -H 'content-type: application/json' -d '{"path":"proj"}' "$M/api/fs/mkdir" -o /dev/null -w '%{http_code}') ← 首次触达应把归属钉住" -echo " 钉住后归属: $(owner "$NU") ← 期望 w-106(与下面的 launch 必须同台)" +echo " 钉住后归属: $(owner "$NU") ← 期望 "$DSH_PEER_HOST_ID"(与下面的 launch 必须同台)" echo " upload=$(curl -s -m 20 -X POST -b "$NC" -H 'content-type: application/json' -d "{\"path\":\"proj\",\"name\":\"hello.txt\",\"data\":\"$(printf 'hi-from-switch' | base64 -w0)\"}" "$M/api/fs/upload" -o /dev/null -w '%{http_code}')" echo " launch=$(curl -s -m 60 -X POST -b "$NC" -H 'content-type: application/json' -d '{"folder":"proj"}' "$M/api/dsh/launch" -o /dev/null -w '%{http_code}')" sleep 4 -echo " 归属: $(owner "$NU") ← 期望 w-106(粘性保持)" -echo " w-106=$(agent 19000 $T106) w-47=$(agent 19100 $T47)" +echo " 归属: $(owner "$NU") ← 期望 "$DSH_PEER_HOST_ID"(粘性保持)" +echo " "$DSH_PEER_HOST_ID"=$(agent 19000 $T106) "$DSHS_CLUSTER_HOST_ID"=$(agent 19100 $T47)" echo " --- 落盘取证 ---" -L47=$($SSH106 "ls /var/lib/dshs/users/$NID/ws/proj/hello.txt 2>/dev/null" 2>/dev/null || true) +L47=$($SSH106 "ls "$DSHS_DATA_ROOT"/users/$NID/ws/proj/hello.txt 2>/dev/null" 2>/dev/null || true) echo " 106 盘: ${L47:-不存在}" -echo " 47 盘: $(ls /var/lib/dshs/users/$NID/ws/proj/hello.txt 2>/dev/null || echo 不存在(应不存在 ✓))" +echo " 47 盘: $(ls "$DSHS_DATA_ROOT"/users/$NID/ws/proj/hello.txt 2>/dev/null || echo 不存在(应不存在 ✓))" NP=$(curl -s -m 25 -L -b "$NC" -H "Host: $NU.$DOMAIN" "$M/" | head -c 300) echo " 实例页(Host: $NU.$DOMAIN): $(isapp "$NP")" diff --git a/scripts/switch-C-verify.sh b/scripts/switch-C-verify.sh index 8c7c751..e7e4e12 100644 --- a/scripts/switch-C-verify.sh +++ b/scripts/switch-C-verify.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash # 切换后功能验证 v2(在 47 上跑) -# ① 既有用户 guest:应留 w-47,且**工作区有历史数据**(文件在 47 的盘上) -# ② 新用户:应落 w-106,且**建的文件真的出现在 106 的盘上**(文件面路由已修) +# ① 既有用户 guest:应留 ,且**工作区有历史数据**(文件在 47 的盘上) +# ② 新用户:应落 ,且**建的文件真的出现在 106 的盘上**(文件面路由已修) # 判据:实例页必须带 '||coalesce(i.host_id,'NULL')||' epoch='|| agent() { curl -s -m 6 -H "x-dsh-agent-token: $2" "http://127.0.0.1:$1/healthz" | grep -o '"instances":[0-9]*'; } isapp() { grep -q '/dev/null | wc -l) 项(>0 = 数据在 47 ✓)" +echo " 47 盘上 guest 工作区条目: $(ls "$DSHS_DATA_ROOT"/users/4092b965-2f68-4977-9989-68b3966f7df0/ws 2>/dev/null | wc -l) 项(>0 = 数据在 47 ✓)" echo -echo "############ ② 新用户(应落 w-106 + 文件真的写到 106 盘) ############" +echo "############ ② 新用户(应落 "$DSH_PEER_HOST_ID" + 文件真的写到 106 盘) ############" NU="swtest$(date +%H%M%S)" AT=$(mksess admin); AC="sid=$AT" echo " register=$(curl -s -m 15 -X POST -H 'content-type: application/json' -d "{\"username\":\"$NU\",\"password\":\"SwitchTest123\"}" "$M/api/auth/register" -o /dev/null -w '%{http_code}')" @@ -46,11 +47,11 @@ echo " mkdir=$(curl -s -m 15 -X POST -b "$NC" -H 'content-type: application/jso echo " upload=$(curl -s -m 20 -X POST -b "$NC" -H 'content-type: application/json' -d "{\"path\":\"proj\",\"name\":\"hello.txt\",\"data\":\"$(printf 'hi-from-switch' | base64 -w0)\"}" "$M/api/fs/upload" -o /dev/null -w '%{http_code}')" echo " launch=$(curl -s -m 60 -X POST -b "$NC" -H 'content-type: application/json' -d '{"folder":"proj"}' "$M/api/dsh/launch" -o /dev/null -w '%{http_code}')" sleep 4 -echo " 归属: $(owner "$NU") ← 期望 w-106" -echo " w-106=$(agent 19000 $T106) w-47=$(agent 19100 $T47)" +echo " 归属: $(owner "$NU") ← 期望 "$DSH_PEER_HOST_ID"" +echo " "$DSH_PEER_HOST_ID"=$(agent 19000 $T106) "$DSHS_CLUSTER_HOST_ID"=$(agent 19100 $T47)" echo " --- 文件落盘取证(这才是文件面路由修好的证据) ---" -echo " 47 盘: $(ls /var/lib/dshs/users/$NU_ID/ws/proj/hello.txt 2>/dev/null && echo 存在 || echo '不存在 ✓(不应在 47)')" -echo " 106 盘: $(ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@106.54.21.172 "ls /var/lib/dshs/users/$NU_ID/ws/proj/hello.txt 2>/dev/null" 2>/dev/null && echo 存在✓ || echo '不存在 ✗')" +echo " 47 盘: $(ls "$DSHS_DATA_ROOT"/users/$NU_ID/ws/proj/hello.txt 2>/dev/null && echo 存在 || echo '不存在 ✓(不应在 47)')" +echo " 106 盘: $(ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@"$DSH_PEER_PUBLIC_IP" "ls "$DSHS_DATA_ROOT"/users/$NU_ID/ws/proj/hello.txt 2>/dev/null" 2>/dev/null && echo 存在✓ || echo '不存在 ✗')" NU_PAGE=$(curl -s -m 25 -L -b "$NC" -H "Host: $NU.$DOMAIN" "$M/" | head -c 300) echo " 实例页(Host: $NU.$DOMAIN): $(isapp "$NU_PAGE")" diff --git a/scripts/switch-C-worker.sh b/scripts/switch-C-worker.sh index d43fde8..ba79a3d 100644 --- a/scripts/switch-C-worker.sh +++ b/scripts/switch-C-worker.sh @@ -4,19 +4,21 @@ # · token 走 env 文件(600)而不是命令行,避免 ps 泄露 # · 反向隧道复用演练时那把 key(其公钥已在 47 的 authorized_keys 里,restrict,port-forwarding) set -uo pipefail -TOKEN="${WORKER_TOKEN:-dshs-worker-7f3a91c05e}" +. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh" +# 凭据 / 地址一律来自配置(config/platform.env)—— ⛔ 不在脚本里写死 +TOKEN="${DSH_PEER_AGENT_TOKEN:?config/platform.env 缺 DSH_PEER_AGENT_TOKEN}" cat > /etc/dshs-worker.env </dev/null 2>&1 +ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@"$DSH_PEER_PUBLIC_IP" 'systemctl restart dshs-worker' >/dev/null 2>&1 sleep 4 -echo " w-47=$(curl -s -m 6 -H 'x-dsh-agent-token: dshs-worker-47-c4b7e19f' http://127.0.0.1:19100/healthz | grep -o '\"instances\":[0-9]*')" -echo " w-106=$(ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@106.54.21.172 'curl -s -m 6 -H "x-dsh-agent-token: dshs-worker-7f3a91c05e" http://127.0.0.1:19000/healthz | grep -o .instances.:[0-9]*' 2>/dev/null)" +echo " "$DSHS_CLUSTER_HOST_ID"=$(curl -s -m 6 -H "x-dsh-agent-token: $DSHS_CLUSTER_AGENT_TOKEN" http://127.0.0.1:19100/healthz | grep -o '\"instances\":[0-9]*')" +echo " "$DSH_PEER_HOST_ID"=$(ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@"$DSH_PEER_PUBLIC_IP" "curl -s -m 6 -H 'x-dsh-agent-token: $DSH_PEER_AGENT_TOKEN' http://127.0.0.1:19000/healthz | grep -o .instances.:[0-9]*" 2>/dev/null)" echo "=== 3) 健康检查 ===" echo " dshs=$(systemctl is-active dshs) dshs-pg=$(systemctl is-active dshs-pg) dshs-worker=$(systemctl is-active dshs-worker)" -echo " 门户公网: $(curl -s -o /dev/null -w '%{http_code}' -m 12 https://alotbuy.com/login.html)" +echo " 门户公网: $(curl -s -o /dev/null -w '%{http_code}' -m 12 https://$DSHS_BASE_DOMAIN/login.html)" echo " --- dshs cluster status ---" -cd /opt/dshs && set -a && . /etc/dshs.env && set +a +cd "$DSH_INSTALL_DIR" && set -a && . /etc/dshs.env && set +a DSHS_DEPLOY_MODE=cluster DSHS_DB_URL="postgres://dshs:dshs_cluster_2026@127.0.0.1:15432/dshs" \ node lib/cli.js cluster status 2>&1 | head -9 | sed 's/^/ /' echo " --- dshs doctor ---" diff --git a/scripts/verify-cluster-cross.mjs b/scripts/verify-cluster-cross.mjs index 46045fd..0d768ab 100644 --- a/scripts/verify-cluster-cross.mjs +++ b/scripts/verify-cluster-cross.mjs @@ -1,10 +1,11 @@ +const cfg = require('../config/index.cjs') /** * T08 · **真跨机演练**驱动脚本(在 Manager 那台机器上运行)。 * * 与 `verify-cluster-live.mjs`(同机、脚本自己起进程)的区别:这里**假设两侧都已部署好**: * · Manager 运行在**本机**(47)`http://127.0.0.1:13080` - * · Worker agent 运行在**另一台机器**(106),经 **SSH 反向隧道**出现在本机 `127.0.0.1:19000` - * · 控制面 PG 也在**另一台机器**(106)上,经隧道出现在本机 `127.0.0.1:15432` + * · Worker agent 运行在**另一台机器**(106),经 **SSH 反向隧道**出现在本机 `127.0.0.1:` + * · 控制面 PG 也在**另一台机器**(106)上,经隧道出现在本机 `127.0.0.1:` * 它回答的是本次演练的核心问题:**跨机到底能不能用**(含跨机代理取页面、跨 worker 迁移)。 * * 运行(在 47 上):MANAGER=http://127.0.0.1:13080 AGENT_TOKEN=cross-machine-token \ @@ -81,7 +82,7 @@ try { // ── 0) 两侧可达性(跨机链路的第一层证据)───────────────────────────── const h1 = await agent(AGENT1, '/healthz') - assert(h1.status === 200 && h1.body.hostId === 'w-106', `Worker w-106 应可达(实际 ${JSON.stringify(h1.body)})`) + assert(h1.status === 200 && h1.body.hostId === 'w-2', `Worker w-2 应可达(实际 ${JSON.stringify(h1.body)})`) assert(h1.body.tunnel?.ready === true, `Worker 侧隧道应就绪(实际 ${JSON.stringify(h1.body.tunnel)})`) console.log('⓪ worker 可达 -> %s(隧道 ready,已转发 %s)', h1.body.hostId, JSON.stringify(h1.body.tunnel.ports)) @@ -93,13 +94,13 @@ try { let r = await json('/api/admin/hosts', { method: 'POST', cookie: adminCookie, - body: { id: 'w-106', endpoint: AGENT1, token: TOKEN, capacityMb: 4096 }, + body: { id: 'w-2', endpoint: AGENT1, token: TOKEN, capacityMb: 4096 }, }) - assert(r.status === 200, `注册 w-106 失败 ${r.status}`) + assert(r.status === 200, `注册 w-2 失败 ${r.status}`) const hosts = await json('/api/admin/hosts', { cookie: adminCookie }) - assert(hosts.body.hosts.some((h) => h.id === 'w-106'), 'w-106 出现在 worker 目录') + assert(hosts.body.hosts.some((h) => h.id === 'w-2'), 'w-2 出现在 worker 目录') assert(!('agentToken' in (hosts.body.hosts[0] ?? {})), '**绝不下发 agentToken**') - console.log('① 注册 -> w-106(列表不含 agentToken)') + console.log('① 注册 -> w-2(列表不含 agentToken)') // ── 2) 用户流程 ─────────────────────────────────────────────────────── const uname = `crossuser${Date.now() % 100000}` @@ -145,22 +146,22 @@ try { r = await json('/api/admin/hosts', { method: 'POST', cookie: adminCookie, - body: { id: 'w-106b', endpoint: AGENT2, token: TOKEN, capacityMb: 4096 }, + body: { id: 'w-2b', endpoint: AGENT2, token: TOKEN, capacityMb: 4096 }, }) - assert(r.status === 200, `注册 w-106b 失败 ${r.status}`) + assert(r.status === 200, `注册 w-2b 失败 ${r.status}`) console.log('⑥ 第二台 -> %s(模拟的第二台服务器)已注册', h2.body.hostId) r = await json(`/api/admin/users/${target.id}/dsh/migrate`, { method: 'POST', cookie: adminCookie, - body: { targetHost: 'w-106b' }, + body: { targetHost: 'w-2b' }, }) assert(r.status === 200, `迁移应 200(实际 ${r.status} ${JSON.stringify(r.body)})`) - assert(r.body.to === 'w-106b', `迁移目标应为 w-106b(实际 ${r.body.to})`) + assert(r.body.to === 'w-2b', `迁移目标应为 w-2b(实际 ${r.body.to})`) await waitRunning(cookie) const a1 = await agent(AGENT1, '/instances') const a2 = await agent(AGENT2, '/instances') - assert(a1.body.instances.length === 0 && a2.body.instances.length === 1, '实例应从 w-106 移到 w-106b') + assert(a1.body.instances.length === 0 && a2.body.instances.length === 1, '实例应从 w-2 移到 w-2b') console.log('⑦ 跨机迁移 -> %s → %s(epoch=%d),源机已空、目标机有 1 个实例', r.body.from, r.body.to, r.body.epoch) const enter2 = await json('/api/dsh/enter', { method: 'POST', cookie }) diff --git a/scripts/verify-cluster-domain.mjs b/scripts/verify-cluster-domain.mjs index c54090c..324dec7 100644 --- a/scripts/verify-cluster-domain.mjs +++ b/scripts/verify-cluster-domain.mjs @@ -1,8 +1,9 @@ +const cfg = require('../config/index.cjs') /** * T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。 * * 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后, - * **按域名形态访问**(`<用户名>.ai1net.com`)还能不能正常落到 106 上的实例? + * **按域名形态访问**(`<用户名>.`)还能不能正常落到 106 上的实例? * * 做法:给演练 Manager 设一个**测试 baseDomain**,用**显式 `Host` 头**打进去。 * @@ -10,15 +11,15 @@ * (Fetch 规范把它列为禁止头,undici 直接忽略)⇒ 请求落到"无租户"的门户路由、回 200 门户页, * 看起来"验证通过"其实是假的。⇒ **必须用 curl(`-H Host:`)**,且判据不能只看状态码。 * - * 运行(在 47 上):MANAGER=http://127.0.0.1:13080 BASE_DOMAIN=test.ai1net.com \ - * AGENT=http://127.0.0.1:19000 AGENT_TOKEN=cross-machine-token \ + * 运行(在 47 上):MANAGER=http://127.0.0.1:13080 BASE_DOMAIN=test. \ + * AGENT=http://127.0.0.1: AGENT_TOKEN=cross-machine-token \ * node scripts/verify-cluster-domain.mjs */ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' const MANAGER = process.env.MANAGER ?? 'http://127.0.0.1:13080' -const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'test.ai1net.com' +const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'test.example.net' const AGENT = process.env.AGENT ?? 'http://127.0.0.1:19000' const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token' const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123' diff --git a/scripts/verify-cluster-lease.mjs b/scripts/verify-cluster-lease.mjs index 2cd4281..d9c053f 100644 --- a/scripts/verify-cluster-lease.mjs +++ b/scripts/verify-cluster-lease.mjs @@ -11,7 +11,7 @@ * ⑤ 顺带验**注册 + 心跳**:`dsh_hosts` 里有记录且 `last_heartbeat` 持续更新。 * * 需要 PG(两个 Manager 必须共享 DB,否则谈不上"归属"): - * CLUSTER_TEST_PG_URL=postgres://dshs:pw@127.0.0.1:15432/dshs_smoke node scripts/verify-cluster-lease.mjs + * CLUSTER_TEST_PG_URL=postgres://dshs:pw@127.0.0.1:/dshs_smoke node scripts/verify-cluster-lease.mjs */ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/scripts/verify-cluster-live.mjs b/scripts/verify-cluster-live.mjs index a0b5060..13348d3 100644 --- a/scripts/verify-cluster-live.mjs +++ b/scripts/verify-cluster-live.mjs @@ -13,8 +13,8 @@ * ⑨ stop → ⑩ 起第二台 worker → 注册 → **迁移** → 再取一次页面 * ⑪ `dshs doctor` / `dshs cluster status`(观测面) * - * 需要:106 上 PG 已在 127.0.0.1:15432;以 root 运行(account 隔离要 setpriv/systemd-run)。 - * 运行:CLUSTER_LIVE_PG_URL=postgres://dshs:pw@127.0.0.1:15432/dshs_live node scripts/verify-cluster-live.mjs + * 需要:106 上 PG 已在 127.0.0.1:;以 root 运行(account 隔离要 setpriv/systemd-run)。 + * 运行:CLUSTER_LIVE_PG_URL=postgres://dshs:pw@127.0.0.1:/dshs_live node scripts/verify-cluster-live.mjs */ import { spawn, spawnSync } from 'node:child_process' import { createWriteStream, mkdirSync, readFileSync, rmSync } from 'node:fs' @@ -35,7 +35,7 @@ const here = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(here, '..') const CLI = join(repoRoot, 'lib', 'cli.js') const TOKEN = 'live-cluster-agent-token' -const DATA_ROOT = process.env.CLUSTER_LIVE_DATA_ROOT ?? '/opt/dshs-cluster/live-data' +const DATA_ROOT = process.env.CLUSTER_LIVE_DATA_ROOT ?? cfg.installDir() + '-cluster/live-data' const ISO = process.env.CLUSTER_LIVE_ISOLATION ?? 'account' const M_PORT = Number(process.env.CLUSTER_LIVE_MANAGER_PORT ?? 13080) const A1_PORT = Number(process.env.CLUSTER_LIVE_AGENT1_PORT ?? 19000) diff --git a/scripts/verify-cluster-migrate.mjs b/scripts/verify-cluster-migrate.mjs index 991161f..12334b9 100644 --- a/scripts/verify-cluster-migrate.mjs +++ b/scripts/verify-cluster-migrate.mjs @@ -9,7 +9,7 @@ * ⑤ **数据不搬家也能用**:两台 worker **共享同一 dataRoot**(模拟共享存储 / 同路径基线)。 * * 需要 PG(归属在 DB 里,两个 Manager/worker 共享): - * CLUSTER_TEST_PG_URL=postgres://dshs:pw@127.0.0.1:15432/dshs_smoke node scripts/verify-cluster-migrate.mjs + * CLUSTER_TEST_PG_URL=postgres://dshs:pw@127.0.0.1:/dshs_smoke node scripts/verify-cluster-migrate.mjs */ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index 3f58c65..2de1459 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * verify-platform-admin-section.mjs —— 「系统管理」分区的**无浏览器**渲染验收(档案 82) * @@ -34,7 +35,7 @@ const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" }; let def = null; const win = { __ModuleLoader__: { load: (d) => { def = d; } }, - location: { hostname: "admin.ai1net.com", protocol: "https:", origin: "https://admin.ai1net.com" }, + location: { hostname: "admin.example.net", protocol: "https:", origin: "https://admin.example.net" }, open: (u) => { globalThis.__OPENED = u; }, document: { createElement: () => { @@ -82,7 +83,7 @@ const sandbox = { Math, Date, encodeURIComponent, decodeURIComponent, window: win, document: win.document, fetch: async (url) => { - const p = String(url).replace("https://ai1net.com", ""); + const p = String(url).replace("https://example.net", ""); const body = p === "/api/auth/me" ? { user: { id: "u1", username: ROLE, role: ROLE } } : DATA[p]; return { ok: body !== undefined, status: body === undefined ? 404 : 200, json: async () => body }; }, diff --git a/scripts/ws-cleanup.cjs b/scripts/ws-cleanup.cjs index a42bf4c..395ae0c 100644 --- a/scripts/ws-cleanup.cjs +++ b/scripts/ws-cleanup.cjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +const cfg = require('../config/index.cjs') /** * ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28) * @@ -19,7 +20,7 @@ const { execFileSync } = require('node:child_process') const { existsSync, lchownSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync } = require('node:fs') const { join, extname, basename } = require('node:path') -const Database = require('/opt/dshs/node_modules/better-sqlite3') +const Database = require('better-sqlite3') const argv = process.argv.slice(2) const APPLY = argv.includes('--apply') @@ -73,7 +74,7 @@ function reclaimOwnership(root, uid) { return fixed } -const db = new Database('/var/lib/dshs/dshs.db', { readonly: true }) +const db = new Database(cfg.dbFile(), { readonly: true }) const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY) for (const u of users) { diff --git a/src/config.ts b/src/config.ts index 1720e9e..a795eb0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,8 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir, hostname } from 'node:os' import { join } from 'node:path' +import { installDir as installDirDefault, platformDir as platformDirDefault } from './platform-paths.js' + /** Isolation tier. `soft` = per-user home/workspace + sandbox (same OS user); * `account` = per-user OS account via a setuid wrapper (Linux, needs root). */ export type IsolationMode = 'soft' | 'account' @@ -32,6 +34,17 @@ export interface ServerConfig { dbUrl?: string /** Root under which per-user homes (`users//home`) and workspaces live. */ dataRoot: string + /** Parent of the platform-private dirs below. Deployment-varying ⇒ from config + * (`DSH_PLATFORM_DIR`), never a hardcoded absolute path. */ + platformDir: string + /** Platform state dir (managed lists, capabilities, runtime baseline). */ + stateDir: string + /** Platform backup dir (backups taken before the platform rewrites a user home file). */ + backupDir: string + /** Platform artifact dir (plugin / product tarballs served to instances). */ + artifactDir: string + /** Code install root (`lib/`、`scripts/` 所在);由本模块位置推导,无需配置。 */ + installDir: string /** Shared read-only skill directory for all users (injected as * `DSH_BUNDLED_SKILL_DIR` into every spawned DSH); empty = feature off. */ bundledSkillDir: string @@ -116,18 +129,18 @@ export interface ServerConfig { clusterWorkerDataRoot: string /** * **worker 侧**会合地址(覆盖网络 S1):worker 主动拨入的 SSH 反向隧道落点。 - * 形如 `ssh://root@47.77.182.89:32022`(也接受不带 scheme 的 `root@host:port`)。空 = 隧道关闭。 + * 形如 `ssh://root@:`(也接受不带 scheme 的 `root@host:port`)。空 = 隧道关闭。 * ⚠️ 与旧变量 `DSHS_TUNNEL_TARGET` **双路径并存**(新变量优先、旧变量兜底)⇒ * 删掉新 env 即回到旧路径,**零代码回滚**。 */ clusterRendezvousUrl: string /** - * **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。 + * **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss:///dshs-relay`。 * 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。 */ relayUrl: string /** - * 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:20080/status`。 + * 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:/status`。 * * 为什么必须查它:relay 为每个注册端口在**它自己的回环**上开一条监听,端口号是 * `listen(0)` 动态分配的(实测 42067)⇒ **Manager 无法从 `dsh_hosts.endpoint` 推出来**, @@ -258,6 +271,8 @@ export interface ConfigOverrides { dbPath?: string dbUrl?: string dataRoot?: string + platformDir?: string + installDir?: string bundledSkillDir?: string dshCommand?: string[] logLevel?: string @@ -394,10 +409,14 @@ const DEFAULT_EMAIL_GUARD = { } /** * 覆盖网络 P0-2:**内置种子**(引导链的常量位)。 - * 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。 + * + * ⛔ 这里**刻意留空** —— 种子是具体部署的入口地址,属部署相关值, + * 一律由 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 提供(见 `config/platform.env`)。 + * 代码内不留任何真实域名 / IP ⇒ 仓库副本换一个部署者也不会带出别人的地址。 + * 未配置 ⇒ 引导链为空,覆盖网络不自动取址(可显式 `--url` 指定)。 * ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。 */ -const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://ai1net.com/dshs-relay'] +const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS: string[] = [] /** Load the encryption secret from env, or persist a generated one at * `/secret.key` (0600) so it survives restarts without setup. */ @@ -459,7 +478,7 @@ function normalizeAction(value: string | undefined): string { /** * 主机名归一:去掉协议 / 路径 / 端口 / 首尾点,转小写并去重。 - * 为什么要容错:运维很容易把 env 写成 `https://ai1net.com/`(照抄 URL 的习惯), + * 为什么要容错:运维很容易把 env 写成 `https:///`(照抄 URL 的习惯), * 而 CF 回显的是**裸主机名** ⇒ 不归一就是"配了却永远不匹配"的静默失效。 */ export function normalizeHostnames(values: readonly string[]): string[] { @@ -539,11 +558,15 @@ function toDeployMode(value: string | undefined): DeployMode | undefined { /** * Fold argv/env overrides over defaults. `dataRoot` defaults to * `~/.dshs` (always writable for dev); production sets - * `DSHS_DATA_ROOT=/var/lib/dshs`. + * `DSHS_DATA_ROOT=`. */ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig { const dataRoot = overrides.dataRoot ?? process.env.DSHS_DATA_ROOT ?? join(homedir(), '.dshs') + // 部署相关的路径一律由 `platform-paths.ts` 统一解析(单一来源 ⇒ 见其模块头注)。 + // 代码内**不含任何真实路径**;缺省值是中性值(`<数据根>/platform`)。 + const platformDir = overrides.platformDir ?? platformDirDefault() + const installDir = overrides.installDir ?? installDirDefault() const port = overrides.port ?? process.env.DSHS_PORT ?? DEFAULT_PORT const dshBin = process.env.DSHS_DSH_BIN const isolationMode = @@ -562,6 +585,11 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig { dbPath: overrides.dbPath ?? join(dataRoot, 'dshs.db'), dbUrl: overrides.dbUrl ?? process.env.DSHS_DB_URL, dataRoot, + platformDir, + stateDir: join(platformDir, 'state'), + backupDir: join(platformDir, 'backups'), + artifactDir: join(platformDir, 'artifacts'), + installDir, bundledSkillDir: overrides.bundledSkillDir ?? process.env.DSHS_BUNDLED_SKILL_DIR ?? @@ -670,7 +698,7 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig { DEFAULT_INSTANCE_PORT_SPAN, ), // 覆盖网络 P0-2:引导三级链(env 显式 > 缓存目录 > 内置种子)。 - // 这一组**全部有安全默认**:不配任何东西 ⇒ 与今天行为一致(只认 env;种子地址就是现网地址)。 + // 内置种子**为空**(部署相关值不入代码)⇒ 不配 env 时引导链为空、不自动取址。 overlayNetworkId: (overrides.overlayNetworkId ?? process.env.DSHS_OVERLAY_NETWORK_ID ?? 'ops').trim() || 'ops', overlayBootstrapSeeds: overrides.overlayBootstrapSeeds ?? diff --git a/src/db/schema.ts b/src/db/schema.ts index 915e9a2..18386c1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -352,8 +352,8 @@ CREATE INDEX IF NOT EXISTS idx_dsh_instances_lease ON dsh_instances (lease_until // // 加列**带默认值** `manager-ssh` ⇒ **先加列 → 再改代码 → 最后回填**,任一步中断都不崩; // 旧代码只读 `endpoint`,完全不受影响(列可留着不删 ⇒ 零风险回滚)。 -// ⚠️ 默认值对 `w-106`(隧道落点)正确、对 `w-47`(同机直连)**不正确** ⇒ 回填由部署步骤 -// 显式 `UPDATE ... WHERE id='w-47'` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。 +// ⚠️ 默认值对 ``(隧道落点)正确、对 ``(同机直连)**不正确** ⇒ 回填由部署步骤 +// 显式 `UPDATE ... WHERE id=''` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。 const SQLITE_V8 = ` ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh'; ` diff --git a/src/db/types.ts b/src/db/types.ts index d433c44..4527aa2 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -412,20 +412,20 @@ export const DEFAULT_HOST_NETWORK = 'ops' /** 一台承载用户实例的 worker(= 设计里的 Worker 节点)。 */ export interface DshHost { id: string - /** agent 的内网地址,如 `10.0.1.11:9000`。 */ + /** agent 的内网地址,如 `:9000`。 */ endpoint: string /** * **经谁可达**(覆盖网络 S2):`Reachability.via` 的词表值,指向一个 `Rendezvous` 实现。 * * ⚠️ 两条现网记录的 `endpoint` 字符串同形、语义不同 ⇒ **不能靠 endpoint 猜**: - * · `w-47` = `127.0.0.1:19100` = **同机直连**(Manager 与 worker 同机)⇒ `local` - * · `w-106` = `127.0.0.1:19000` = **Manager 主机上 sshd 的反向隧道落点** ⇒ `manager-ssh` + * · `` = `127.0.0.1:` = **同机直连**(Manager 与 worker 同机)⇒ `local` + * · `` = `127.0.0.1:` = **Manager 主机上 sshd 的反向隧道落点** ⇒ `manager-ssh` */ via: string /** * **属于哪张网**(覆盖网络 P0-1,`dsh_hosts.network_id`)。 * - * `ops` = 运维网(平台自己的机器:`manager` / `w-47` / `w-106`,以及未来的中继与骨干); + * `ops` = 运维网(平台自己的机器:`manager` / `` / ``,以及未来的中继与骨干); * `u:` = 该用户名下的全部设备。取值与 `src/net/relay/network.ts` 同一词表。 * * 为什么它是**结构性**维度而不是又一个标签:relay 侧按 `/` 建会话、且 diff --git a/src/fs/remote-user-fs.ts b/src/fs/remote-user-fs.ts index 4ef0dd7..c744f3b 100644 --- a/src/fs/remote-user-fs.ts +++ b/src/fs/remote-user-fs.ts @@ -84,7 +84,7 @@ export class RemoteUserFs implements UserFs { * ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}`,与"**文件夹不存在**"**完全同形** * (用户读成"我的文件丢了",而真因是"请求根本没出这台机"); * ② 若本地恰好有同名目录 ⇒ 直接把文件写进**一份没人在看的副本**(更糟的静默写坏)。 - * 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充,w-106 用户点启动 + * 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充, 用户点启动 * 连发 3 次 **全 404,且 relay 零 `DIAL`、拨号池零落点** ⇒ 请求根本没出去。 * **判别器 = 看 relay 有没有 `DIAL`**(本机单测打在 `fetch` 上,断言"没打默认机")。 * diff --git a/src/net/reachability.ts b/src/net/reachability.ts index 430d4a9..d27d86a 100644 --- a/src/net/reachability.ts +++ b/src/net/reachability.ts @@ -8,8 +8,8 @@ * * | hostId | endpoint | 真实语义 | * |---|---|---| - * | `w-47` | `http://127.0.0.1:19100` | **直连本机**(Manager 与 worker 同机,node 直接监听) | - * | `w-106` | `http://127.0.0.1:19000` | **经 47 上 sshd 的反向隧道落点**(隧道的副作用) | + * | `` | `http://127.0.0.1:` | **直连本机**(Manager 与 worker 同机,node 直接监听) | + * | `` | `http://127.0.0.1:` | **经 47 上 sshd 的反向隧道落点**(隧道的副作用) | * * ⇒ 换会合 / 中继组件时,表里**无法表达**「via(经哪个中继)+ 真实可达地址」, * 只能改表;越晚改代价越大(会合中继拆分方案 §2 C3)。 @@ -17,7 +17,7 @@ * `Reachability` 把这件事显式化:`via` 指向一个 `Rendezvous` 实现,`address` 是真实地址。 * * ## P0-3:为什么要带 `networkId` - * 地址是**网内**的:`127.0.0.1:19000` 在 `ops` 里指向 `w-106` 的 relay 落点,在 `u:5` 里 + * 地址是**网内**的:`127.0.0.1:` 在 `ops` 里指向 `` 的 relay 落点,在 `u:5` 里 * 可能指另一台。只带 `hostId` 的重达性描述**在多网下是有歧义的**,而歧义会以 * "打到另一张网的同名节点"这种最贵的形态暴露(静默串网)。⇒ 本类型**必带**网络维度, * `parseReachability()` 从**逻辑名**(`/`)里取它,调用方不许自己拼。 @@ -117,7 +117,7 @@ export function agentBaseUrlOf(host: HostAddressable): string { * `/`:网络段由**本函数**切出来(`parseLogicalName`),调用方不碰。 * ⚠️ **裸 `hostId` 仍兼容**(⇒ 落 `ops`):过渡期不破坏现网调用方与既有单测。 * - * 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸 + * 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:`),也容忍裸 * `host:port` —— 没写 scheme 时按 `http` 处理,与 `RemoteSpawner` 原先"直接把它当 * fetch 基址"的行为一致(fetch 会补 `http://`)。 */ @@ -151,7 +151,7 @@ export function toEndpoint(reach: Reachability): string { * * 为什么需要:relay 为每个注册端口在**它自己的回环**上开一条监听,回环口号由 `listen(0)` * 动态分配 ⇒ Manager 拿不到、也推不出,只能拿「要拨的端口号」去 relay 的 `/status` 里查。 - * 而那个号码就住在 `dsh_hosts.endpoint` 里(`http://127.0.0.1:19000`)⇒ 统一在这里剥出来, + * 而那个号码就住在 `dsh_hosts.endpoint` 里(`http://127.0.0.1:`)⇒ 统一在这里剥出来, * 别在调用方各写一遍 `split(':')`(IPv6 字面量会切错)。 */ export function addressPort(address: string): number | undefined { diff --git a/src/net/relay/addr-override.ts b/src/net/relay/addr-override.ts index 27056a0..5640d71 100644 --- a/src/net/relay/addr-override.ts +++ b/src/net/relay/addr-override.ts @@ -2,7 +2,7 @@ * 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。 * * ## 它解决的唯一问题 - * 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**, + * 兜底入口 `relay-direct.` 与主入口 `` **同属 `*.{base-domain}`**, * 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于 * "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果 * **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。 @@ -23,7 +23,7 @@ * * ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5) * ``` - * DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.ai1net.com=47.77.182.89 + * DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.= * ``` * 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。 * diff --git a/src/net/relay/client.ts b/src/net/relay/client.ts index dc8c92a..aa26b6b 100644 --- a/src/net/relay/client.ts +++ b/src/net/relay/client.ts @@ -71,7 +71,7 @@ export type WebSocketCtor = new (url: string) => WebSocketLike export type RelayClientState = 'idle' | 'connecting' | 'handshaking' | 'up' | 'backoff' | 'queued' | 'stopped' export interface RelayClientOptions { - /** relay 的 WebSocket 地址:`ws://127.0.0.1:20080/dshs-relay`(R1)或 `wss://<域名>/dshs-relay`(R2)。 */ + /** relay 的 WebSocket 地址:`ws://127.0.0.1:/dshs-relay`(R1)或 `wss://<域名>/dshs-relay`(R2)。 */ url: string hostId: string /** diff --git a/src/net/relay/dialer.ts b/src/net/relay/dialer.ts index 1e9cc54..5d669f0 100644 --- a/src/net/relay/dialer.ts +++ b/src/net/relay/dialer.ts @@ -239,7 +239,7 @@ export class RelayDialer { /** * ⛔ `DIAL.target` 是**裸 hostId**,不含网络段(服务端会显式拼上**拨号方自己**那张网, * 见 `server.ts#onDial`)。键从 P0-3 起是逻辑名 ⇒ 这里**必须**剥掉网络段再发。 - * 漏剥的表现极具误导性:服务端按 `logicalName('ops', 'ops/w-106')` = `ops/ops/w-106` 找会话, + * 漏剥的表现极具误导性:服务端按 `logicalName('ops', 'ops/')` = `ops/ops/` 找会话, * 找不到 ⇒ 回 `target-offline`("节点离线"),而节点其实**好好在册** —— * 实测踩过一次(2026-09-17 07:32 线上,`refused: 8 / streamsOpened: 0`)。 */ diff --git a/src/net/relay/directory.ts b/src/net/relay/directory.ts index d46bdac..f3ba7cc 100644 --- a/src/net/relay/directory.ts +++ b/src/net/relay/directory.ts @@ -74,16 +74,21 @@ const MAX_ENTRY_LEN = 512 const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') /** - * **内置种子的字面量**(引导链的常量位)。已持证书的门户域名、**不新增域名成本**。 - * ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量, + * **内置种子的常量位**。 + * ⛔ 刻意留空 —— 种子是**具体部署的入口地址**,属部署相关值, + * 一律由 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 提供(见 `config/platform.env`)。 + * 代码内不留真实域名 / IP;未配置 ⇒ 引导链为空、不自动取址。 + * ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同语义常量, * **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。 */ -export const DEFAULT_OVERLAY_SEED = 'https://ai1net.com/dshs-relay' +export const DEFAULT_OVERLAY_SEED = '' -/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */ +/** 从环境变量取种子;**没配** ⇒ 用内置常量位(空 ⇒ 返回空列表)。 */ export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] { const raw = env.DSHS_OVERLAY_BOOTSTRAP_SEEDS - if (raw === undefined) return [DEFAULT_OVERLAY_SEED] + if (raw === undefined || raw.trim() === '') { + return DEFAULT_OVERLAY_SEED === '' ? [] : [DEFAULT_OVERLAY_SEED] + } return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))] } @@ -321,7 +326,7 @@ export function buildDirectoryDocument(input: { /** * 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。 * - * 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://ai1net.com/dshs-relay`, + * 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https:///dshs-relay`, * 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。 * 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。 */ @@ -462,7 +467,7 @@ function entryIdentity(u: URL): string { /** * 选出**可以对外公布**的中继 / 引导地址。 * - * ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:20080` 对客户端毫无用处, + * ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:` 对客户端毫无用处, * 还白送一份内网拓扑。对齐红线「**权限只准收窄**」(这里收窄的是**暴露面**)。 * 同一语义身份只保留**首次出现**的那条(⇒ `wss://` 写法优先于同源的 `https://` 写法)。 */ diff --git a/src/net/relay/keys.ts b/src/net/relay/keys.ts index 246cec4..86163b6 100644 --- a/src/net/relay/keys.ts +++ b/src/net/relay/keys.ts @@ -22,14 +22,14 @@ * ## 格式(**向后兼容,旧配置一字不改照旧可用**) * ```json * { - * "w-47": "fc6c…7d01", // 旧写法:裸 hostId ⇒ 运维网 ops + * "": "fc6c…7d01", // 旧写法:裸 hostId ⇒ 运维网 ops * "manager": { "secret": "515d…3b6ea" }, // 新写法:显式给出 secret * "u:5/pc-1": "aa11…77aa", // 带网:与 u:9/pc-1 互不干扰(网络取自**键**) * "u:9/pc-1": { "secret": "bb22…88bb" } * } * ``` * 内联形式(便于 env 传入,**不建议**用于生产,因为 env 会进 `ps`/journald): - * `w-47:<64hex>,ops/manager:<64hex>,u:5/pc-1:<64hex>` + * `:<64hex>,ops/manager:<64hex>,u:5/pc-1:<64hex>` * * ⚠️ 名字段一律走 `network.ts#parseLogicalName`(**唯一入口**)—— 它同时接受 * `网/hostId`、`网:hostId` 与旧形态裸 `hostId`,且**网络 id 非法就抛**。 diff --git a/src/net/relay/main.ts b/src/net/relay/main.ts index 5775959..07f46e7 100644 --- a/src/net/relay/main.ts +++ b/src/net/relay/main.ts @@ -6,8 +6,8 @@ * node lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 * * # 客户端(worker 侧拨出;R1 用 `ssh -L` 把远端的回环口引到本机来拨) - * node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay \ - * --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20000 + * node lib/net/relay/main.js --client --url ws://127.0.0.1:/dshs-relay \ + * --host --keys-file /etc/dshs/relay-keys.json --ports 20000 * ``` * * ⚠️ **本文件暂不读 `src/config.ts`**:R1 阶段 relay 是**独立可选单元**,且 `src/config.ts` @@ -93,7 +93,7 @@ function parseArgs(argv: readonly string[]): Args { 'usage: main.js [--client --url --host --ports ] [--network ] [--port n] [--keys-file p] [--base n] [--span n] [--max-hosts n]\n' + ' 容量准入:--max-hosts(0 = 不限);满载时新节点收到 at-capacity + retryAfterMs 并**排队等待**,已在册节点重连优先。\n' + ' 网维度(P0-1):--network 缺省 ops;拨号方白名单 DSHS_RELAY_DIALERS 接受 "ops:manager" / "manager"(旧写法)两种。\n' + - ` 引导(P0-2):客户端**不带 --url** 时按「缓存目录 > 内置种子」取址;内置种子 = ${DEFAULT_OVERLAY_SEED}\n` + + ` 引导(P0-2):客户端**不带 --url** 时按「缓存目录 > 内置种子」取址;内置种子 = ${DEFAULT_OVERLAY_SEED === '' ? '(未配置 ⇒ 引导链为空)' : DEFAULT_OVERLAY_SEED}\n` + ' (env 显式 = --url / DSHS_RELAY_URL,**压制引导链**;受信目录公钥 = DSHS_OVERLAY_DIR_PUBKEYS)。\n', ) process.exit(0) diff --git a/src/net/relay/network.ts b/src/net/relay/network.ts index 04a4b7e..7a2a568 100644 --- a/src/net/relay/network.ts +++ b/src/net/relay/network.ts @@ -36,7 +36,7 @@ export const OPS_NETWORK = 'ops' const TENANT_NET_RE = /^u:[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ const GENERIC_NET_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/ -/** hostId 的合法形状(`w-47` / `w-106` / `manager` …)。 */ +/** hostId 的合法形状(`` / `` / `manager` …)。 */ const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ /** 逻辑名的**规范分隔符**。`/` —— `network_id` 不含 `/`,故**首个** `/` 即分隔点。 */ @@ -123,7 +123,7 @@ export function logicalName(network: string, hostId: string): string { * 反解一个逻辑名 / 配置项。 * * ## 三条分隔规则(顺序即优先级) - * 1. 含 `/` ⇒ 按**首个** `/` 切(`ops/manager` · `u:5/w-106`)—— 规范形态; + * 1. 含 `/` ⇒ 按**首个** `/` 切(`ops/manager` · `u:5/`)—— 规范形态; * 2. 否则含 `:` ⇒ 按**最后一个** `:` 切(`ops:manager` · `u:5:manager`)—— * 为的是兼容运维习惯的 `network:hostId` 写法;⚠️ 必须从**右**切:`u:5` 里的 `:` 属于网络 id; * 3. 都不含 ⇒ **旧形态**(R5 时代的扁平 `hostId`)⇒ 落在 `ops`,**旧配置照旧可用**。 diff --git a/src/net/relay/registry.ts b/src/net/relay/registry.ts index da1c573..8a4a43c 100644 --- a/src/net/relay/registry.ts +++ b/src/net/relay/registry.ts @@ -28,6 +28,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { dirname } from 'node:path' import { OPS_NETWORK, assertNetworkId, isHostId, logicalName, networkKindOf } from './network.js' +import { installDir } from '../../platform-paths.js' import { verifySignedPayload, type IdentityReason } from './identity.js' // ── 邀请(准入)凭据 ───────────────────────────────────────────────────────── @@ -477,7 +478,9 @@ export function deriveDropIn( const hosts = [...(deriveDialers(reg, [network]).get(network) ?? new Set())].sort() // 逻辑名形态(`<网>/`)—— `normalizeDialers` 的**规范形态**;⛔ 不用 `网:hostId` 旧式写法。 const entries = hosts.map((h) => logicalName(network, h)) - const libDir = opts.libDir ?? '/opt/dsh-relay' + // relay 代码安装根:**部署相关 ⇒ 不写死**(`DSH_RELAY_LIB_DIR` 可显式指定, + // 缺省取本进程的安装根 —— relay 进程跑在自己的安装目录下,即为正确值)。 + const libDir = opts.libDir ?? process.env.DSH_RELAY_LIB_DIR ?? installDir() const unit = opts.unit ?? 'dshs-relay' return [ `# 由控制面**派生**(${unit} · network=${network})—— 源 = 网注册表里该网的 approved 集合`, diff --git a/src/net/relay/rendezvous.ts b/src/net/relay/rendezvous.ts index a389ac0..beec598 100644 --- a/src/net/relay/rendezvous.ts +++ b/src/net/relay/rendezvous.ts @@ -4,8 +4,8 @@ * ## 与其他两个实现的关系(同一 `Rendezvous` 接口,可共存、可逐个切换) * | 实现 | `via` | Manager 侧看到的地址 | 数据面 | * |---|---|---|---| - * | `LocalRendezvous` | `local` | `127.0.0.1:19100`(同机直连) | 无中转 | - * | `ManagerSshRendezvous` | `manager-ssh` | `127.0.0.1:19000`(**sshd 反向隧道落点**) | Manager 主机上的 sshd | + * | `LocalRendezvous` | `local` | `127.0.0.1:`(同机直连) | 无中转 | + * | `ManagerSshRendezvous` | `manager-ssh` | `127.0.0.1:`(**sshd 反向隧道落点**) | Manager 主机上的 sshd | * | **`RelayRendezvous`** | `relay` | `127.0.0.1:`(**relay 开了回环监听**) | relay 的一条出向 wss | * * 三者对 Manager 侧**同形**(都是 `host:port`)⇒ 换实现不动调用方,这是 S0 抽 `Reachability` @@ -24,7 +24,7 @@ import type { AddressLookup, Rendezvous } from '../rendezvous.js' import { parseLogicalName } from './network.js' export interface RelayRendezvousOptions { - /** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.ai1net.com/dshs-relay`。 */ + /** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh./dshs-relay`。 */ dialTargetUrl: string /** * **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。 diff --git a/src/net/relay/server.ts b/src/net/relay/server.ts index 395e40c..e769460 100644 --- a/src/net/relay/server.ts +++ b/src/net/relay/server.ts @@ -25,7 +25,7 @@ * `HELLO` 的 MAC 输入刻意保持 `${hostId}|${ts}|${nonce}|${portsCsv}` **一字不改**:现网 47 / 106 * 上跑的是旧客户端,改 MAC 公式 = 硬断(必须两端同时升级)。而网络维度的**真判据在服务端** * (白名单按网络分桶 + 同网校验),`network` 只是"我属于哪张网"的声明 —— 声明错了也不会多拿到 - * 任何东西:想拨 `ops/w-106` 就得有一个**在 `ops` 桶里的 hostId 密钥**。⇒ 安全性不依赖这个字段, + * 任何东西:想拨 `ops/` 就得有一个**在 `ops` 桶里的 hostId 密钥**。⇒ 安全性不依赖这个字段, * 而兼容性(旧客户端不声明 `network` ⇒ 按 `ops` 处理)正好是**存量全部落在运维网**的现网事实。 * * ## 稳定性(本轮重点) @@ -520,7 +520,7 @@ export interface RelayStatus { * 序⑤(观测最小集):`DIAL` 的**判别器计数**。 * * 为什么需要它:443 单 §12 留下的教训原文是「**静默失效靠判别器定位**」,判别器就是 - * 「relay 到底有没有 `DIAL`」—— 今天它**只存在于日志行**(`DIAL manager -> w-106:21000 ok`), + * 「relay 到底有没有 `DIAL`」—— 今天它**只存在于日志行**(`DIAL manager -> :21000 ok`), * 脚本无法断言 ⇒ 观测最小集缺了最关键的一条。 * * 为什么不复用 `refused`:`refused` 是**所有**拒绝的合计(`HELLO` 越界、端口越界、`DIAL`…), diff --git a/src/net/relay/switcher.ts b/src/net/relay/switcher.ts index c12f30d..29c765f 100644 --- a/src/net/relay/switcher.ts +++ b/src/net/relay/switcher.ts @@ -329,7 +329,7 @@ export class RelayFailoverSupervisor { * * 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与 * **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。 - * 首轮真机实测(11:43:26):`wss://106… -> wss://ai1net.com…` —— 而 `ai1net.com` 十几分钟前 + * 首轮真机实测(11:43:26):`wss://106… -> wss://…` —— 而 `` 十几分钟前 * **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来 * ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。 * ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。 @@ -353,7 +353,7 @@ export class RelayFailoverSupervisor { /** * 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖): * - * 生产目录的 `relays[]` = `[ai1net.com(47), relay-direct.ai1net.com(47), 106]` + * 生产目录的 `relays[]` = `[(47), relay-direct.(47), 106]` * ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选", * 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒ * 链虽然"不再退化成单点",却依然**换不过去**。 diff --git a/src/net/rendezvous.ts b/src/net/rendezvous.ts index b1ef542..6e6dd81 100644 --- a/src/net/rendezvous.ts +++ b/src/net/rendezvous.ts @@ -13,7 +13,7 @@ * * ## 现状与目标 * 今天"会合 + 中继"**不是一个组件,而是 Manager 主机上 sshd 的副作用**: - * 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。 + * 会合点 = `:`,中继落点 = Manager 的 `127.0.0.1`。 * 本模块的作用是**先把接口抽出来**,让 SSH 隧道退化成"第一个可替换实现" * —— ⛔ 这一步**不换协议**,只换绑定与寻址(换 WireGuard / TURN 属远期)。 * @@ -48,7 +48,7 @@ export interface Rendezvous { */ export type AddressLookup = (name: string) => string | undefined -/** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`w-47` 就是这一类)。 */ +/** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`` 就是这一类)。 */ export class LocalRendezvous implements Rendezvous { readonly id = VIA_LOCAL @@ -81,7 +81,7 @@ export class ManagerSshRendezvous implements Rendezvous { constructor( private readonly opts: { - /** 会合点的 SSH 目标,如 `root@47.77.182.89:32022`。 */ + /** 会合点的 SSH 目标,如 `root@:`。 */ target: string addressOf: AddressLookup }, diff --git a/src/platform-paths.ts b/src/platform-paths.ts new file mode 100644 index 0000000..0242d6f --- /dev/null +++ b/src/platform-paths.ts @@ -0,0 +1,64 @@ +/** + * **部署相关路径的唯一解析处**。 + * + * ## 为什么单独成模块 + * 这些路径原先各自**硬编码在 5 个文件里**(`/state`、`/backups`、 + * `/scripts`、`/overlay` …)⇒ 仓库副本换一个部署者就会**带出别人的 + * 目录结构与主机信息**。集中到这里后:代码内**不含任何真实路径**,一律从配置读取 + * (见 `config/platform.env` 与 `config/README.md`)。 + * + * ## 两条纪律 + * ① **⛔ 不要在别处重算这套路径** —— 同一事实只有一处(R11)。要新路径就加在这里。 + * ② **本模块必须零副作用** —— 不建目录、不写文件、不抛错。`resolveConfig()` 会 + * `mkdir` 数据根并可能生成 `secret.key`,所以**不能**在这里调它(会被静态资源 + * 或只读路径调用)。这里只做 `process.env` + 中性默认值的纯计算。 + * + * @module dshs/platform-paths + */ + +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** 数据根(每用户 home/ws、平台库)。部署时用 `DSHS_DATA_ROOT` 指定。 */ +export function dataRootDir(): string { + return process.env.DSHS_DATA_ROOT ?? join(homedir(), '.dshs') +} + +/** 平台私有目录的父目录(其下 `state` / `backups` / `artifacts`)。 + * 缺省取 `<数据根>/platform` —— **中性默认**,不含任何真实部署路径。 */ +export function platformDir(): string { + return process.env.DSH_PLATFORM_DIR ?? join(dataRootDir(), 'platform') +} + +/** 平台状态目录(托管清单、能力清单、运行时基线)。 */ +export function stateDir(): string { + return process.env.DSH_PLATFORM_STATE_DIR ?? join(platformDir(), 'state') +} + +/** 平台备份目录(改写用户 home 文件前的平台侧备份)。 */ +export function backupDir(): string { + return process.env.DSH_PLATFORM_BACKUP_DIR ?? join(platformDir(), 'backups') +} + +/** 平台产物目录(插件 / 产物 tgz)。 */ +export function artifactDir(): string { + return process.env.DSH_PLATFORM_ARTIFACT_DIR ?? join(platformDir(), 'artifacts') +} + +/** 代码安装根(`lib/`、`scripts/` 所在)。 + * 默认从本模块位置推导:`/lib/platform-paths.js` ⇒ ``。 */ +export function installDir(): string { + const fromEnv = process.env.DSH_INSTALL_DIR + if (fromEnv !== undefined && fromEnv.trim() !== '') return fromEnv.trim() + try { + return dirname(dirname(fileURLToPath(import.meta.url))) + } catch { + return process.cwd() + } +} + +/** 代码根下的脚本路径(如 `installDir()/scripts/ensure-biz-plugins.cjs`)。 */ +export function scriptPath(...parts: string[]): string { + return join(installDir(), 'scripts', ...parts) +} diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts index db2c629..411d582 100644 --- a/src/supervisor/orchestrator.ts +++ b/src/supervisor/orchestrator.ts @@ -351,7 +351,7 @@ export class LocalSpawner implements Spawner { * 而 `listUserInstances()` 的口径**就是 `mains`** ⇒ 上一进程遗留的实例监听端口**没有任何人** * 会向 relay 重新声明一遍。实测症状(2026-09-19):106 的实例 `:21001` 进程健在、 * `[rehydrate] probe OK` 也打了,但两台中继的端点表里都没有它(47 侧只留一条 - * `w-106:19000 online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。 + * `: online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。 * * ⇒ 由 **worker agent** 接这个回调,把 `adoptedInstancePorts()` 并进对账口径(⛔ 不改认领语义、 * ⛔ 不把认领实例写进 `mains`):端口一落定就登记,**不必等 20 s 对账节拍**。 diff --git a/src/supervisor/remote-spawner.ts b/src/supervisor/remote-spawner.ts index 254c549..f04be13 100644 --- a/src/supervisor/remote-spawner.ts +++ b/src/supervisor/remote-spawner.ts @@ -29,7 +29,7 @@ import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js' * (唯一入口,别在调用点自己拼字符串): * · `reachability` = S0 引入的**可达性描述**,比 `agentUrl` 多一层语义 —— * **经谁中转**(`via`)。现网两类 host 的 `endpoint` 字符串同形但语义完全不同 - * (`w-47` 是直连本机、`w-106` 是 Manager 上的隧道落点),只有它能表达。 + * (`` 是直连本机、`` 是 Manager 上的隧道落点),只有它能表达。 * · `agentUrl` = 旧字段,保留向后兼容。 */ export interface ClusterHost { diff --git a/src/web/email-code.ts b/src/web/email-code.ts index 4d3e804..dcc42d5 100644 --- a/src/web/email-code.ts +++ b/src/web/email-code.ts @@ -53,7 +53,7 @@ export function isValidUsername(username: string): boolean { return USERNAME_RE.test(username) } -/** 邮件里的站点名:取主域名标签大写(`ai1net.com` → `AI1NET`)。空 ⇒ 不写站点名。 */ +/** 邮件里的站点名:取主域名标签大写(`` → `EXAMPLE`)。空 ⇒ 不写站点名。 */ export function mailBrandFromConfig(config: ServerConfig): string { const domain = (config.baseDomain ?? '').trim() if (domain === '') return '' diff --git a/src/web/home-files.ts b/src/web/home-files.ts index eb57bb7..fd94370 100644 --- a/src/web/home-files.ts +++ b/src/web/home-files.ts @@ -6,7 +6,7 @@ * (两份实现迟早漂),不如抽出来共用(R11:同一事实只有一处)。 * * ⚠️ **`writeHomeFile` 里那两步都不能省**(都是从事故里换来的): - * ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `/opt/dsh/backups`) + * ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `/backups`) * —— ⛔ 不能备份进用户 home:那是 dsh 的 watch 域,放进去的文件会被扫; * ② **写完 chown 给 home 属主** —— 实例以 `dsh-` 身份运行,root 写的 0600 文件它**读不了** * ⇒ 漏掉这步就是"配置写了但实例死活读不到"(档案 43 / R10 同族)。 @@ -18,6 +18,8 @@ import { basename, dirname, join } from 'node:path' import { writeFileSync } from 'node:fs' import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { backupDir } from '../platform-paths.js' + /** 读文本,文件不存在 / 读不动 ⇒ 空串(调用方按"从零建文档"处理)。 */ export async function readTextOrEmpty(file: string): Promise { try { @@ -47,12 +49,12 @@ export async function writeHomeFile(homeDir: string, file: string, text: string) * * 为什么单独抽出来(档案 138):用户卷可能**不在本机**(实例在 worker 上)⇒ 写入必须走 * `UserFs`(会按归属路由到那台机),而备份是**平台自己**的副本 —— 落在控制面的 - * `/opt/dsh/backups` 正合适,也不该为了备份再往远端开一条通道。 + * `/backups` 正合适,也不该为了备份再往远端开一条通道。 * 备份的命名规则与 {@link writeHomeFile} 的①步**逐字一致**(⛔ 别各写一套)。 */ export async function backupHomeFile(homeDir: string, fileOrName: string, text: string): Promise { try { - const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups' + const bakDir = backupDir() await mkdir(bakDir, { recursive: true }) const label = basename(fileOrName).replace(/^\./, '').replace(/\.ya?ml$/, '') // ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home", diff --git a/src/web/mail.ts b/src/web/mail.ts index 15f7fa0..f7b4e8a 100644 --- a/src/web/mail.ts +++ b/src/web/mail.ts @@ -43,7 +43,7 @@ export interface VerificationMail { to: string code: string ttlMinutes: number - /** 展示给收件人的站点名(如 `AI1NET`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */ + /** 展示给收件人的站点名(如 `EXAMPLE`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */ brand?: string } diff --git a/src/web/routes/admin-user-ops.ts b/src/web/routes/admin-user-ops.ts index c2a3257..f2a1d74 100644 --- a/src/web/routes/admin-user-ops.ts +++ b/src/web/routes/admin-user-ops.ts @@ -15,7 +15,7 @@ * 越界即 `bad_path`) * ② 启停其 DSH 实例 —— 与用户自己点「启动 / 停止」同一条 `supervisor` 路径 * 这与 `requireAdmin` 既有职能(审批 / 禁用 / 删除用户)同级;服务器层面 admin 本就能读 - * `/var/lib/dshs/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。 + * `/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。 * @module dshs/web/routes/admin-user-ops */ diff --git a/src/web/routes/admin.ts b/src/web/routes/admin.ts index b25e381..82c3d07 100644 --- a/src/web/routes/admin.ts +++ b/src/web/routes/admin.ts @@ -7,12 +7,15 @@ import type { FastifyPluginAsync } from 'fastify' import { execFileSync, spawn } from 'node:child_process' import { rm } from 'node:fs/promises' +import { join } from 'node:path' import { requireAdmin } from '../middleware/authn.js' import { userRoot } from '../../fs/workspace.js' +import { scriptPath, stateDir } from '../../platform-paths.js' -/** 档案 36:新用户审批通过后自动铺「功能插件」分区的脚本(幂等)。 */ +/** 档案 36:新用户审批通过后自动铺「功能插件」分区的脚本(幂等)。 + * 路径由**安装根**推导(`DSH_INSTALL_DIR` 可覆盖),⛔ 不写死绝对路径。 */ const ENSURE_BIZ_PLUGINS = - process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs' + process.env.DSH_ENSURE_BIZ_PLUGINS ?? scriptPath('ensure-biz-plugins.cjs') /** 档案 138:「平台共享模型」逐用户授权的入参(只有开关本身)。 */ const sharedModelSchema = { @@ -149,7 +152,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { /** * 档案 47:实例共享运行时/工具清单(admin 只读)。 * 数据全部用 shell 取(避免为此新增 import):版本 = 直接执行二进制; - * 基线 = cat /opt/dsh/state/runtime-baseline.json;清单 = cat SHARED-TOOLS.md。 + * 基线 = cat /state/runtime-baseline.json;清单 = cat SHARED-TOOLS.md。 * 升级/卸载不在此做 —— 走 `scripts/install-*.sh`(幂等、带 sha256 校验), * 升级后必须跑 `runtime-baseline.cjs --accept` 刷新基线(与档案 44 的版本冻结配套)。 */ @@ -182,7 +185,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { let baseline: unknown = null let drift: string[] = [] try { - baseline = JSON.parse(sh('cat', ['/opt/dsh/state/runtime-baseline.json'], '{}')) + baseline = JSON.parse(sh('cat', [join(stateDir(), 'runtime-baseline.json')], '{}')) const v = (baseline as { versions?: Record }).versions ?? {} const num = (s: string): string => (s.match(/\d+\.\d+(\.\d+)?/) ?? [''])[0] const pair: Array<[string, string]> = [ diff --git a/src/web/routes/dsh.ts b/src/web/routes/dsh.ts index 71c41ca..eb1e70e 100644 --- a/src/web/routes/dsh.ts +++ b/src/web/routes/dsh.ts @@ -14,6 +14,8 @@ import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orc import { renderPatch } from '../../supervisor/patch.js' import { subdomainForUser } from '../../supervisor/proxy.js' import { homeRoot, userRoot } from '../../fs/workspace.js' +import { join } from 'node:path' +import { stateDir } from '../../platform-paths.js' import { latestSessionPreset } from '../../supervisor/session-preset.js' const launchSchema = { @@ -192,7 +194,7 @@ export const dshRoutes: FastifyPluginAsync = async (app) => { // 档案 56 ②:实例能力清单(由 scripts/gen-capabilities.cjs 生成,与实例内 skill 同源)。 app.get('/api/capabilities', { preHandler: requireAuth }, async () => { const { readFileSync } = await import('node:fs') - const file = process.env.DSH_CAPABILITIES_FILE ?? '/opt/dsh/state/capabilities.json' + const file = process.env.DSH_CAPABILITIES_FILE ?? join(stateDir(), 'capabilities.json') try { return JSON.parse(readFileSync(file, 'utf8')) } catch { diff --git a/src/web/routes/overlay-nodes.ts b/src/web/routes/overlay-nodes.ts index 89818af..8534234 100644 --- a/src/web/routes/overlay-nodes.ts +++ b/src/web/routes/overlay-nodes.ts @@ -35,6 +35,8 @@ import { } from '../../net/relay/direct/index.js' import { loadRegistry, summarizeNetworks, listNodes } from '../../net/relay/registry.js' import { requireAdmin } from '../middleware/authn.js' +import { join } from 'node:path' +import { dataRootDir } from '../../platform-paths.js' /** 本机配置落点(`DSHS_OVERLAY_NODE_CONFIG` 可覆盖;缺省与 `join` 的 `--config` 一致)。 */ function nodeConfigFile(): string { @@ -45,7 +47,7 @@ function nodeConfigFile(): string { /** 注册表落点(`DSHS_OVERLAY_NODES_FILE` 可覆盖;缺省 = 参数表 `NODES_REGISTRY_FILE`)。 */ function registryFile(): string { const v = process.env.DSHS_OVERLAY_NODES_FILE - return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/var/lib/dshs/overlay/nodes.json' + return typeof v === 'string' && v.trim() !== '' ? v.trim() : join(dataRootDir(), 'overlay', 'nodes.json') } const fss = { diff --git a/src/web/server.ts b/src/web/server.ts index 3ac664b..4c4be1d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -53,6 +53,7 @@ import { type SettingsEntry, } from './model-landing.js' import { backupHomeFile } from './home-files.js' +import { stateDir } from '../platform-paths.js' import { rateLimit } from './middleware/rate-limit.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' @@ -147,7 +148,7 @@ export async function buildServer(config: ServerConfig): Promise => { const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []) @@ -333,7 +334,7 @@ export async function buildServer(config: ServerConfig): Promise` 同机直连 / `` 隧道落点)。 * `via` 列把"经谁"显式化 ⇒ 换中继 / 会合时不必改表语义(会合中继拆分方案 §2 C3)。 * * ⚠️ **S2 阶段行为零变化**:两种现役实现的 `resolve()` 都只把 endpoint 拆成 @@ -1095,7 +1096,7 @@ export async function buildServer(config: ServerConfig): Promise')` 返回 `undefined` ⇒ 旧行为**静默回退到本机 agent** ⇒ worker 上当然 * 没有这个用户 ⇒ 假 `404 {"error":"not_found"}`,与"文件夹不存在"完全同形,且平台零日志。 * (判别器 = relay 有没有 `DIAL`:没有 = 请求根本没出这台机。回归用例见 * `test/remote-user-fs.test.mjs`。) @@ -1167,9 +1168,9 @@ export async function buildServer(config: ServerConfig): Promise.dsh.ai1net.com` and calls portal APIs on - // `dsh.ai1net.com`). Cookie is HttpOnly + SameSite=None (secure mode) with - // Domain=.dsh.ai1net.com, so credentials ride along; we only need to allow + // runs in the browser on `.dsh.` and calls portal APIs on + // `dsh.`). Cookie is HttpOnly + SameSite=None (secure mode) with + // Domain=.dsh., so credentials ride along; we only need to allow // the Origin. Restricted to the platform base domain and its subdomains. app.addHook('onRequest', async (request, reply) => { const origin = request.headers.origin diff --git a/src/worker/agent.ts b/src/worker/agent.ts index eb90194..527ec97 100644 --- a/src/worker/agent.ts +++ b/src/worker/agent.ts @@ -52,7 +52,7 @@ export interface WorkerAgentOptions { /** 日志级别。 */ logLevel?: string /** - * **反向隧道**(跨机演练):Worker 主动拨 Manager,形如 `root@47.77.182.89:32022`。 + * **反向隧道**(跨机演练):Worker 主动拨 Manager,形如 `root@:`。 * 不设则完全关闭(同机/单机形态零影响)。见 `tunnel.ts` 头注释。 */ tunnelTarget?: string @@ -261,7 +261,7 @@ export function buildWorkerAgent( * ② `adoptedInstancePorts()` = 本进程**认领**来的存量实例(⛔ 不进 `mains`,见其文件头 序 ㉕)。 * * 只取 ① 就是本次实测的缺陷:worker 重启后 `mains` 空 ⇒ 上一进程遗留的实例端口**没人重新声明**, - * relay 端点表里只留一条 `online=false` 的孤儿条目(实测 47 侧 `w-106:19000`)⇒ Manager 侧 + * relay 端点表里只留一条 `online=false` 的孤儿条目(实测 47 侧 `:`)⇒ Manager 侧 * `(hostId, port)` 翻译不出来。并进 ② 之后,`forward(port)` 会把那条孤儿**就地覆盖**成在线 * (relay 侧 `ensureEndpoint` 复用既有条目、只换绑定会话,⛔ 不新开口、⛔ 不动白名单)。 */ diff --git a/src/worker/relay-tunnel.ts b/src/worker/relay-tunnel.ts index 24937b0..16bd8e7 100644 --- a/src/worker/relay-tunnel.ts +++ b/src/worker/relay-tunnel.ts @@ -32,9 +32,9 @@ import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/s import type { WorkerTunnel } from './tunnel.js' export interface RelayTunnelOptions { - /** relay 的 WebSocket 地址:`wss://ai1net.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */ + /** relay 的 WebSocket 地址:`wss:///dshs-relay`(生产)或 `ws://127.0.0.1:/dshs-relay`(本机验)。 */ url: string - /** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */ + /** 本机在 `dsh_hosts.id` 里的标识(`` / ``)。 */ hostId: string /** 与 relay 的预共享密钥(hex)。**缺失必须吵** —— 静默回退到别的传输比报错危险得多。 */ secret: string @@ -93,7 +93,7 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea * - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`); * - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**: * 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选 - * `wss://ai1net.com/dshs-relay` 与 `wss://relay-direct.ai1net.com/dshs-relay` **摘名不同、 + * `wss:///dshs-relay` 与 `wss://relay-direct./dshs-relay` **摘名不同、 * 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**, * 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够", * "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用); @@ -119,7 +119,7 @@ export function candidateObsMs(env: Record = process * 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。 * * 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒ - * 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47, + * 本数**不是独立物理路径数**(真机实证:`` 与 `relay-direct.` 都在 47, * 但 `count=3` 时 `hosts` 也报 3)。 */ function candHostsOf(urls: readonly string[]): number { diff --git a/src/worker/tunnel.ts b/src/worker/tunnel.ts index 4c2adb6..03ba393 100644 --- a/src/worker/tunnel.ts +++ b/src/worker/tunnel.ts @@ -28,13 +28,13 @@ const run = promisify(execFile) * 归一化会合地址(覆盖网络 S1)。 * * 为什么要它:会合点从"硬写在 env 里的 `user@host:port`"升格为**带 scheme 的 URL** - * (`ssh://root@47.77.182.89:32022`)—— 以后换传输协议(中继/隧道服务)只改 scheme。 + * (`ssh://root@:`)—— 以后换传输协议(中继/隧道服务)只改 scheme。 * 而本类其余代码如下按 `user@host:port` 切分 ⇒ 必须在**入口处**剥掉 scheme: - * 否则 `'ssh://root@h:32022'.split(':')` 会切成三截,把 `ssh` 当成主机名。 + * 否则 `'ssh://root@h:'.split(':')` 会切成三截,把 `ssh` 当成主机名。 * * 两种写法都接受(**单点归一,调用方不必判断**): - * · `ssh://root@47.77.182.89:32022` → `root@47.77.182.89:32022` - * · `root@47.77.182.89:32022` → 原样(兼容历史 env `DSHS_TUNNEL_TARGET`) + * · `ssh://root@:` → `root@:` + * · `root@:` → 原样(兼容历史 env `DSHS_TUNNEL_TARGET`) */ export function normalizeTunnelTarget(raw: string): string { const trimmed = raw.trim() @@ -45,7 +45,7 @@ export function normalizeTunnelTarget(raw: string): string { } export interface TunnelOptions { - /** 拨入目标,形如 `root@47.77.182.89:32022`(带 `ssh://` 前缀也接受,见 {@link normalizeTunnelTarget})。 */ + /** 拨入目标,形如 `root@:`(带 `ssh://` 前缀也接受,见 {@link normalizeTunnelTarget})。 */ target: string /** 私钥路径(建议专用、且在 Manager 侧用 `restrict,port-forwarding` 限权)。 */ identity: string @@ -235,7 +235,7 @@ export class SshTunnel implements WorkerTunnel { this.forwarded.clear() } - /** 目标 SSH 端口(`root@h:32022` → 32022)。 */ + /** 目标 SSH 端口(`root@h:` → 32022)。 */ get targetPort(): number | undefined { return this.portPart } diff --git a/test/i18n-brand.test.mjs b/test/i18n-brand.test.mjs index 7f0a0d9..27d3893 100644 --- a/test/i18n-brand.test.mjs +++ b/test/i18n-brand.test.mjs @@ -60,7 +60,7 @@ function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) { addEventListener: () => {}, }, navigator: { language, languages: [language] }, - location: { search, href: `https://ai1net.com/login.html${search}`, reload: () => {} }, + location: { search, href: `https://example.net/login.html${search}`, reload: () => {} }, window: {}, } sandbox.window = sandbox diff --git a/test/orchestrator-rehydrate.test.mjs b/test/orchestrator-rehydrate.test.mjs index 2aafa8d..95fd107 100644 --- a/test/orchestrator-rehydrate.test.mjs +++ b/test/orchestrator-rehydrate.test.mjs @@ -33,16 +33,16 @@ import { */ const REAL_DESC = '/usr/bin/bwrap --ro-bind /usr /usr --tmpfs /etc ' + - '--bind /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0/tmp /tmp ' + - '--bind /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0 /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0 ' + + '--bind /var/lib/dsh-test/users/4092b965-2f68-4977-9989-68b3966f7df0/tmp /tmp ' + + '--bind /var/lib/dsh-test/users/4092b965-2f68-4977-9989-68b3966f7df0 /var/lib/dsh-test/users/4092b965-2f68-4977-9989-68b3966f7df0 ' + '--unshare-pid ' + - '--chdir /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0/ws ' + + '--chdir /var/lib/dsh-test/users/4092b965-2f68-4977-9989-68b3966f7df0/ws ' + '-- setpriv --reuid 100002 --regid 100002 --clear-groups ' + '/usr/bin/dsh --profile web --host 127.0.0.1 --port 21000' const UID = 100002 const USER = '4092b965-2f68-4977-9989-68b3966f7df0' -const FOLDER = `/var/lib/dshs/users/${USER}/ws` +const FOLDER = `/var/lib/dsh-test/users/${USER}/ws` /* ── R1–R5:scope 名解析(⛔ 只认本平台自己的形态) ─────────────────────────── */ diff --git a/test/overlay-auth.test.mjs b/test/overlay-auth.test.mjs index ee24cbe..316b466 100644 --- a/test/overlay-auth.test.mjs +++ b/test/overlay-auth.test.mjs @@ -116,7 +116,7 @@ test('A2 parseReachability 的第一个参数是**逻辑名**:网络段由它 assert.equal(r.networkId, U_A) assert.equal(agentBaseUrl(r), 'http://127.0.0.1:19000', '取址与 S0/S2 逐字一致(网络维度不进地址)') // 裸 hostId 仍兼容 ⇒ 落 ops(过渡期:现网所有调用方一个字都不用改) - assert.equal(parseReachability('w-47', 'http://127.0.0.1:19100', VIA_LOCAL).networkId, OPS_NETWORK) + assert.equal(parseReachability('w-1', 'http://127.0.0.1:19100', VIA_LOCAL).networkId, OPS_NETWORK) // 非法网络段 ⇒ **抛**(配错别伪装成"这张网里没有它") assert.throws(() => parseReachability('UPPER/w-1', 'http://x:1', VIA_LOCAL), /网络段/) }) @@ -148,17 +148,17 @@ test('A3 两张网各有一台同名 w-1 ⇒ 解析各查各的(键是逻辑 /* ─────────── A4:via=relay 时禁止回落到 endpoint ─────────── */ test('A4 via=relay 且解析不出落点 ⇒ **抛**(不许回落到 endpoint = relay 落点)', () => { - const relayHost = { hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000', via: VIA_RELAY } + const relayHost = { hostId: 'w-2', agentUrl: 'http://127.0.0.1:19000', via: VIA_RELAY } assert.throws(() => agentBaseUrlOf(relayHost), /拒绝回落到 endpoint/) // 一旦解析成功 ⇒ 照常取址,且**优先**于 endpoint const ok = { ...relayHost, - reachability: { hostId: 'w-106', networkId: OPS_NETWORK, via: VIA_RELAY, address: '127.0.0.1:42067', scheme: 'http' }, + reachability: { hostId: 'w-2', networkId: OPS_NETWORK, via: VIA_RELAY, address: '127.0.0.1:42067', scheme: 'http' }, } assert.equal(agentBaseUrlOf(ok), 'http://127.0.0.1:42067') // 非 relay 语义(同机直连 / ssh 隧道)照旧回落 endpoint —— 这条不能被误伤 - assert.equal(agentBaseUrlOf({ hostId: 'w-47', agentUrl: 'http://127.0.0.1:19100', via: VIA_LOCAL }), 'http://127.0.0.1:19100') - assert.equal(agentBaseUrlOf({ hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000' }), 'http://127.0.0.1:19000') + assert.equal(agentBaseUrlOf({ hostId: 'w-1', agentUrl: 'http://127.0.0.1:19100', via: VIA_LOCAL }), 'http://127.0.0.1:19100') + assert.equal(agentBaseUrlOf({ hostId: 'w-2', agentUrl: 'http://127.0.0.1:19000' }), 'http://127.0.0.1:19000') }) /* ─────────── A5:控制面侧的跨网门(RelayDialer 口池) ─────────── */ diff --git a/test/overlay-bootstrap.test.mjs b/test/overlay-bootstrap.test.mjs index b17aaa5..c84ec99 100644 --- a/test/overlay-bootstrap.test.mjs +++ b/test/overlay-bootstrap.test.mjs @@ -126,7 +126,7 @@ function refusingFetch(calls) { test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { // 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径 - assert.equal(directoryUrlFor('https://ai1net.com/dshs-relay'), `https://ai1net.com${DIRECTORY_PATH}`) + assert.equal(directoryUrlFor('https://example.net/dshs-relay'), `https://example.net${DIRECTORY_PATH}`) assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`) // 已经是目录地址 ⇒ 原样 assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`) @@ -134,7 +134,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`) // 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据) - assert.equal(toRelayUrl('https://ai1net.com/dshs-relay'), 'wss://ai1net.com/dshs-relay') + assert.equal(toRelayUrl('https://example.net/dshs-relay'), 'wss://example.net/dshs-relay') assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay') assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x') assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝') @@ -178,20 +178,21 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { 'http://100.64.7.7/dshs-relay', 'http://relaybox/dshs-relay', 'http://[::1]/dshs-relay', - 'https://ai1net.com/dshs-relay', + 'https://example.net/dshs-relay', 'https://relay.example.com/dshs-relay', ]), - ['https://ai1net.com/dshs-relay', 'https://relay.example.com/dshs-relay'], + ['https://example.net/dshs-relay', 'https://relay.example.com/dshs-relay'], ) - // 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本) - assert.equal(DEFAULT_OVERLAY_SEED, 'https://ai1net.com/dshs-relay') + // ⛔ 内置种子**刻意留空** —— 种子是**具体部署的入口地址**,一律由配置提供 + // (`DSHS_OVERLAY_BOOTSTRAP_SEEDS`,见 `config/platform.env`)⇒ 代码内不留真实域名。 + assert.equal(DEFAULT_OVERLAY_SEED, '', '内置种子不得写死生产域名') // 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443) // ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继) assert.deepEqual( - publicRelayEntries(['wss://ai1net.com/dshs-relay', 'https://ai1net.com/dshs-relay']), - ['wss://ai1net.com/dshs-relay'], + publicRelayEntries(['wss://example.net/dshs-relay', 'https://example.net/dshs-relay']), + ['wss://example.net/dshs-relay'], ) // …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留 assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [ @@ -210,7 +211,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => { const keys = makeKeys() const other = makeKeys() - const origin = 'https://ai1net.com/dshs-relay' + const origin = 'https://example.net/dshs-relay' const { doc, sig } = signedDoc(origin, keys.privatePem) // 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验 @@ -248,7 +249,7 @@ test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律 test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => { const keys = makeKeys() - const seed = 'https://ai1net.com/dshs-relay' + const seed = 'https://example.net/dshs-relay' // ① env 显式 ⇒ 压制引导链(**一次网络都不发**) { @@ -279,7 +280,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级' fetchImpl: refusingFetch(calls), }) assert.equal(r.source, 'cache') - assert.equal(r.url, 'wss://ai1net.com/dshs-relay') + assert.equal(r.url, 'wss://example.net/dshs-relay') assert.deepEqual(calls, [], '新鲜缓存不许联网') } finally { rmSync(dir, { recursive: true, force: true }) @@ -298,7 +299,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级' fetchImpl: refusingFetch(calls), }) assert.equal(r.source, 'seed-fallback') - assert.equal(r.url, 'wss://ai1net.com/dshs-relay') + assert.equal(r.url, 'wss://example.net/dshs-relay') assert.ok(calls.length >= 1, '应当尝试过取目录') assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存') } finally { @@ -320,7 +321,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级' fetchImpl: refusingFetch([]), }) assert.equal(r.source, 'stale-cache') - assert.equal(r.url, 'wss://ai1net.com/dshs-relay') + assert.equal(r.url, 'wss://example.net/dshs-relay') // 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签) assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined) } finally { @@ -496,13 +497,16 @@ test('B6 签名不对的目录:既不写缓存也不采用(有旧缓存则 test('B7 端点契约:只公布公网地址、签名可被受信公钥验过、无密钥即不可用', async () => { const keys = makeKeys() + // 夹具用引导地址(RFC 2606 保留域):**不等于**内置种子常量 —— + // 后者刻意留空(生产入口地址一律由配置提供),所以这里必须自带一个公网形态的夹具。 + const FIXTURE_SEED = 'https://relay.example.net/dshs-relay' // 服务端逻辑:候选 = 显式中继入口 + 种子;**过滤回环/私网**后再组装 - const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', DEFAULT_OVERLAY_SEED]) - const bootstrap = publicRelayEntries([DEFAULT_OVERLAY_SEED]) + const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', FIXTURE_SEED]) + const bootstrap = publicRelayEntries([FIXTURE_SEED]) const doc = buildDirectoryDocument({ relays, bootstrap, network: 'ops', now: Date.now() }) const sig = signDirectory(doc, keys.privatePem) - assert.deepEqual(relays, [DEFAULT_OVERLAY_SEED], '回环地址不得出现在目录里') + assert.deepEqual(relays, [FIXTURE_SEED], '回环地址不得出现在目录里') assert.deepEqual(Object.keys(doc).sort(), [ 'bootstrap', 'issuedAt', @@ -539,7 +543,8 @@ test('S0 夹具自检:缓存读写是字节级可复现的(避免"测试夹 const { dir, file } = tmpCacheFile() try { const now = Date.now() - const { doc, sig } = signedDoc(DEFAULT_OVERLAY_SEED, keys.privatePem, { now }) + const FIXTURE_SEED = 'https://relay.example.net/dshs-relay' + const { doc, sig } = signedDoc(FIXTURE_SEED, keys.privatePem, { now }) writeCachedDirectory(file, doc, sig, now) const raw = readFileSync(file, 'utf8') const parsed = JSON.parse(raw) @@ -685,8 +690,8 @@ test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => { * **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」—— * `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。 */ -const FB_MAIN = 'https://ai1net.com/dshs-relay' -const FB_ALT = 'https://relay-direct.ai1net.com/dshs-relay' +const FB_MAIN = 'https://example.net/dshs-relay' +const FB_ALT = 'https://relay-direct.example.net/dshs-relay' /** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */ function twoEntryDoc(keys) { @@ -704,7 +709,7 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的 const logs = [] const fetchImpl = async (url) => { calls.push(String(url)) - if (String(url).startsWith('https://ai1net.com/')) throw new Error('cf unreachable') + if (String(url).startsWith('https://example.net/')) throw new Error('cf unreachable') return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) } const r = await resolveOverlayRelay({ @@ -717,14 +722,14 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的 // ① 逐个 origin 试,主 origin 被拒后才到兜底 assert.equal(calls.length, 2) assert.ok( - logs.some((l) => l.includes('拒绝 https://ai1net.com/dshs-overlay/bootstrap')), + logs.some((l) => l.includes('拒绝 https://example.net/dshs-overlay/bootstrap')), '缺"逐 origin 拒绝原因"这一行', ) // ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据) assert.equal(r.source, 'seed-directory') - assert.equal(r.url, 'wss://relay-direct.ai1net.com/dshs-relay') + assert.equal(r.url, 'wss://relay-direct.example.net/dshs-relay') assert.ok( - logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.ai1net.com/dshs-relay')), + logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.example.net/dshs-relay')), '缺"为什么走了兜底"这一行(可解释性)', ) }) @@ -742,6 +747,6 @@ test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首 fetchImpl, log: (l) => logs.push(l), }) - assert.equal(r.url, 'wss://ai1net.com/dshs-relay') + assert.equal(r.url, 'wss://example.net/dshs-relay') assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志') }) diff --git a/test/overlay-direct.test.mjs b/test/overlay-direct.test.mjs index 8fa9e9d..4786e2e 100644 --- a/test/overlay-direct.test.mjs +++ b/test/overlay-direct.test.mjs @@ -73,8 +73,8 @@ const serverPath = join(root, 'src', 'net', 'relay', 'server.ts') const dialers = () => normalizeDialers( new Map([ - ['ops', new Set(['manager', 'w-106'])], - ['u:5', new Set(['w-106'])], + ['ops', new Set(['manager', 'w-2'])], + ['u:5', new Set(['w-2'])], ]), ) @@ -89,7 +89,7 @@ async function deadPort() { const candidate = (over = {}) => encodeDirectMessage({ - hostId: 'w-106', + hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], ts: Date.now(), @@ -126,7 +126,7 @@ test('T1 开关:缺省=开|开集/关集|非法值 ⇒ null(⛔ 不静 // ── T2 · 关闭 ⇒ 零 socket / 零候选 ────────────────────────────────────────────── test('T2 关闭 ⇒ 零 UDP socket + 零候选;开启才真的开', async () => { - const ctxOf = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' } + const ctxOf = { dialers: dialers(), from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager' } const off = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'false' }), deadlineMs: 300 }) const v = off.offerCandidate(candidate(), ctxOf) assert.equal(v.ok, false) @@ -153,7 +153,7 @@ test('T2 关闭 ⇒ 零 UDP socket + 零候选;开启才真的开', async () // ── T3 · 候选准入矩阵 ─────────────────────────────────────────────────────────── test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒绝;静默拒绝 = 0', () => { const led = new CandidateLedger() - const inOps = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' } + const inOps = { dialers: dialers(), from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager' } const expectOk = (raw, ctx = inOps) => { const v = led.judge(raw, ctx) assert.equal(v.ok, true, `应接受:${v.ok ? '' : v.reason} ${v.ok ? '' : v.detail}`) @@ -168,7 +168,7 @@ test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒 expectOk(candidate()) expectOk(candidate({ addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }, { host: '2001:db8::1', port: PUNCH_PORT_BASE + 2 }] })) // 同名跨网**互不可见**:同一 hostId 在另一张网里是合法身份 - expectOk(candidate({ network: 'u:5' }), { dialers: dialers(), from: { network: 'u:5', hostId: 'w-106' }, selfHostId: 'w-106' }) + expectOk(candidate({ network: 'u:5' }), { dialers: dialers(), from: { network: 'u:5', hostId: 'w-2' }, selfHostId: 'w-2' }) expectReject(candidate({ network: 'u:5' }), 'cross-network') expectReject(candidate({ hostId: 'w-999' }), 'not-self-candidate') @@ -177,7 +177,7 @@ test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒 expectReject('{"kind":"DIRECT_CANDIDATE"}', 'bad-shape') expectReject('not json at all', 'bad-shape') expectReject( - JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], nodeKey: 'deadbeef' }), + JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], nodeKey: 'deadbeef' }), 'secret-field', ) expectReject(candidate({ addrs: [{ host: 'example.com', port: 80 }] }), 'bad-address') @@ -196,9 +196,9 @@ test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒 // 每一条拒绝**都有具名原因** for (const r of snap.rejected) assert.ok(typeof r.reason === 'string' && r.reason !== '') // 白名单是**按网络分桶**的:拿 ops 的桶查 u:5 的同名 hostId 必须为假 - assert.equal(isAllowedDialer(dialers(), 'ops', 'w-106'), true) - assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-106'), true) - assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-106'), false) + assert.equal(isAllowedDialer(dialers(), 'ops', 'w-2'), true) + assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-2'), true) + assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-2'), false) assert.equal(isAllowedDialer(dialers(), 'ops', 'w-999'), false) // 地址形状:IPv4/IPv6 收,主机名不收 assert.equal(isValidAddress({ host: '10.0.0.9', port: 21100 }), true) @@ -236,7 +236,7 @@ test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉 // ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ──────────────────────────────────── test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', async () => { - const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 2500 }, { sleep }) + const r = await runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs: 2500 }, { sleep }) assert.equal(r.a.bidirectional, true, `A 侧应双向成立:${r.a.detail}`) assert.equal(r.b.bidirectional, true, `B 侧应双向成立:${r.b.detail}`) assert.equal(r.a.reason, 'ok') @@ -248,7 +248,7 @@ test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', a // ── T6 · 单向不算直连 ─────────────────────────────────────────────────────────── test('T6 单向 ⇒ 判死 one-way(⛔ 不许把单向当成功)', async () => { - const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 1200, oneWay: 'a' }, { sleep }) + const r = await runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs: 1200, oneWay: 'a' }, { sleep }) assert.equal(r.bidirectional, false) assert.equal(r.a.reason, 'one-way', `A 侧(只能出不能进)应判 one-way:${r.a.detail}`) assert.equal(r.a.bidirectional, false) @@ -397,7 +397,7 @@ test('T10 提示文案必须**可行动**:三段齐 + 含开关键与关值' // ⛔ 禁止只写"已启用直连" assert.ok(!/^已启用直连$/.test(text.trim())) // 编码侧的结构性防线:**只吃白名单字段** ⇒ 多给的任何字段(含凭据)都进不了载荷 - const encoded = JSON.parse(encodeDirectMessage({ hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: 1 }], secret: 'x' })) + const encoded = JSON.parse(encodeDirectMessage({ hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: 1 }], secret: 'x' })) assert.deepEqual(Object.keys(encoded).sort(), ['addrs', 'hostId', 'kind', 'network', 'ts']) assert.equal(encoded.secret, undefined, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)') }) diff --git a/test/overlay-identity.test.mjs b/test/overlay-identity.test.mjs index 83a5d6a..b5affe9 100644 --- a/test/overlay-identity.test.mjs +++ b/test/overlay-identity.test.mjs @@ -342,10 +342,10 @@ test('B12 指纹:每机一把 ⇒ 指纹互不相同;解析不出来就 unde test('C1 旧写法(裸 hostId + hex 串)⇒ 归入运维网 ops(现网配置一字不改)', () => { const s = randomBytes(32).toString('hex') - const map = parseKeysInline(`w-47:${s}`) - // 键 = **逻辑名**(序③)⇒ 裸 `w-47` 归一成 `ops/w-47` - assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s }) - assert.equal(describeKeyEntry('w-47', map.get('ops/w-47')), 'w-47', 'ops 下省略网络前缀(日志读法与 R5 一致)') + const map = parseKeysInline(`w-1:${s}`) + // 键 = **逻辑名**(序③)⇒ 裸 `w-1` 归一成 `ops/w-1` + assert.deepEqual(map.get('ops/w-1'), { network: OPS_NETWORK, secret: s }) + assert.equal(describeKeyEntry('w-1', map.get('ops/w-1')), 'w-1', 'ops 下省略网络前缀(日志读法与 R5 一致)') }) test('C2 新写法:`网/hostId:secret` 与 `网:hostId:secret` 都切得出网络(切点是**最后一个冒号**)', () => { @@ -515,9 +515,9 @@ test('E1 keys 文件:新旧写法可**共存**(原地升级 ⇒ 可原子替 const s1 = randomBytes(32).toString('hex') const s2 = randomBytes(32).toString('hex') const file = join(dir, 'relay-keys.json') - writeFileSync(file, JSON.stringify({ 'w-47': s1, w106: { network: OPS_NETWORK, secret: s2 } })) + writeFileSync(file, JSON.stringify({ 'w-1': s1, w106: { network: OPS_NETWORK, secret: s2 } })) const map = loadKeysFile(file) - assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s1 }) + assert.deepEqual(map.get('ops/w-1'), { network: OPS_NETWORK, secret: s1 }) assert.deepEqual(map.get('ops/w106'), { network: OPS_NETWORK, secret: s2 }) // 非法网络段 ⇒ **装载即抛**("配置错就炸",不变成运行期的静默拒绝) diff --git a/test/overlay-network.test.mjs b/test/overlay-network.test.mjs index 1a162a7..db762df 100644 --- a/test/overlay-network.test.mjs +++ b/test/overlay-network.test.mjs @@ -153,7 +153,7 @@ test('U1 逻辑名唯一入口:旧形态 / 新形态 / `u:<租户>` 里的冒 // ① 规范形态 assert.equal(logicalName('u:5', 'w-1'), 'u:5/w-1') assert.deepEqual(parseLogicalName('ops/manager'), { network: 'ops', hostId: 'manager' }) - assert.deepEqual(parseLogicalName('u:5/w-106'), { network: 'u:5', hostId: 'w-106' }) + assert.deepEqual(parseLogicalName('u:5/w-2'), { network: 'u:5', hostId: 'w-2' }) // ② 兼容形态:`network:hostId`(运维习惯写法) assert.deepEqual(parseLogicalName('ops:manager'), { network: 'ops', hostId: 'manager' }) @@ -163,7 +163,7 @@ test('U1 逻辑名唯一入口:旧形态 / 新形态 / `u:<租户>` 里的冒 // ③ 旧形态(R5 时代的扁平 hostId)⇒ 落在运维网,**旧配置照旧可用** assert.deepEqual(parseLogicalName('manager'), { network: OPS_NETWORK, hostId: 'manager' }) - assert.deepEqual(parseLogicalName('w-106'), { network: OPS_NETWORK, hostId: 'w-106' }) + assert.deepEqual(parseLogicalName('w-2'), { network: OPS_NETWORK, hostId: 'w-2' }) // ④ 非法 ⇒ **抛**(配置错就炸,不静默变成"谁也没匹配上") assert.throws(() => parseLogicalName('u:5'), /网络段/) @@ -205,8 +205,8 @@ test('U2 旧客户端(不声明 network)+ 旧扁平白名单 ⇒ 一切照 const srv = new RelayServer({ port: 0, keys: new Map([ - ['w-106', wSecret], - ['w-47', w47Secret], + ['w-2', wSecret], + ['w-1', w47Secret], ['manager', mSecret], ]), instancePortBase: BASE, @@ -223,21 +223,21 @@ test('U2 旧客户端(不声明 network)+ 旧扁平白名单 ⇒ 一切照 }) // ① **旧客户端握手**:HELLO 里根本没有 network 字段(= 现网 47/106 上正在跑的那一版) - const { ws: rawWs, frame: ack } = await rawHello(url, 'w-106', wSecret, String(legacyPort), randomBytes(16).toString('hex')) + const { ws: rawWs, frame: ack } = await rawHello(url, 'w-2', wSecret, String(legacyPort), randomBytes(16).toString('hex')) t.after(() => rawWs.close()) assert.ok(ack !== undefined, '旧客户端必须能注册(否则这次改动就是硬断)') assert.equal(ack.type, MUX.HELLO_ACK, '旧客户端应拿到 HELLO_ACK') const parsed = JSON.parse(ack.payload.toString('utf8')) assert.equal(parsed.network, OPS_NETWORK, 'HELLO_ACK 应回显服务端认定的网 = ops') - assert.equal(parsed.name, logicalName(OPS_NETWORK, 'w-106')) - assert.ok(await waitFor(() => srv.isOnline('w-106')), '默认网络(ops)里应看到它') + assert.equal(parsed.name, logicalName(OPS_NETWORK, 'w-2')) + assert.ok(await waitFor(() => srv.isOnline('w-2')), '默认网络(ops)里应看到它') assert.ok( - await waitFor(() => srv.localPortOf('w-106', legacyPort) !== undefined), + await waitFor(() => srv.localPortOf('w-2', legacyPort) !== undefined), '旧客户端照样拿到回环落点(R1–R4 的行为不变)', ) // ② 不传 `networkId` 的真 client = ops(默认值即现网事实);旧扁平白名单照旧拨得动 - const worker = new RelayClient({ url, hostId: 'w-47', secret: w47Secret, ports: [servePort], log: () => {} }) + const worker = new RelayClient({ url, hostId: 'w-1', secret: w47Secret, ports: [servePort], log: () => {} }) const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) worker.start() dialer.start() @@ -249,14 +249,14 @@ test('U2 旧客户端(不声明 network)+ 旧扁平白名单 ⇒ 一切照 assert.equal(worker.status().network, OPS_NETWORK, '不声明网络 ⇒ 默认 ops(不是空、不是 undefined)') assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') assert.deepEqual(srv.status().dialers, ['manager'], '/status 的 dialers 仍按旧写法展示(不破坏既有消费者)') - const duplex = await dialer.openStream('w-47', servePort) + const duplex = await dialer.openStream('w-1', servePort) assert.equal(await dialRoundTrip(duplex, 'legacy-ok', 'ops:'.length), 'ops:legacy-ok', '字节要真的过去') duplex.destroy() }) /* ─────────── U3:跨网隔离是**结构性**的(拒绝点在 relay) ─────────── */ -test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay 拒,且**不泄露**目标在哪张网', async (t) => { +test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-2 ⇒ relay 拒,且**不泄露**目标在哪张网', async (t) => { const wSecret = randomBytes(32).toString('hex') const opsSecret = randomBytes(32).toString('hex') const foreignSecret = randomBytes(32).toString('hex') @@ -266,7 +266,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay const srv = new RelayServer({ port: 0, keys: new Map([ - ['w-106', wSecret], + ['w-2', wSecret], ['manager', opsSecret], // 序③:`dt` 真正属于 U_TEST(密钥表说了算)⇒ 它能进自己的网,然后在 **DIAL 那一步** // 被跨网判据挡住 —— 这才是本用例要测的那道门,而不是『压根没登记』那道。 @@ -286,7 +286,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay }) await srv.start() const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` - const worker = new RelayClient({ url, hostId: 'w-106', secret: wSecret, ports: [workerPort], log: () => {} }) + const worker = new RelayClient({ url, hostId: 'w-2', secret: wSecret, ports: [workerPort], log: () => {} }) const foreign = new RelayClient({ url, hostId: 'dt', @@ -304,7 +304,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay echo.close() await srv.stop() }) - const opsUp = await waitFor(() => srv.isOnline('w-106')) + const opsUp = await waitFor(() => srv.isOnline('w-2')) assert.ok(opsUp, 'ops 侧 worker 未注册') const foreignUp = await waitFor(() => srv.isOnline('dt', U_TEST)) assert.ok(foreignUp, `别网拨号方未注册;最近日志:${logs.slice(-10).join(' | ')}`) @@ -313,8 +313,8 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay const nets = srv.status().networks assert.deepEqual( nets.find((n) => n.network === OPS_NETWORK)?.sessions, - ['w-106'], - 'ops 网里应只有 w-106', + ['w-2'], + 'ops 网里应只有 w-2', ) assert.deepEqual(nets.find((n) => n.network === U_TEST)?.sessions, ['dt'], '别张网里应只有 dt') @@ -322,7 +322,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay const before = srv.status().counters.refused let foreignMsg = '' await assert.rejects( - () => foreign.openStream('w-106', workerPort), + () => foreign.openStream('w-2', workerPort), (err) => { foreignMsg = err instanceof Error ? err.message : String(err) return true @@ -347,7 +347,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay ) // 目标侧没有任何流被建立(不是"建了再断") assert.equal( - srv.status().endpoints.find((e) => e.hostId === 'w-106' && e.network === OPS_NETWORK && e.port === workerPort)?.streams, + srv.status().endpoints.find((e) => e.hostId === 'w-2' && e.network === OPS_NETWORK && e.port === workerPort)?.streams, 0, '被拒的跨网拨号不得在目标侧留下流', ) @@ -358,7 +358,7 @@ test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay ops.start() t.after(() => ops.stop()) assert.ok(await waitFor(() => ops.status().state === 'up'), 'ops 拨号方未注册') - const duplex = await ops.openStream('w-106', workerPort) + const duplex = await ops.openStream('w-2', workerPort) assert.equal(await dialRoundTrip(duplex, 'same-net', 'ops:'.length), 'ops:same-net') duplex.destroy() }) diff --git a/test/reachability.test.mjs b/test/reachability.test.mjs index 1c22c8d..469974c 100644 --- a/test/reachability.test.mjs +++ b/test/reachability.test.mjs @@ -9,8 +9,8 @@ * * | hostId | dsh_hosts.endpoint(2026-09-16 实测) | 真实语义 | * |---|---|---| - * | `w-106` | `http://127.0.0.1:19000` | 经 47 上 sshd 的反向隧道落点 | - * | `w-47` | `http://127.0.0.1:19100` | 同机直连(node 自己监听) | + * | `w-2` | `http://127.0.0.1:19000` | 经 47 上 sshd 的反向隧道落点 | + * | `w-1` | `http://127.0.0.1:19100` | 同机直连(node 自己监听) | */ import { test } from 'node:test' import assert from 'node:assert/strict' @@ -27,8 +27,8 @@ import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../li /** 现网真实两条(2026-09-16 在 47 上 `SELECT id, endpoint FROM dsh_hosts` 实测)。 */ const LIVE_HOSTS = [ - { hostId: 'w-106', endpoint: 'http://127.0.0.1:19000', via: VIA_MANAGER_SSH }, - { hostId: 'w-47', endpoint: 'http://127.0.0.1:19100', via: VIA_LOCAL }, + { hostId: 'w-2', endpoint: 'http://127.0.0.1:19000', via: VIA_MANAGER_SSH }, + { hostId: 'w-1', endpoint: 'http://127.0.0.1:19100', via: VIA_LOCAL }, ] test('S0 等价性:旧 agentUrl 取址与可达性取址逐字相等', () => { @@ -75,9 +75,9 @@ test('parseReachability:https / 裸 host:port / 尾斜杠 三种兼容面', () test('可达性优先于旧 agentUrl', () => { const host = { - hostId: 'w-106', + hostId: 'w-2', agentUrl: 'http://stale.example:1', - reachability: { hostId: 'w-106', via: VIA_MANAGER_SSH, address: '127.0.0.1:19000', scheme: 'http' }, + reachability: { hostId: 'w-2', via: VIA_MANAGER_SSH, address: '127.0.0.1:19000', scheme: 'http' }, } assert.equal(agentBaseUrlOf(host), 'http://127.0.0.1:19000') }) @@ -88,31 +88,31 @@ test('两者皆缺 ⇒ 抛错(禁止静默打到空地址)', () => { }) test('LocalRendezvous:命中给 local,未命中回 undefined 不抛', async () => { - const table = new Map([['w-47', '127.0.0.1:19100']]) + const table = new Map([['w-1', '127.0.0.1:19100']]) const rv = new LocalRendezvous((id) => table.get(id)) assert.equal(rv.id, VIA_LOCAL) assert.equal(rv.dialTarget(), '(direct)') - assert.deepEqual(await rv.resolve('w-47'), { - hostId: 'w-47', + assert.deepEqual(await rv.resolve('w-1'), { + hostId: 'w-1', networkId: 'ops', via: VIA_LOCAL, address: '127.0.0.1:19100', scheme: 'http', }) - assert.equal(await rv.resolve('w-106'), undefined) + assert.equal(await rv.resolve('w-2'), undefined) }) test('ManagerSshRendezvous:解析出的基址必须等于现网 endpoint(S2 迁移判据)', async () => { const table = new Map([ - ['w-106', '127.0.0.1:19000'], - ['w-47', '127.0.0.1:19100'], + ['w-2', '127.0.0.1:19000'], + ['w-1', '127.0.0.1:19100'], ]) const rv = new ManagerSshRendezvous({ - target: 'root@47.77.182.89:32022', + target: 'root@203.0.113.10:32022', addressOf: (id) => table.get(id), }) assert.equal(rv.id, VIA_MANAGER_SSH) - assert.equal(rv.dialTarget(), 'root@47.77.182.89:32022') + assert.equal(rv.dialTarget(), 'root@203.0.113.10:32022') for (const h of LIVE_HOSTS) { const resolved = await rv.resolve(h.hostId) assert.equal(agentBaseUrl(resolved), h.endpoint, `${h.hostId} 迁移后基址变了`) @@ -142,7 +142,7 @@ test('S2:via → Rendezvous → Reachability 后取址与旧 agentUrl 逐条 const hostAddresses = new Map() const rendezvous = new RendezvousRegistry([ new LocalRendezvous((id) => hostAddresses.get(id)), - new ManagerSshRendezvous({ target: 'ssh://root@47.77.182.89:32022', addressOf: (id) => hostAddresses.get(id) }), + new ManagerSshRendezvous({ target: 'ssh://root@203.0.113.10:32022', addressOf: (id) => hostAddresses.get(id) }), ]) // hostsProvider 第一遍:同步地址表 for (const row of rows) { diff --git a/test/register-guard.test.mjs b/test/register-guard.test.mjs index 66396fb..8fb0577 100644 --- a/test/register-guard.test.mjs +++ b/test/register-guard.test.mjs @@ -161,9 +161,9 @@ test('retryAfter 人话格式化', () => { }) test('mail: 正文含验证码与有效期,且明确告知"非本人操作请忽略"', () => { - const { subject, text } = renderVerificationMail({ to: 'a@b.com', code: '123456', ttlMinutes: 10, brand: 'AI1NET' }) + const { subject, text } = renderVerificationMail({ to: 'a@b.com', code: '123456', ttlMinutes: 10, brand: 'EXAMPLE' }) assert.ok(subject.includes('123456')) - assert.ok(subject.includes('AI1NET')) + assert.ok(subject.includes('EXAMPLE')) assert.ok(text.includes('123456')) assert.ok(text.includes('10')) assert.ok(text.includes('ignore this e-mail')) @@ -210,8 +210,8 @@ test('mail: http 驱动把 URL / 鉴权头 / body 模板交给配置(换供应 apiUrl: `http://127.0.0.1:${port}/send`, apiKey: 'sekret', authHeader: 'x-api-key', - from: 'no-reply@ai1net.com', - fromName: 'AI1NET', + from: 'no-reply@example.net', + fromName: 'EXAMPLE', bodyTemplate: '{"to":"{{to}}","subject":"{{subject}}","payload":{"code":"{{code}}"}}', timeoutMs: 3000, }, @@ -249,7 +249,7 @@ test('mail: 上游 5xx ⇒ 明确失败(不重试、不假装成功)', async }) test('turnstile: 只填一把钥匙视为未配置(半配置不许当"已启用")', () => { - const base = { siteKey: 'k', secret: 's', action: 'signup', hostnames: ['ai1net.com'], timeoutMs: 1000 } + const base = { siteKey: 'k', secret: 's', action: 'signup', hostnames: ['example.net'], timeoutMs: 1000 } assert.equal(turnstileEnabled(base), true) assert.equal(turnstileEnabled({ ...base, secret: '' }), false) assert.equal(turnstileEnabled({ ...base, siteKey: '' }), false) @@ -260,8 +260,8 @@ test('turnstile: 只填一把钥匙视为未配置(半配置不许当"已启 test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三项)', async () => { // 本地假 siteverify:按 path 决定回什么,从而逐组合断言判定逻辑 const cases = { - '/ok': { success: true, action: 'signup', hostname: 'ai1net.com' }, - '/badaction': { success: true, action: 'login', hostname: 'ai1net.com' }, + '/ok': { success: true, action: 'signup', hostname: 'example.net' }, + '/badaction': { success: true, action: 'login', hostname: 'example.net' }, '/badhost': { success: true, action: 'signup', hostname: 'evil.example' }, '/nohost': { success: true, action: 'signup' }, '/fail': { success: false, 'error-codes': ['invalid-input-response'] }, @@ -277,7 +277,7 @@ test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三 const port = server.address().port const settings = (path) => ({ siteKey: 'k', secret: 's', timeoutMs: 3000, action: 'signup', - hostnames: ['ai1net.com', 'www.ai1net.com'], + hostnames: ['example.net', 'www.example.net'], verifyUrl: `http://127.0.0.1:${port}${path}`, }) try { @@ -301,21 +301,21 @@ test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三 }) test('config: 期望主机名归一 + 派生 + ⛔ 绝不自动加 localhost', () => { - assert.deepEqual(normalizeHostnames(['https://AI1net.com/', 'www.ai1net.com:443', ' ai1net.com ']), [ - 'ai1net.com', 'www.ai1net.com', + assert.deepEqual(normalizeHostnames(['https://EXAMPLE.net/', 'www.example.net:443', ' example.net ']), [ + 'example.net', 'www.example.net', ]) // 未配(undefined)⇒ 从 baseDomain 派生 - assert.deepEqual(resolveTurnstileHostnames(undefined, 'ai1net.com'), ['ai1net.com', 'www.ai1net.com']) + assert.deepEqual(resolveTurnstileHostnames(undefined, 'example.net'), ['example.net', 'www.example.net']) // 显式配 ⇒ 只用配的(可加旧域,旧域门户仍在线) assert.deepEqual( - resolveTurnstileHostnames(['ai1net.com', 'alotbuy.com'], 'ai1net.com'), - ['ai1net.com', 'alotbuy.com'], + resolveTurnstileHostnames(['example.net', 'example.org'], 'example.net'), + ['example.net', 'example.org'], ) // 显式空 ⇒ 关闭(不派生) - assert.deepEqual(resolveTurnstileHostnames([], 'ai1net.com'), []) + assert.deepEqual(resolveTurnstileHostnames([], 'example.net'), []) // baseDomain 空 ⇒ 空(= 未配置完成 ⇒ 人机验证停用,不会静默放开) assert.deepEqual(resolveTurnstileHostnames(undefined, ''), []) - assert.equal(normalizeHostnames(['localhost']).includes('ai1net.com'), false) + assert.equal(normalizeHostnames(['localhost']).includes('example.net'), false) }) /* ── 编排层:真库 + log 驱动 ─────────────────────────────────────────────── */ diff --git a/test/relay-failover.test.mjs b/test/relay-failover.test.mjs index 888336a..2bd09ac 100644 --- a/test/relay-failover.test.mjs +++ b/test/relay-failover.test.mjs @@ -488,7 +488,7 @@ const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_ /** * F12 · **目录路径没有豁免权**(E1 / D1)。 * - * 立项依据:真机 11:43:26 实测 `wss://106… -> wss://ai1net.com…` —— 只因"目录里的地址变了" + * 立项依据:真机 11:43:26 实测 `wss://106… -> wss://example.net…` —— 只因"目录里的地址变了" * 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。 */ test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => { @@ -858,12 +858,12 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0) obs.record( [ - 'wss://ai1net.com/dshs-relay', - 'https://ai1net.com/other', - 'wss://106.54.21.172/dshs-relay', + 'wss://example.net/dshs-relay', + 'https://example.net/other', + 'wss://198.51.100.20/dshs-relay', ], 'cache', - '/var/lib/dshs/overlay/directory.json', + '/var/lib/dsh-test/overlay/directory.json', ) assert.equal(lines.length, 1, '一次 record 写一行') const line = lines[0] @@ -876,7 +876,7 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts ) const snap = obs.snapshot() assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)') - assert.equal(snap.hosts, 2, 'hosts = 独立主机数(ai1net.com 的两条算同一台 —— scheme 不参与)') + assert.equal(snap.hosts, 2, 'hosts = 独立主机数(example.net 的两条算同一台 —— scheme 不参与)') assert.equal(snap.source, 'cache') assert.equal(snap.resolves, 1) assert.equal(snap.unresolved, false) diff --git a/test/relay.test.mjs b/test/relay.test.mjs index 7590359..63a10e3 100644 --- a/test/relay.test.mjs +++ b/test/relay.test.mjs @@ -1044,7 +1044,7 @@ test('T20 序⑤ 判别器计数:DIAL 放行 / 策略拒绝 / 目标不可达 * # T23 · 拨号流**严格单向**(序 ⑭ · 数据面缺陷回归) * * ## 生产现象(用户可见) - * 任何 `via='relay'` 的 host(今天 = w-106)上,**同一条 keep-alive 连接的第 2 条** agent 请求 + * 任何 `via='relay'` 的 host(今天 = w-2)上,**同一条 keep-alive 连接的第 2 条** agent 请求 * 必回 `400 clientError`(Fastify `clientError` 兜底)⇒ 用户 `POST /api/dsh/enter` 回 **500** * ⇒ **"登录直达工作区"整体不可用**。 * @@ -1870,14 +1870,14 @@ test('T35 P-2:`relayEndpointTarget` 四支判定(透传 / 拨号 / 快照 / test('T36 P-2:键口径 —— 裸 `hostId` 必须经 `hostNameIndex` 换到逻辑名(⛔ 闭包不得再拿 hostId 当键)', async () => { const idx = hostNameIndex([ - { id: 'w-47', networkId: 'ops' }, - { id: 'w-106', networkId: '' }, // 空 ⇒ 归属网取兜底(与 DB 列默认值同口径) + { id: 'w-1', networkId: 'ops' }, + { id: 'w-2', networkId: '' }, // 空 ⇒ 归属网取兜底(与 DB 列默认值同口径) { id: 'd1', networkId: 'u:5' }, ]) - assert.equal(idx.get('w-47'), 'ops/w-47') - assert.equal(idx.get('w-106'), 'ops/w-106', '空 network_id 必须按兜底网补全(否则与 DB 行写的键不一致)') + assert.equal(idx.get('w-1'), 'ops/w-1') + assert.equal(idx.get('w-2'), 'ops/w-2', '空 network_id 必须按兜底网补全(否则与 DB 行写的键不一致)') assert.equal(idx.get('d1'), 'u:5/d1', '跨网同 hostId 各算一台(P0-3)') - assert.equal(idx.get('ops/w-106'), undefined, '索引的键是**裸 hostId** ⇒ 拿逻辑名查不到(两侧口径必须显式转换)') + assert.equal(idx.get('ops/w-2'), undefined, '索引的键是**裸 hostId** ⇒ 拿逻辑名查不到(两侧口径必须显式转换)') assert.equal(hostNameIndex([{ id: 'x', networkId: '' }], 'u:9').get('x'), 'u:9/x', '兜底网可注入(⛔ 不写死 ops)') /** diff --git a/test/remote-spawner.test.mjs b/test/remote-spawner.test.mjs index e90f589..57c1f59 100644 --- a/test/remote-spawner.test.mjs +++ b/test/remote-spawner.test.mjs @@ -37,9 +37,9 @@ function fakeFetch(payload) { /** `via='relay'` 的一台 worker:agent 口号 19000,relay 已把它映射到本机 38253。 */ const W106 = { - hostId: 'w-106', + hostId: 'w-2', agentUrl: 'http://127.0.0.1:19000', - reachability: { hostId: 'w-106', via: 'relay', address: '127.0.0.1:38253', scheme: 'http' }, + reachability: { hostId: 'w-2', via: 'relay', address: '127.0.0.1:38253', scheme: 'http' }, token: 'tok-106', } @@ -47,8 +47,8 @@ function make(opts = {}) { return new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok-local', - defaultHostId: 'w-47', - hostIdFor: async () => 'w-106', + defaultHostId: 'w-1', + hostIdFor: async () => 'w-2', hostsProvider: async () => [W106], fetchImpl: fakeFetch({ running: true, host: '127.0.0.1', port: 21000 }), ...opts, @@ -67,7 +67,7 @@ test('T1 翻译器被真的接上:传入的 hostId/endpoint 与返回值都要 }) assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 34241 }) // ★ 这一条就是首版漏赋值时唯一会红的断言:漏了 ⇒ seen 为空、返回 {21000} - assert.deepEqual(seen, [['w-106', { host: '127.0.0.1', port: 21000 }]]) + assert.deepEqual(seen, [['w-2', { host: '127.0.0.1', port: 21000 }]]) }) test('T2 未给翻译器 ⇒ 原样透传(local / manager-ssh 的同号语义,行为零变化)', async () => { diff --git a/test/remote-user-fs.test.mjs b/test/remote-user-fs.test.mjs index fc4e51c..9c9a4b3 100644 --- a/test/remote-user-fs.test.mjs +++ b/test/remote-user-fs.test.mjs @@ -33,7 +33,7 @@ import { RemoteUserFs } from '../lib/fs/remote-user-fs.js' /** Manager 自己那台(= 默认 / 回退 agent)—— 任何"打到这里"都是路由失败。 */ const DEFAULT_AGENT = 'http://127.0.0.1:19100' -/** 归属机:w-106 的 agent。 */ +/** 归属机:w-2 的 agent。 */ const W106_AGENT = 'http://127.0.0.1:19000' /** 记账用 fetch:记下每一发请求,永不真的出网。 */ @@ -74,7 +74,7 @@ test('U1 目录未命中:ensureHost 补齐后打到**归属机器**(不再 const dir = new Map() // 模拟 hostsProvider() 尚未填过的空目录 let ensured = 0 const { fs, fetchImpl } = mk({ - hostIdFor: async () => 'w-106', + hostIdFor: async () => 'w-2', agentFor: (h) => dir.get(h), ensureHost: async (h) => { ensured += 1 @@ -85,13 +85,13 @@ test('U1 目录未命中:ensureHost 补齐后打到**归属机器**(不再 await fs.listDir('u1', '') assert.equal(ensured, 1, '未命中必须触发一次补齐') - assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`], '必须打到 w-106,不许打默认机') + assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`], '必须打到 w-2,不许打默认机') }) test('U7 命中时**不**做补齐(正常路径零开销:不查库)', async () => { let ensured = 0 const { fs, fetchImpl } = mk({ - hostIdFor: async () => 'w-106', + hostIdFor: async () => 'w-2', agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }), ensureHost: async () => { ensured += 1 @@ -109,8 +109,8 @@ test('U7 命中时**不**做补齐(正常路径零开销:不查库)', asyn test('U2 补齐后仍取不到地址:503 host_unresolved,且**零请求发往默认机**(写操作也拦住)', async () => { let ensured = 0 const { fs, fetchImpl } = mk({ - hostIdFor: async () => 'w-106', - agentFor: () => undefined, // 目录里始终没有 w-106 + hostIdFor: async () => 'w-2', + agentFor: () => undefined, // 目录里始终没有 w-2 ensureHost: async () => { ensured += 1 }, @@ -125,7 +125,7 @@ test('U2 补齐后仍取不到地址:503 host_unresolved,且**零请求发 test('U3 未提供 ensureHost(老调用点):未命中同样失败关闭,**不退化**为静默回退', async () => { const { fs, fetchImpl } = mk({ - hostIdFor: async () => 'w-106', + hostIdFor: async () => 'w-2', agentFor: () => undefined, // ensureHost 故意不传 }) @@ -136,7 +136,7 @@ test('U3 未提供 ensureHost(老调用点):未命中同样失败关闭, test('U8 ensureHost 自己抛错(如查库失败):仍失败关闭,不吞成"默认机"', async () => { const { fs, fetchImpl } = mk({ - hostIdFor: async () => 'w-106', + hostIdFor: async () => 'w-2', agentFor: () => undefined, ensureHost: async () => { throw new Error('pg is down') diff --git a/web/wake.html b/web/wake.html index 682a13f..118cc9e 100644 --- a/web/wake.html +++ b/web/wake.html @@ -89,9 +89,22 @@ show(I18N.t('wake.failed')) } - // 只允许跳回 *.ai1net.com,避免被 next 参数带偏 + // 只允许跳回**本站注册域**(运行时从 hostname 推导,⛔ 不写死域名)。 + // 规则:去掉最左一段即到注册域 —— `guest.example.com` ⇒ `example.com`。 + function registrableDomain() { + var h = location.hostname + if (!h || h === 'localhost' || /^\d+(\.\d+){3}$/.test(h)) return h + var parts = h.split('.') + return parts.length > 2 ? parts.slice(1).join('.') : h + } function safeNext(u) { - try { var x = new URL(u, location.href); return /(^|\.)ai1net\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' } + try { + var x = new URL(u, location.href) + var d = registrableDomain() + if (!d) return '' + var hop = '.' + d + return x.hostname === d || x.hostname.slice(-hop.length) === hop ? x.href : '' + } catch (e) { return '' } } async function wake() {