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:
1 parent
9c2e7975ac
commit
452924d89c
100 files changed
+1167
-453
No files matched your search
+36
-8
@@ -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
@@ -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
@@ -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>` 建会话、且
|
||||
|
||||
@@ -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` 上,断言"没打默认机")。
|
||||
*
|
||||
|
||||
@@ -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,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"更难排查)。
|
||||
*
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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`)。
|
||||
*/
|
||||
|
||||
@@ -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://` 写法)。
|
||||
*/
|
||||
|
||||
@@ -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 非法就抛**。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`,**旧配置照旧可用**。
|
||||
|
||||
@@ -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 集合`,
|
||||
|
||||
@@ -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,与另两个实现一致)。
|
||||
|
||||
@@ -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`…),
|
||||
|
||||
@@ -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)** ⇒
|
||||
* 链虽然"不再退化成单点",却依然**换不过去**。
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 对账节拍**。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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
@@ -43,7 +43,7 @@ export interface VerificationMail {
|
||||
to: string
|
||||
code: string
|
||||
ttlMinutes: number
|
||||
/** 展示给收件人的站点名(如 `AI1NET`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
|
||||
/** 展示给收件人的站点名(如 `EXAMPLE`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
|
||||
brand?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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]> = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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` 复用既有条目、只换绑定会话,⛔ 不新开口、⛔ 不动白名单)。
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user