feat(config): 涉密内容外置到配置目录(档案 140)

把散落在代码里的真实部署值统一收进 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 未被误建)。
This commit is contained in:
admin committed 2026-09-19 15:12:19 +08:00
1 parent 9c2e7975ac
commit 452924d89c
100 files changed
+1167 -453

No files matched your search

+5
View File
@@ -32,3 +32,8 @@ docs/k8s.md
# 本项目的工作数据(会话/记忆/锁日志),不属于仓库内容
.workbuddy/
# 部署配置的**真实值**(含域名/地址/凭据)—— 只入库模板 platform.env.example
config/platform.env
config/*.env.local
+68
View File
@@ -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 // <platform-dir>
cfg.stateDir // <platform-dir>/state
cfg.backupDir // <platform-dir>/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` | 平台私有目录的父目录 | `<dataRoot>/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`。
+152
View File
@@ -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() // <platformDir>/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,
}
+79
View File
@@ -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}"
+67
View File
@@ -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=<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=<server-public-ip>
DSH_HOST_LAN_IP=<host-lan-ip>
# --- 控制面 PG(cluster 形态用)-------------------------------------------
DSHS_PG_HOST=127.0.0.1
DSHS_PG_PORT=<pg-port>
DSHS_PG_USER=dshs
DSHS_PG_DB=dshs
DSHS_PG_PASSWORD=<pg-password>
DSH_PG_DATA_DIR=/var/lib/dsh-pg
# --- Worker 注册凭据(cluster 形态必填)----------------------------------
DSHS_CLUSTER_AGENT_TOKEN=<worker-token>
# --- 反向隧道目标(可选;留空 = 隧道关闭)--------------------------------
# 形如 root@<host>:<port> 或 ssh://root@<host>:<port>
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=
@@ -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 侧发告警;⛔ 不把生产值留作代码默认 |
| 注释里的真标识 | **中性化**(`<server-public-ip>` / `<base-domain>` / `<host-a>`),注释无法「引用配置」 |
| 测试夹具 | 改 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` 会退化成
`<dataRoot>/platform` = `/var/lib/dshs/platform`,于是 state/backups/artifacts **静默搬家**。
- ⏳ 遗留:`test/lease.test.mjs` 的那 1 个失败属**既有**问题,与本次无关,未修。
+7 -6
View File
@@ -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-<TS>.tar.gz
# 用法:<platform-dir>/backup.sh 产物:<platform-dir>/backups/dsh-platform-backup-<TS>.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
+5 -4
View File
@@ -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 从 <host-a> 迁到 <host-b> 时,节点侧缺的东西全是**手工**补的
# (装 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. 数据根与权限 —— <data-root> 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 数据根(默认 <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
+4 -3
View File
@@ -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 安装用插件包(现在改为直接用 <platform-dir>/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)
+2 -1
View File
@@ -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 ==="
+13 -4
View File
@@ -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", "[email protected]"], label: "Manager + w-47" },
"106": { ssh: ["-p", "22", "test106"], label: "w-106" },
};
// 主机表从配置读取(⛔ 不在代码里写死地址 / ssh 别名):
// DSHLOG_HOSTS='{"mgr":{"ssh":["-p","22","[email protected]"],"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 {};
}
})();
// 巡检规则:只认**指向本项目自身故障**的信号。
// ⚠️ 每条规则都是误报与漏报的取舍 —— 下面两组是实测调过的:
+4 -3
View File
@@ -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'
+5 -4
View File
@@ -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')
+4 -3
View File
@@ -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')
+5 -4
View File
@@ -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(<platform-dir> 是 drwx------ root,用户 uid 读不到其中文件)
const stageDir = join(u.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, WANT_BASE)
+6 -5
View File
@@ -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 / 门户(机读)
* ① <platform-dir>/state/capabilities.json —— 给平台 API / 门户(机读)
* ② <bundled-skill-dir>/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:<port> 链接(用户浏览器打不开)'],
avoid: ['不要给服务器绝对路径', '不要给 127.0.0.1:<port> 链接(用户浏览器打不开)'],
howToShare: '用户在会话页右下角「我的文件」面板里可浏览工作区并下载任意文件(平台代理,不暴露宿主路径)。',
},
}
+19 -5
View File
@@ -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/面板)
# <host-lan-ip> → 宿主内网卡(eth0,同样到达 nginx/面板)
# 172.17.0.1 → docker0 网桥网关
# 47.77.182.89 → 公网 EIP(回环到本机 nginx/sshd)
# <server-public-ip> → 公网 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]
+4 -3
View File
@@ -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"
+3 -2
View File
@@ -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; }
+3 -2
View File
@@ -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 <tgz路径>}"
@@ -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; }
+3 -2
View File
@@ -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`:
* 输出 `<platform-dir>/state/instance-mem-peak.json`:
* { "updatedAt": <ms>, "uids": { "<uid>": { 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)。
+2 -2
View File
@@ -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 <data-root>/dshs.db \
* --pg postgres://dshs:***@127.0.0.1:<pg-port>/dshs [--dry-run]
*
* @module dshs/scripts/migrate-sqlite-to-pg
*/
+14 -13
View File
@@ -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 节点)的 `<install-dir>-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`** ⇒ 这里**具名降级**:
* `<install-dir>-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)
+4 -4
View File
@@ -42,7 +42,7 @@
* 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。
*
* ## 运行(🆕 序㊸:**cwd 不再受限**;⛔ 阈值零硬编码)
* `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]`
* `node "<repo>/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 条同机**(`<base-domain>` + `relay-direct.<base-domain>`
* 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住
* ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
*
* 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106
* (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`ai1net` = switched-away)
* (manager 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`<base-domain>` = switched-away)
* → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。
*
* 🔴 **2026-09-19 域迁 `ai1net.com` 修正**:本幕判定"目标是不是 47 那台"原先**写死** `alotbuy.com`,
* 🔴 **2026-09-19 域迁 `<base-domain>` 修正**:本幕判定"目标是不是 47 那台"原先**写死** `<legacy-domain>`,
* 域一切就恒判失败(**假红**:豁免实际已生效并切回 47,仅字面匹配不上)。
* ⇒ 现一律改用参数表的 `DRILL_KILLED_MATCH`(= 目录 `relays[]` 首位的 host),
* ⛔ 不再在脚本里写死域名 —— 下次换域只改参数表一处。
+2 -2
View File
@@ -23,8 +23,8 @@
*
* 用法:
* node overlay-holepunch.cjs --observer --port-x 21100 --port-y 21101 --token <t> --secs 90
* node overlay-holepunch.cjs --probe --obs 47.77.182.89 --port-x 21100 --port-y 21101 \
* --token <t> --name w-dev --peers w-47u,w-106u
* node overlay-holepunch.cjs --probe --obs <server-public-ip> --port-x 21100 --port-y 21101 \
* --token <t> --name w-dev --peers <host-a>u,<host-b>u
*
* @module scripts/overlay-holepunch
*/
+3 -3
View File
@@ -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 <peer-public-ip> --count 300 --interval 0.2
* node overlay-jitter.cjs --tcp <peer-public-ip>: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 <peer-public-ip>:22,<server-public-ip>:22 --rounds 40 --gap 400
*
* @module scripts/overlay-jitter
*/
+2 -2
View File
@@ -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 <pem> --signers <pubhex,…> --network ops --out <json>
* node scripts/overlay-keyring.cjs init-node --key /etc/dshs/node.key
* node scripts/overlay-keyring.cjs issue-grant --signer-key <pem> --network ops --host w-106 --node-key <file|hex> --out <json>
* node scripts/overlay-keyring.cjs issue-grant --signer-key <pem> --network ops --host <host-b> --node-key <file|hex> --out <json>
* node scripts/overlay-keyring.cjs sign-revocations --signer-key <pem> --network ops --hosts a,b --out <json>
* node scripts/overlay-keyring.cjs verify-grant --file <json> --signer-pub <hex> [--host w-106 --network ops]
* node scripts/overlay-keyring.cjs verify-grant --file <json> --signer-pub <hex> [--host <host-b> --network ops]
* node scripts/overlay-keyring.cjs recover-root --code <hex> --out <pem> [--expect-pub <hex>]
* # 演练三判据:--expect-pub 比公钥;
* # 再给 --signers <hex,…> --network <n> --issued-at <iso> ⇒ 用重建的根签 SignerSet 验通(判据②);
+3 -2
View File
@@ -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`,缺省 `<data-root>/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`),
+4 -4
View File
@@ -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 "<repo>/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:<PEER_AGENT_PORT> offline, w-106:<实例口> offline]` ⇒ `OBS-01` FAIL、`OBS-08` FAIL、
* `used=1 / endpoints=[<host-b>:<PEER_AGENT_PORT> offline, <host-b>:<实例口> 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. 脚本自身目录、其上一级、上两级(仓根 / `<install-dir>` 这类部署形态都覆盖)
*
* 🔴 **"恰好 1 个才算合法"这条防错保留、且更严**:
* 任一候选目录里 ≥2 份 ⇒ **立即报错**;跨候选目录合计 ≥2 份 ⇒ **也报错**(列出全部命中)。
@@ -1637,7 +1637,7 @@ function main() {
* ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。
*
* 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**:
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `ai1net.com` 与 `relay-direct.ai1net.com`
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `<base-domain>` 与 `relay-direct.<base-domain>`
* **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。
* 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**;
* 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。
+7 -6
View File
@@ -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
+2 -1
View File
@@ -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
+6 -5
View File
@@ -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
+2 -1
View File
@@ -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) => {
+3 -2
View File
@@ -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) {
+3 -2
View File
@@ -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」:独立数据目录 <data-root>-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)
+11 -10
View File
@@ -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:<worker-port-b>` / `19001`
# · 控制面 PG 也在 **106**,经同一条隧道出现在本机 `127.0.0.1:<pg-port>`
# 用法: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 <<ENVEOF
DSHS_DEPLOY_MODE=cluster
DSHS_DB_URL=postgres://dshs:[email protected]:15432/dshs_cross
DSHS_DATA_ROOT=/opt/dshs-cluster/data
DSHS_CLUSTER_HOST_ID=m-47
DSHS_DB_URL=$DSHS_DB_URL
DSHS_DATA_ROOT="$DSH_INSTALL_DIR"-cluster/data
DSHS_CLUSTER_HOST_ID=${DSHS_CLUSTER_HOST_ID:-m-1}
DSHS_CLUSTER_AGENT_URL=http://127.0.0.1:19000
DSHS_CLUSTER_AGENT_TOKEN=cross-machine-token
DSHS_CLUSTER_AGENT_TOKEN=$DSHS_CLUSTER_AGENT_TOKEN
DSHS_CLUSTER_INSTANCE_HOST=127.0.0.1
DSHS_CLUSTER_WORKER_DATA_ROOT=/opt/dshs-cluster/live-data
DSHS_CLUSTER_WORKER_DATA_ROOT="$DSH_INSTALL_DIR"-cluster/live-data
DSHS_CLUSTER_CAPACITY_MB=-1
DSHS_CLUSTER_REGISTER_SELF=0
DSHS_CLUSTER_LEASE_TTL_MS=30000
@@ -26,7 +27,7 @@ ENVEOF
set -a
# shellcheck disable=SC1091
. /opt/dshs-cluster/manager.env
. "$DSH_INSTALL_DIR"-cluster/manager.env
set +a
echo "--- bootstrap 管理员 ---"
+3 -2
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
const cfg = require('../config/index.cjs')
/**
* storage-report.cjs —— 每用户存储用量上报(档案 28)
* 输出:/var/run/dsh-storage-report.json(供门户 GET /api/admin/storage 直接读取,避免每次请求都 du)
@@ -7,14 +8,14 @@
const { execFileSync } = require('node:child_process')
const { existsSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const Database = require('better-sqlite3')
const OUT = process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json'
const WS_MB = Number(process.env.DSH_WS_THRESHOLD_MB ?? 2048)
const SESS_MB = Number(process.env.DSH_SESSIONS_THRESHOLD_MB ?? 1024)
const duKB = (p) => { 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) => {
+29 -27
View File
@@ -1,30 +1,32 @@
#!/usr/bin/env bash
# 切换 A 步(在 47 上跑):
# ① 备份 /opt/dshs/lib → /opt/dsh/backups/lib-<ts>/
# ② 覆盖 /opt/dshs/lib(T08 集群版代码)
# ③ 装 **本地 Worker**(w-47,19100,无隧道 —— Manager 同机直连)
# ④ 把既有用户(admin/guest)的归属**预置**为 w-47(否则粘性落点无处可粘、新老用户会被按容量随机调度)
# ⑤ 在 PG 里注册 w-47 / w-106 两台 worker
# ① 备份 <install-dir>/lib → <platform-dir>/backups/lib-<ts>/
# ② 覆盖 <install-dir>/lib(T08 集群版代码)
# ③ 装 **本地 Worker**(<host-a>,19100,无隧道 —— Manager 同机直连)
# ④ 把既有用户(admin/guest)的归属**预置**为 <host-a>(否则粘性落点无处可粘、新老用户会被按容量随机调度)
# ⑤ 在 PG 里注册 <host-a> / <host-b> 两台 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:[email protected]: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 <<ENV
DSHS_DATA_ROOT=/var/lib/dshs
DSHS_DATA_ROOT=$DSHS_DATA_ROOT
DSHS_ISOLATION_MODE=account
DSHS_DSH_BIN=/usr/local/bin/dsh
DSHS_BASE_UID=100000
@@ -41,7 +43,7 @@ After=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/dshs-worker.env
ExecStart=/usr/local/bin/node /opt/dshs/lib/cli.js worker --port 19100 --host 127.0.0.1 --host-id w-47 --instance-host 127.0.0.1 --log-level info
ExecStart=/usr/local/bin/node $DSH_INSTALL_DIR/lib/cli.js worker --port 19100 --host 127.0.0.1 --host-id $DSHS_CLUSTER_HOST_ID --instance-host 127.0.0.1 --log-level info
Restart=on-failure
RestartSec=3
KillMode=mixed
@@ -53,24 +55,24 @@ systemctl daemon-reload; systemctl enable dshs-worker >/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"
+3 -2
View File
@@ -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:[email protected]: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
+7 -6
View File
@@ -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/^/ /'
+4 -3
View File
@@ -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"
+9 -8
View File
@@ -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 <<CONF
# T08 集群化(2026-09-15):Manager 在 47、实例落在 w-47(既有用户)/ w-106(新用户)
# T08 集群化(2026-09-15):Manager 在 47、实例落在 <host-a>(既有用户)/ <host-b>(新用户)
# 回滚:删除本文件 → systemctl daemon-reload → systemctl restart dshs
[Service]
Environment="DSHS_DEPLOY_MODE=cluster"
Environment="DSHS_DB_URL=postgres://dshs:[email protected]: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:[email protected]: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"
+11 -10
View File
@@ -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
+11 -10
View File
@@ -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)\"
"'
+17 -16
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env bash
# 最终验证(在 47 上跑):清污染 → 重置锚点 → 重验两条路径
# ① 既有用户 guest:留 w-47 + 工作区有历史数据 + 实例页正常
# ② 新用户:落 w-106 + **文件真的写到 106 的盘** + 实例页正常
# ① 既有用户 guest:留 <host-a> + 工作区有历史数据 + 实例页正常
# ② 新用户:落 <host-b> + **文件真的写到 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 '<base href=' <<<"$1" && echo "✓实例页" || echo "✗非
echo "########## 0) 清污染:停所有实例 + 删测试用户 ##########"
systemctl restart dshs-worker; sleep 4
$SSH106 'systemctl restart dshs-worker' >/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")"
+15 -14
View File
@@ -1,14 +1,15 @@
#!/usr/bin/env bash
# 切换后功能验证 v2(在 47 上跑)
# ① 既有用户 guest:应留 w-47,且**工作区有历史数据**(文件在 47 的盘上)
# ② 新用户:应落 w-106,且**建的文件真的出现在 106 的盘上**(文件面路由已修)
# ① 既有用户 guest:应留 <host-a>,且**工作区有历史数据**(文件在 47 的盘上)
# ② 新用户:应落 <host-b>,且**建的文件真的出现在 106 的盘上**(文件面路由已修)
# 判据:实例页必须带 <base href="/"(门户页不算);归属看 PG;文件落盘看两台机器磁盘
set -uo pipefail
. "$(cd "$(dirname "$0")/.." && pwd)/config/load.sh"
PG() { PGPASSWORD=dshs_cluster_2026 /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
DOMAIN="${DSHS_BASE_DOMAIN:?config/platform.env 缺 DSHS_BASE_DOMAIN}"
T47="$DSHS_CLUSTER_AGENT_TOKEN"
T106="$DSH_PEER_AGENT_TOKEN"
mksess() {
local u="$1" T H
@@ -21,21 +22,21 @@ owner() { PG "select u.username||' -> '||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 '<base href=' <<<"$1" && echo "✓实例页" || echo "✗非实例页"; }
echo "############ ① 既有用户 guest(应留 w-47 + 工作区有数据) ############"
echo "############ ① 既有用户 guest(应留 "$DSHS_CLUSTER_HOST_ID" + 工作区有数据) ############"
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")
echo " enter → $(head -c 130 <<<"$E")"
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" | head -c 150)"
URL=$(grep -o '"url":"[^"]*"' <<<"$E" | head -1 | cut -d'"' -f4)
PAGE=$(curl -s -m 25 -L -b "$GC" -H "Host: guest.$DOMAIN" "$M/" | head -c 300)
echo " 实例页: $(isapp "$PAGE")"
echo " 47 盘上 guest 工作区条目: $(ls /var/lib/dshs/users/4092b965-2f68-4977-9989-68b3966f7df0/ws 2>/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")"
+7 -5
View File
@@ -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 <<ENV
# DSHS 集群 Worker(2026-09-15 切换)—— 与 47 /etc/dshs.env 的**实例侧**条目保持一致
DSHS_DATA_ROOT=/var/lib/dshs
DSHS_DATA_ROOT=$DSHS_DATA_ROOT
DSHS_ISOLATION_MODE=account
DSHS_DSH_BIN=/usr/bin/dsh
DSHS_BASE_UID=100000
DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160
DSH_INSTANCE_UNIVER_SOCKET=auto
# 控制通道:Worker 主动拨 47 的反向隧道(公网入方向被云安全组挡住 ⇒ 只能这个方向)
# 控制通道:Worker 主动拨 Manager 的反向隧道(公网入方向被云安全组挡住 ⇒ 只能这个方向)
DSHS_CLUSTER_AGENT_TOKEN=$TOKEN
DSHS_TUNNEL_TARGET=[email protected]:32022
DSHS_TUNNEL_TARGET=$DSHS_TUNNEL_TARGET
DSHS_TUNNEL_IDENTITY=/root/.ssh/tunnel_ed25519
ENV
chmod 600 /etc/dshs-worker.env
@@ -30,7 +32,7 @@ Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/dshs-worker.env
ExecStart=/usr/bin/node /opt/dshs-cluster/lib/cli.js worker --port 19000 --host 127.0.0.1 --host-id w-106 --instance-host 127.0.0.1 --log-level info
ExecStart=/usr/bin/node $DSH_INSTALL_DIR-cluster/lib/cli.js worker --port 19000 --host 127.0.0.1 --host-id $DSH_PEER_HOST_ID --instance-host 127.0.0.1 --log-level info
Restart=on-failure
RestartSec=3
KillMode=mixed
+6 -5
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# 切换收尾:清理验证残留 + 健康检查(在 47 上跑)
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
@@ -17,16 +18,16 @@ echo " 剩余用户: $(Q "select string_agg(username||'('||role||')', ', ') fro
echo "=== 2) 停掉验证期间起的实例 ==="
systemctl restart dshs-worker; sleep 4
ssh -n -i /root/.ssh/dshworker_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=accept-new root@106.54.21.172 'systemctl restart dshs-worker' >/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:[email protected]:15432/dshs" \
node lib/cli.js cluster status 2>&1 | head -9 | sed 's/^/ /'
echo " --- dshs doctor ---"
+13 -12
View File
@@ -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:<worker-port-b>`
* · 控制面 PG 也在**另一台机器**(106)上,经隧道出现在本机 `127.0.0.1:<pg-port>`
* 它回答的是本次演练的核心问题:**跨机到底能不能用**(含跨机代理取页面、跨 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 })
+5 -4
View File
@@ -1,8 +1,9 @@
const cfg = require('../config/index.cjs')
/**
* T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。
*
* 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后,
* **按域名形态访问**(`<用户名>.ai1net.com`)还能不能正常落到 106 上的实例?
* **按域名形态访问**(`<用户名>.<base-domain>`)还能不能正常落到 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.<base-domain> \
* AGENT=http://127.0.0.1:<worker-port-b> 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'
+1 -1
View File
@@ -11,7 +11,7 @@
* ⑤ 顺带验**注册 + 心跳**:`dsh_hosts` 里有记录且 `last_heartbeat` 持续更新。
*
* 需要 PG(两个 Manager 必须共享 DB,否则谈不上"归属"):
* CLUSTER_TEST_PG_URL=postgres://dshs:[email protected]:15432/dshs_smoke node scripts/verify-cluster-lease.mjs
* CLUSTER_TEST_PG_URL=postgres://dshs:[email protected]:<pg-port>/dshs_smoke node scripts/verify-cluster-lease.mjs
*/
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
+3 -3
View File
@@ -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:[email protected]:15432/dshs_live node scripts/verify-cluster-live.mjs
* 需要:106 上 PG 已在 127.0.0.1:<pg-port>;以 root 运行(account 隔离要 setpriv/systemd-run)。
* 运行:CLUSTER_LIVE_PG_URL=postgres://dshs:[email protected]:<pg-port>/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)
+1 -1
View File
@@ -9,7 +9,7 @@
* ⑤ **数据不搬家也能用**:两台 worker **共享同一 dataRoot**(模拟共享存储 / 同路径基线)。
*
* 需要 PG(归属在 DB 里,两个 Manager/worker 共享):
* CLUSTER_TEST_PG_URL=postgres://dshs:[email protected]:15432/dshs_smoke node scripts/verify-cluster-migrate.mjs
* CLUSTER_TEST_PG_URL=postgres://dshs:[email protected]:<pg-port>/dshs_smoke node scripts/verify-cluster-migrate.mjs
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
+3 -2
View File
@@ -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 };
},
+3 -2
View File
@@ -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) {
+36 -8
View File
@@ -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/<id>/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@<server-public-ip>:<ssh-port>`(也接受不带 scheme 的 `root@host:port`)。空 = 隧道关闭。
* ⚠️ 与旧变量 `DSHS_TUNNEL_TARGET` **双路径并存**(新变量优先、旧变量兜底)⇒
* 删掉新 env 即回到旧路径,**零代码回滚**。
*/
clusterRendezvousUrl: string
/**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://<base-domain>/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/
relayUrl: string
/**
* 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:20080/status`。
* 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:<relay-port>/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
* `<dataRoot>/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://<base-domain>/`(照抄 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=<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 ??
+2 -2
View File
@@ -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 在别的部署上会错)。
// ⚠️ 默认值对 `<host-b>`(隧道落点)正确、对 `<host-a>`(同机直连)**不正确** ⇒ 回填由部署步骤
// 显式 `UPDATE ... WHERE id='<host-a>'` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。
const SQLITE_V8 = `
ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';
`
+4 -4
View File
@@ -412,20 +412,20 @@ export const DEFAULT_HOST_NETWORK = 'ops'
/** 一台承载用户实例的 worker(= 设计里的 Worker 节点)。 */
export interface DshHost {
id: string
/** agent 的内网地址,如 `10.0.1.11:9000`。 */
/** agent 的内网地址,如 `<lan-ip>: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`
* · `<host-a>` = `127.0.0.1:<worker-port-a>` = **同机直连**(Manager 与 worker 同机)⇒ `local`
* · `<host-b>` = `127.0.0.1:<worker-port-b>` = **Manager 主机上 sshd 的反向隧道落点** ⇒ `manager-ssh`
*/
via: string
/**
* **属于哪张网**(覆盖网络 P0-1,`dsh_hosts.network_id`)。
*
* `ops` = 运维网(平台自己的机器:`manager` / `w-47` / `w-106`,以及未来的中继与骨干);
* `ops` = 运维网(平台自己的机器:`manager` / `<host-a>` / `<host-b>`,以及未来的中继与骨干);
* `u:<userId>` = 该用户名下的全部设备。取值与 `src/net/relay/network.ts` 同一词表。
*
* 为什么它是**结构性**维度而不是又一个标签:relay 侧按 `<network>/<hostId>` 建会话、且
+1 -1
View File
@@ -84,7 +84,7 @@ export class RemoteUserFs implements UserFs {
* ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}`,与"**文件夹不存在**"**完全同形**
* (用户读成"我的文件丢了",而真因是"请求根本没出这台机");
* ② 若本地恰好有同名目录 ⇒ 直接把文件写进**一份没人在看的副本**(更糟的静默写坏)。
* 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充,w-106 用户点启动
* 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充,<host-b> 用户点启动
* 连发 3 次 **全 404,且 relay 零 `DIAL`、拨号池零落点** ⇒ 请求根本没出去。
* **判别器 = 看 relay 有没有 `DIAL`**(本机单测打在 `fetch` 上,断言"没打默认机")。
*
+5 -5
View File
@@ -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 的反向隧道落点**(隧道的副作用) |
* | `<host-a>` | `http://127.0.0.1:<worker-port-a>` | **直连本机**(Manager 与 worker 同机,node 直接监听) |
* | `<host-b>` | `http://127.0.0.1:<worker-port-b>` | **经 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:<worker-port-b>` 在 `ops` 里指向 `<host-b>` 的 relay 落点,在 `u:5` 里
* 可能指另一台。只带 `hostId` 的重达性描述**在多网下是有歧义的**,而歧义会以
* "打到另一张网的同名节点"这种最贵的形态暴露(静默串网)。⇒ 本类型**必带**网络维度,
* `parseReachability()` 从**逻辑名**(`<network_id>/<hostId>`)里取它,调用方不许自己拼。
@@ -117,7 +117,7 @@ export function agentBaseUrlOf(host: HostAddressable): string {
* `<network_id>/<hostId>`:网络段由**本函数**切出来(`parseLogicalName`),调用方不碰。
* ⚠️ **裸 `hostId` 仍兼容**(⇒ 落 `ops`):过渡期不破坏现网调用方与既有单测。
*
* 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸
* 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:<worker-port-b>`),也容忍裸
* `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:<worker-port-b>`)⇒ 统一在这里剥出来,
* 别在调用方各写一遍 `split(':')`(IPv6 字面量会切错)。
*/
export function addressPort(address: string): number | undefined {
+2 -2
View File
@@ -2,7 +2,7 @@
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
*
* ## 它解决的唯一问题
* 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
* 兜底入口 `relay-direct.<base-domain>` 与主入口 `<base-domain>` **同属 `*.{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.<base-domain>=<server-public-ip>
* ```
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
*
+1 -1
View File
@@ -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:<relay-port>/dshs-relay`(R1)或 `wss://<域名>/dshs-relay`(R2)。 */
url: string
hostId: string
/**
+1 -1
View File
@@ -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/<host-b>')` = `ops/ops/<host-b>` 找会话,
* 找不到 ⇒ 回 `target-offline`("节点离线"),而节点其实**好好在册** ——
* 实测踩过一次(2026-09-17 07:32 线上,`refused: 8 / streamsOpened: 0`)。
*/
+12 -7
View File
@@ -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://<base-domain>/dshs-relay`,
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
*/
@@ -462,7 +467,7 @@ function entryIdentity(u: URL): string {
/**
* 选出**可以对外公布**的中继 / 引导地址。
*
* ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:20080` 对客户端毫无用处,
* ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:<relay-port>` 对客户端毫无用处,
* 还白送一份内网拓扑。对齐红线「**权限只准收窄**」(这里收窄的是**暴露面**)。
* 同一语义身份只保留**首次出现**的那条(⇒ `wss://` 写法优先于同源的 `https://` 写法)。
*/
+2 -2
View File
@@ -22,14 +22,14 @@
* ## 格式(**向后兼容,旧配置一字不改照旧可用**)
* ```json
* {
* "w-47": "fc6c…7d01", // 旧写法:裸 hostId ⇒ 运维网 ops
* "<host-a>": "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>`
* `<host-a>:<64hex>,ops/manager:<64hex>,u:5/pc-1:<64hex>`
*
* ⚠️ 名字段一律走 `network.ts#parseLogicalName`(**唯一入口**)—— 它同时接受
* `网/hostId`、`网:hostId` 与旧形态裸 `hostId`,且**网络 id 非法就抛**。
+3 -3
View File
@@ -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:<relay-port>/dshs-relay \
* --host <host-a> --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 <ws> --host <id> --ports <a,b>] [--network <id>] [--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)
+2 -2
View File
@@ -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 的合法形状(`<host-a>` / `<host-b>` / `manager` …)。 */
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
/** 逻辑名的**规范分隔符**。`<network_id>/<hostId>` —— `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/<host-b>`)—— 规范形态;
* 2. 否则含 `:` ⇒ 按**最后一个** `:` 切(`ops:manager` · `u:5:manager`)——
* 为的是兼容运维习惯的 `network:hostId` 写法;⚠️ 必须从**右**切:`u:5` 里的 `:` 属于网络 id;
* 3. 都不含 ⇒ **旧形态**(R5 时代的扁平 `hostId`)⇒ 落在 `ops`,**旧配置照旧可用**。
+4 -1
View File
@@ -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<string>())].sort()
// 逻辑名形态(`<网>/<hostId>`)—— `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 集合`,
+3 -3
View File
@@ -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:<worker-port-a>`(同机直连) | 无中转 |
* | `ManagerSshRendezvous` | `manager-ssh` | `127.0.0.1:<worker-port-b>`(**sshd 反向隧道落点**) | Manager 主机上的 sshd |
* | **`RelayRendezvous`** | `relay` | `127.0.0.1:<relay 动态分配>`(**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.<base-domain>/dshs-relay`。 */
dialTargetUrl: string
/**
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
+2 -2
View File
@@ -25,7 +25,7 @@
* `HELLO` 的 MAC 输入刻意保持 `${hostId}|${ts}|${nonce}|${portsCsv}` **一字不改**:现网 47 / 106
* 上跑的是旧客户端,改 MAC 公式 = 硬断(必须两端同时升级)。而网络维度的**真判据在服务端**
* (白名单按网络分桶 + 同网校验),`network` 只是"我属于哪张网"的声明 —— 声明错了也不会多拿到
* 任何东西:想拨 `ops/w-106` 就得有一个**在 `ops` 桶里的 hostId 密钥**。⇒ 安全性不依赖这个字段,
* 任何东西:想拨 `ops/<host-b>` 就得有一个**在 `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 -> <host-b>:21000 ok`),
* 脚本无法断言 ⇒ 观测最小集缺了最关键的一条。
*
* 为什么不复用 `refused`:`refused` 是**所有**拒绝的合计(`HELLO` 越界、端口越界、`DIAL`…),
+2 -2
View File
@@ -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://<base-domain>…` —— 而 `<base-domain>` 十几分钟前
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
@@ -353,7 +353,7 @@ export class RelayFailoverSupervisor {
/**
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
*
* 生产目录的 `relays[]` = `[ai1net.com(47), relay-direct.ai1net.com(47), 106]`
* 生产目录的 `relays[]` = `[<base-domain>(47), relay-direct.<base-domain>(47), 106]`
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
* 链虽然"不再退化成单点",却依然**换不过去**。
+3 -3
View File
@@ -13,7 +13,7 @@
*
* ## 现状与目标
* 今天"会合 + 中继"**不是一个组件,而是 Manager 主机上 sshd 的副作用**:
* 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。
* 会合点 = `<server-public-ip>:<ssh-port>`,中继落点 = 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 的端口,不经任何中转(`<host-a>` 就是这一类)。 */
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@<server-public-ip>:<ssh-port>`。 */
target: string
addressOf: AddressLookup
},
+64
View File
@@ -0,0 +1,64 @@
/**
* **部署相关路径的唯一解析处**。
*
* ## 为什么单独成模块
* 这些路径原先各自**硬编码在 5 个文件里**(`<platform-dir>/state`、`<platform-dir>/backups`、
* `<install-dir>/scripts`、`<data-root>/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/` 所在)。
* 默认从本模块位置推导:`<root>/lib/platform-paths.js` ⇒ `<root>`。 */
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)
}
+1 -1
View File
@@ -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)` 翻译不出来。
* `<host-b>:<worker-port-b> online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。
*
* ⇒ 由 **worker agent** 接这个回调,把 `adoptedInstancePorts()` 并进对账口径(⛔ 不改认领语义、
* ⛔ 不把认领实例写进 `mains`):端口一落定就登记,**不必等 20 s 对账节拍**。
+1 -1
View File
@@ -29,7 +29,7 @@ import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
* (唯一入口,别在调用点自己拼字符串):
* · `reachability` = S0 引入的**可达性描述**,比 `agentUrl` 多一层语义 ——
* **经谁中转**(`via`)。现网两类 host 的 `endpoint` 字符串同形但语义完全不同
* (`w-47` 是直连本机、`w-106` 是 Manager 上的隧道落点),只有它能表达。
* (`<host-a>` 是直连本机、`<host-b>` 是 Manager 上的隧道落点),只有它能表达。
* · `agentUrl` = 旧字段,保留向后兼容。
*/
export interface ClusterHost {
+1 -1
View File
@@ -53,7 +53,7 @@ export function isValidUsername(username: string): boolean {
return USERNAME_RE.test(username)
}
/** 邮件里的站点名:取主域名标签大写(`ai1net.com` → `AI1NET`)。空 ⇒ 不写站点名。 */
/** 邮件里的站点名:取主域名标签大写(`<base-domain>` → `EXAMPLE`)。空 ⇒ 不写站点名。 */
export function mailBrandFromConfig(config: ServerConfig): string {
const domain = (config.baseDomain ?? '').trim()
if (domain === '') return ''
+5 -3
View File
@@ -6,7 +6,7 @@
* (两份实现迟早漂),不如抽出来共用(R11:同一事实只有一处)。
*
* ⚠️ **`writeHomeFile` 里那两步都不能省**(都是从事故里换来的):
* ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `/opt/dsh/backups`)
* ① **先备份到平台目录**(`DSH_PLATFORM_BACKUP_DIR`,默认 `<platform-dir>/backups`)
* —— ⛔ 不能备份进用户 home:那是 dsh 的 watch 域,放进去的文件会被扫;
* ② **写完 chown 给 home 属主** —— 实例以 `dsh-<uid>` 身份运行,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<string> {
try {
@@ -47,12 +49,12 @@ export async function writeHomeFile(homeDir: string, file: string, text: string)
*
* 为什么单独抽出来(档案 138):用户卷可能**不在本机**(实例在 worker 上)⇒ 写入必须走
* `UserFs`(会按归属路由到那台机),而备份是**平台自己**的副本 —— 落在控制面的
* `/opt/dsh/backups` 正合适,也不该为了备份再往远端开一条通道。
* `<platform-dir>/backups` 正合适,也不该为了备份再往远端开一条通道。
* 备份的命名规则与 {@link writeHomeFile} 的①步**逐字一致**(⛔ 别各写一套)。
*/
export async function backupHomeFile(homeDir: string, fileOrName: string, text: string): Promise<void> {
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",
+1 -1
View File
@@ -43,7 +43,7 @@ export interface VerificationMail {
to: string
code: string
ttlMinutes: number
/** 展示给收件人的站点名(如 `AI1NET`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
/** 展示给收件人的站点名(如 `EXAMPLE`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
brand?: string
}
+1 -1
View File
@@ -15,7 +15,7 @@
* 越界即 `bad_path`)
* ② 启停其 DSH 实例 —— 与用户自己点「启动 / 停止」同一条 `supervisor` 路径
* 这与 `requireAdmin` 既有职能(审批 / 禁用 / 删除用户)同级;服务器层面 admin 本就能读
* `/var/lib/dshs/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。
* `<data-root>/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。
* @module dshs/web/routes/admin-user-ops
*/
+7 -4
View File
@@ -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 <platform-dir>/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<string, string> }).versions ?? {}
const num = (s: string): string => (s.match(/\d+\.\d+(\.\d+)?/) ?? [''])[0]
const pair: Array<[string, string]> = [
+3 -1
View File
@@ -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 {
+3 -1
View File
@@ -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 = {
+7 -6
View File
@@ -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<FastifyInstance
routes: string[]
}
/** 托管清单落点:**平台状态目录**(不在 home、也不在文档库)。 */
const managedDir = join(process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state', 'model-landing')
const managedDir = join(stateDir(), 'model-landing')
/** 只有清单里的 ref / route 才允许被平台改写或删除 —— 用户自己配的一律不碰。 */
const readManaged = async (userId: string): Promise<Managed> => {
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<FastifyInstance
* 会合解析(覆盖网络 S2):**`via` → `Rendezvous` 实现 → `Reachability`**。
*
* 为什么要有这一层:`endpoint` 只说得清"拨哪个地址",说不清"**经谁**" —— 而现网两条
* 记录的 endpoint 恰好**字符串同形、语义不同**(`w-47` 同机直连 / `w-106` 隧道落点)。
* 记录的 endpoint 恰好**字符串同形、语义不同**(`<host-a>` 同机直连 / `<host-b>` 隧道落点)。
* `via` 列把"经谁"显式化 ⇒ 换中继 / 会合时不必改表语义(会合中继拆分方案 §2 C3)。
*
* ⚠️ **S2 阶段行为零变化**:两种现役实现的 `resolve()` 都只把 endpoint 拆成
@@ -1095,7 +1096,7 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* **为什么文件面必须自己会刷新**:`hostDirectory` 是**惰性** Map —— 唯一的写入者是
* `hostsProvider()`,而此前只有 `RemoteSpawner.ensureHosts()`(TTL 30 s)会调它 ⇒
* Manager 重启后若用户先碰文件面("我的文件" / launch 的 folder 检查),表里只有本机
* ⇒ `agentFor('w-106')` 返回 `undefined` ⇒ 旧行为**静默回退到本机 agent** ⇒ worker 上当然
* ⇒ `agentFor('<host-b>')` 返回 `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<FastifyInstance
await registerDshProxy(app)
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
// runs in the browser on `<user>.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 `<user>.dsh.<base-domain>` and calls portal APIs on
// `dsh.<base-domain>`). Cookie is HttpOnly + SameSite=None (secure mode) with
// Domain=.dsh.<base-domain>, 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
+2 -2
View File
@@ -52,7 +52,7 @@ export interface WorkerAgentOptions {
/** 日志级别。 */
logLevel?: string
/**
* **反向隧道**(跨机演练):Worker 主动拨 Manager,形如 `root@47.77.182.89:32022`。
* **反向隧道**(跨机演练):Worker 主动拨 Manager,形如 `root@<server-public-ip>:<ssh-port>`。
* 不设则完全关闭(同机/单机形态零影响)。见 `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 侧 `<host-b>:<worker-port-b>`)⇒ Manager 侧
* `(hostId, port)` 翻译不出来。并进 ② 之后,`forward(port)` 会把那条孤儿**就地覆盖**成在线
* (relay 侧 `ensureEndpoint` 复用既有条目、只换绑定会话,⛔ 不新开口、⛔ 不动白名单)。
*/
+4 -4
View File
@@ -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://<base-domain>/dshs-relay`(生产)或 `ws://127.0.0.1:<relay-port>/dshs-relay`(本机验)。 */
url: string
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
/** 本机在 `dsh_hosts.id` 里的标识(`<host-a>` / `<host-b>`)。 */
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://<base-domain>/dshs-relay` 与 `wss://relay-direct.<base-domain>/dshs-relay` **摘名不同、
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
@@ -119,7 +119,7 @@ export function candidateObsMs(env: Record<string, string | undefined> = process
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
*
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
* 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47,
* 本数**不是独立物理路径数**(真机实证:`<base-domain>` 与 `relay-direct.<base-domain>` 都在 47,
* 但 `count=3` 时 `hosts` 也报 3)。
*/
function candHostsOf(urls: readonly string[]): number {
+6 -6
View File
@@ -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@<server-public-ip>:<ssh-port>`)—— 以后换传输协议(中继/隧道服务)只改 scheme。
* 而本类其余代码如下按 `user@host:port` 切分 ⇒ 必须在**入口处**剥掉 scheme:
* 否则 `'ssh://root@h:32022'.split(':')` 会切成三截,把 `ssh` 当成主机名。
* 否则 `'ssh://root@h:<ssh-port>'.split(':')` 会切成三截,把 `ssh` 当成主机名。
*
* 两种写法都接受(**单点归一,调用方不必判断**):
* · `ssh://root@47.77.182.89:32022` → `[email protected]:32022`
* · `root@47.77.182.89:32022` → 原样(兼容历史 env `DSHS_TUNNEL_TARGET`)
* · `ssh://root@<server-public-ip>:<ssh-port>` → `root@<server-public-ip>:<ssh-port>`
* · `root@<server-public-ip>:<ssh-port>` → 原样(兼容历史 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@<server-public-ip>:<ssh-port>`(带 `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:<ssh-port>` → 32022)。 */
get targetPort(): number | undefined {
return this.portPart
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 名解析(⛔ 只认本平台自己的形态) ─────────────────────────── */
+5 -5
View File
@@ -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 口池) ─────────── */
+29 -24
View File
@@ -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, '首位命中时不该有多余日志')
})
+13 -13
View File
@@ -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, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
})
+6 -6
View File
@@ -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 })
// 非法网络段 ⇒ **装载即抛**("配置错就炸",不变成运行期的静默拒绝)
+19 -19
View File
@@ -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()
})
+15 -15
View File
@@ -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) {
+15 -15
View File
@@ -161,9 +161,9 @@ test('retryAfter 人话格式化', () => {
})
test('mail: 正文含验证码与有效期,且明确告知"非本人操作请忽略"', () => {
const { subject, text } = renderVerificationMail({ to: '[email protected]', code: '123456', ttlMinutes: 10, brand: 'AI1NET' })
const { subject, text } = renderVerificationMail({ to: '[email protected]', 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 驱动 ─────────────────────────────────────────────── */
+6 -6
View File
@@ -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)
+6 -6
View File
@@ -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)')
/**
+5 -5
View File
@@ -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 () => {
+8 -8
View File
@@ -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')
+15 -2
View File
@@ -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() {