2026-09-18 16:37:09 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 覆盖网络 **管理面 API**(序㊵ · P2/S5 一并做 —— 该文件是 P1 的出口项)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## 它回答两个问题
|
|
|
|
|
|
* | 端点 | 作用 | 鉴权 |
|
|
|
|
|
|
* |---|---|---|
|
|
|
|
|
|
* | `GET /api/admin/overlay-nodes` | **有哪些网 / 每个节点什么状态**(只读汇总) | `requireAdmin` |
|
|
|
|
|
|
* | `GET /api/admin/overlay-nodes/direct` | 本机**直连开关**现状 + **三段提示** | `requireAdmin` |
|
|
|
|
|
|
* | `POST /api/admin/overlay-nodes/direct` | **用户可设置**(口径①)—— 只改本机配置的 `direct` 字段 | `requireAdmin` |
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## 🔴 三条纪律
|
|
|
|
|
|
* ① **全部走 `requireAdmin`** —— ⛔ 不做第二个无鉴权端点:既有的 `GET /dshs-overlay/bootstrap`
|
|
|
|
|
|
* 之所以能无鉴权,是因为它**只服务还没有凭据的新节点**且内容被收窄到"去哪儿";
|
|
|
|
|
|
* "**有哪些节点**"是**拓扑信息**,放公网 = 扩大暴露面(命中 **R5**)。
|
|
|
|
|
|
* ② **只写"跟着这台机器走"的那一项** —— 直连开关放**本机配置**(`<NODE_CONFIG_FILE>.direct`),
|
|
|
|
|
|
* ⛔ 不写共享 drop-in / env:那属于控制面或运维的通道(见 `direct/index.ts#resolveDirectSwitch`
|
|
|
|
|
|
* 的优先级口径:env > 本机配置 > 缺省)。
|
|
|
|
|
|
* ③ **失败具名** —— 文件不存在 / 形状坏 / 写不进去,各自一个原因码,⛔ 不吞、⛔ 不静默创建。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @module dshs/web/routes/overlay-nodes
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
|
|
|
|
|
|
|
|
|
|
import type { FastifyPluginAsync } from 'fastify'
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
DIRECT_ENV_KEY,
|
|
|
|
|
|
DEFAULT_DIRECT_ENABLED,
|
|
|
|
|
|
NODE_CONFIG_FILE_DEFAULT,
|
|
|
|
|
|
directFromNodeConfig,
|
|
|
|
|
|
directHintLines,
|
|
|
|
|
|
resolveDirectSwitch,
|
|
|
|
|
|
writeNodeConfigDirect,
|
|
|
|
|
|
} from '../../net/relay/direct/index.js'
|
|
|
|
|
|
import { loadRegistry, summarizeNetworks, listNodes } from '../../net/relay/registry.js'
|
|
|
|
|
|
import { requireAdmin } from '../middleware/authn.js'
|
2026-09-19 15:12:19 +08:00
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
|
import { dataRootDir } from '../../platform-paths.js'
|
2026-09-18 16:37:09 +08:00
|
|
|
|
|
|
|
|
|
|
/** 本机配置落点(`DSHS_OVERLAY_NODE_CONFIG` 可覆盖;缺省与 `join` 的 `--config` 一致)。 */
|
|
|
|
|
|
function nodeConfigFile(): string {
|
|
|
|
|
|
const v = process.env.DSHS_OVERLAY_NODE_CONFIG
|
|
|
|
|
|
return typeof v === 'string' && v.trim() !== '' ? v.trim() : NODE_CONFIG_FILE_DEFAULT
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 注册表落点(`DSHS_OVERLAY_NODES_FILE` 可覆盖;缺省 = 参数表 `NODES_REGISTRY_FILE`)。 */
|
|
|
|
|
|
function registryFile(): string {
|
|
|
|
|
|
const v = process.env.DSHS_OVERLAY_NODES_FILE
|
2026-09-19 15:12:19 +08:00
|
|
|
|
return typeof v === 'string' && v.trim() !== '' ? v.trim() : join(dataRootDir(), 'overlay', 'nodes.json')
|
2026-09-18 16:37:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fss = {
|
|
|
|
|
|
exists: (p: string) => existsSync(p),
|
|
|
|
|
|
read: (p: string) => readFileSync(p, 'utf8'),
|
|
|
|
|
|
write: (p: string, text: string, mode: number) => writeFileSync(p, text, { mode }),
|
|
|
|
|
|
rename: (from: string, to: string) => renameSync(from, to),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 读本机配置里的 `direct`(**缺文件 ⇒ `undefined`**,形状坏 ⇒ 抛给调用方具名化)。 */
|
|
|
|
|
|
function readLocalDirect(): { file: string; present: boolean; direct?: boolean } {
|
|
|
|
|
|
const file = nodeConfigFile()
|
|
|
|
|
|
if (!existsSync(file)) return { file, present: false }
|
|
|
|
|
|
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
|
|
|
|
|
return { file, present: true, direct: directFromNodeConfig(raw) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const overlayNodeRoutes: FastifyPluginAsync = async (app) => {
|
|
|
|
|
|
/** 只读汇总:有哪些网 / 每张网几个 approved、几个 pending / 节点清单。 */
|
|
|
|
|
|
app.get('/api/admin/overlay-nodes', { preHandler: requireAdmin }, async (_request, reply) => {
|
|
|
|
|
|
const file = registryFile()
|
|
|
|
|
|
if (!existsSync(file)) {
|
|
|
|
|
|
// "这套准入还没开始用"是**合法状态** ⇒ 200 + 明确的 `present:false`(⛔ 不是 500,也不是空 200)
|
|
|
|
|
|
return reply.send({ registry: { file, present: false }, networks: [], nodes: [] })
|
|
|
|
|
|
}
|
|
|
|
|
|
let reg
|
|
|
|
|
|
try {
|
|
|
|
|
|
reg = loadRegistry(file)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return reply.code(500).send({ error: 'registry-unreadable', detail: err instanceof Error ? err.message : String(err) })
|
|
|
|
|
|
}
|
|
|
|
|
|
return reply.send({
|
|
|
|
|
|
registry: { file, present: true, version: reg.version },
|
|
|
|
|
|
networks: summarizeNetworks(reg),
|
|
|
|
|
|
nodes: listNodes(reg).map((n) => ({
|
|
|
|
|
|
network: n.network,
|
|
|
|
|
|
hostId: n.hostId,
|
|
|
|
|
|
status: n.status,
|
|
|
|
|
|
group: n.group,
|
|
|
|
|
|
appliedAt: n.appliedAt,
|
|
|
|
|
|
approvedAt: n.approvedAt,
|
|
|
|
|
|
})),
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/** 直连开关现状(含**三段提示** —— 用户口径③的"设置面显示"落点)。 */
|
|
|
|
|
|
app.get('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (_request, reply) => {
|
|
|
|
|
|
let local: { file: string; present: boolean; direct?: boolean }
|
|
|
|
|
|
try {
|
|
|
|
|
|
local = readLocalDirect()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return reply.code(500).send({ error: 'node-config-bad', detail: err instanceof Error ? err.message : String(err) })
|
|
|
|
|
|
}
|
|
|
|
|
|
const state = resolveDirectSwitch(process.env, { localDirect: local.direct })
|
|
|
|
|
|
return reply.send({
|
|
|
|
|
|
envKey: DIRECT_ENV_KEY,
|
|
|
|
|
|
defaultEnabled: DEFAULT_DIRECT_ENABLED,
|
|
|
|
|
|
effective: state.enabled,
|
|
|
|
|
|
source: state.source,
|
|
|
|
|
|
raw: state.raw,
|
|
|
|
|
|
invalid: state.invalid,
|
|
|
|
|
|
nodeConfig: { file: local.file, present: local.present, direct: local.direct ?? null },
|
|
|
|
|
|
hint: directHintLines(),
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置直连开关(**用户可设置**)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⛔ 只动本机配置的 `direct`;⚠️ 若 env 里显式设了 `DSHS_OVERLAY_DIRECT`,它会**压过**本项
|
|
|
|
|
|
* ⇒ 回执里把这件事**明说**(否则用户会以为"改了没生效 = 平台坏了")。
|
|
|
|
|
|
*/
|
|
|
|
|
|
app.post('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (request, reply) => {
|
|
|
|
|
|
const body = request.body as { enabled?: unknown } | undefined
|
|
|
|
|
|
if (body === undefined || typeof body.enabled !== 'boolean') {
|
|
|
|
|
|
return reply.code(400).send({ error: 'bad-body', detail: 'body 须为 {enabled: boolean}' })
|
|
|
|
|
|
}
|
|
|
|
|
|
const outcome = writeNodeConfigDirect(nodeConfigFile(), body.enabled, fss)
|
|
|
|
|
|
if (!outcome.ok) return reply.code(400).send({ error: outcome.reason, detail: outcome.detail })
|
|
|
|
|
|
const state = resolveDirectSwitch(process.env, { localDirect: outcome.direct })
|
|
|
|
|
|
return reply.send({
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
file: outcome.file,
|
|
|
|
|
|
direct: outcome.direct,
|
|
|
|
|
|
effective: state.enabled,
|
|
|
|
|
|
source: state.source,
|
|
|
|
|
|
envOverrides: state.source === 'env',
|
|
|
|
|
|
note:
|
|
|
|
|
|
state.source === 'env'
|
|
|
|
|
|
? `⚠️ 本机 env 里已显式设了 ${DIRECT_ENV_KEY}=${state.raw} ⇒ 它**压过**本次设置(env > 本机配置 > 缺省)`
|
|
|
|
|
|
: '已生效(重启节点服务后按本文件生效)',
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|