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

+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
}