aio-plugin-portal 0.1.0 → 0.2.1-dev.6.gd90568e1c0fd

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # 端口门户
2
2
 
3
- 把「别名 + 内网端口」登记成一条 `https://<别名>.<suffix>`:**内网**直接把该名字解析到本机
4
- (不经公网),**外网**仍按原隧道/代理访问,两条路互不影响。以后新增站点只改注册表,不用改代码。
3
+ 把「别名 + 内网端口」登记成一条 `https://<别名>.<suffix>`,并在**一套期望态**里同时驱动
4
+ DNS、反代、证书、访问策略四类能力。内网直达本机,公网按登记的策略决定是否可达。
5
5
 
6
6
  ## 安装与使用
7
7
 
@@ -14,13 +14,46 @@ aio-plugin-portal --help
14
14
  必填域名后缀,其余按需设置;样例见 `config/aio-portal.example.json`。
15
15
 
16
16
  ```sh
17
- aio-plugin-portal add tc --port 8111 --host 192.168.31.10 --note TeamCity
17
+ aio-plugin-portal add tc --port 8111 --host 127.0.0.1 --note TeamCity
18
+ aio-plugin-portal add only-public --port 9000 --visibility public --authorized-by alice
18
19
  aio-plugin-portal list
19
- aio-plugin-portal sync
20
+ aio-plugin-portal plan # 只出计划,不写任何东西
21
+ aio-plugin-portal sync # 渲染产物 + 执行 provider + 校验
22
+ aio-plugin-portal sync --apply # 允许 provider 真实写入
20
23
  aio-plugin-portal doctor
21
24
  aio-plugin-portal remove tc
22
25
  ```
23
26
 
27
+ ## 内网 / 公网边界
28
+
29
+ 每条登记都有 `visibility`,它是安全边界而不是展示字段:
30
+
31
+ - `internal`(默认):**永不产生公网 DNS 记录**;Caddy 站点同时要求 `bind <入口地址>` 与
32
+ `remote_ip` 来源白名单,未命中直接 `respond 404`。缺 `caddy.bind` 时 `verify` 判失败。
33
+ - `public`:必须同时满足 `allowPublicWrite: true`、有凭据、且登记了 `authorizedBy`,否则计划被标记
34
+ 为 `blocked`,`sync --apply` 拒绝执行。缺任意一项宁可不动,也不“先写上去再说”。
35
+
36
+ `blocked` 的计划一律不 apply;`verify` 重新读取远端/产物,不用本地计划冒充成功。
37
+ `sync` 只有在全部 provider 计划可执行且校验通过时才报告成功。
38
+
39
+ > 边界代价:只在经过本机 Caddy 的路径上成立。若同名域名在公网 DNS 或别的入口仍指向旧隧道,
40
+ > 那部分流量不归本机管。真正“仅内网”还要清掉对应公网记录,这一步本工具不会替你猜。
41
+
42
+ ## provider 抽象
43
+
44
+ 四类 provider 各自独立组合,登记项可用 `providers` 字段单条覆盖:
45
+
46
+ | 类别 | 可用值 | 说明 |
47
+ |---|---|---|
48
+ | `dns` | `local-dnsmasq`、`cloudflare`、`dnspod`(腾讯云)、`huawei`(华为云) | 本地只写解析片段;云厂商走 `list → create/update/noop` 幂等协调 |
49
+ | `connectivity` | `caddy`、`aio-tunnel`、`cloudflared` | `caddy` 生成站点片段;隧道类只给计划,要求由隧道侧真实校验 |
50
+ | `certificate` | `internal-ca`、`acme` | `internal-ca` 由本地 Caddy internal issuer 签发;`acme` 需外部可达,未授权即 blocked |
51
+ | `policy` | `lan-allowlist`、`aio-auth`、`public` | `lan-allowlist` 生成 `remote_ip` 白名单;`aio-auth` 交给应用自身 |
52
+
53
+ 云 DNS 凭据只从环境变量读取(`credentialsEnv` 指向变量名),不写进注册表或配置。
54
+ 腾讯云 DNSPod 用 `Record.List/Create/Modify`;华为云使用 Zone ID + IAM `X-Auth-Token`,
55
+ **长期 AK/SK 签名尚未实现**,未配置 token 时只会给出可审计的计划。
56
+
24
57
  ## 让整片局域网无感走内网
25
58
 
26
59
  客户端零配置的前提是**网关替所有设备做 DNS 覆盖**:把网关上游 DNS 指向本机解析器。
@@ -33,7 +66,12 @@ aio-plugin-portal warden --password '<网关密码>' --restore # 回滚
33
66
 
34
67
  - 默认只读,写入必须显式 `--apply`;网关改动会影响**所有**经它上网的设备。
35
68
  - 只想让个别机器走内网时,不动网关,改用客户端按域名分流(如 macOS `/etc/resolver/<suffix>`)。
36
- - 网关适配器通过配置选择;不含任何厂商地址、账号或密码。
69
+
70
+ ## 与配对机制的边界
71
+
72
+ “配对”不是本工具的职责。设备配对复用 `aio-plugin-device` 的一次性短时票据闭环
73
+ (签发 → 登录确认 → 轮询换取凭据 → 心跳/租约 → 可撤销)。本工具只负责把已经存在的
74
+ 目标端口登记成可达的站点,并把它的可见范围钉死在内网。
37
75
 
38
76
  发布与市场配置见 [AIO 自动发布说明](AIO.md)。
39
77
 
@@ -6,7 +6,11 @@
6
6
  "resolver": {
7
7
  "port": 53,
8
8
  "upstreams": ["223.5.5.5", "114.114.114.114"],
9
- "service": "aio-portal-dns"
9
+ "service": "aio-portal-dns",
10
+ "provider": "dnsmasq",
11
+ "fragment": true,
12
+ "configFile": "/etc/dnsmasq.d/intranet/20-aio-portal.conf",
13
+ "testCommand": ["dnsmasq", "--test"]
10
14
  },
11
15
  "portal": {
12
16
  "root": "/opt/aio-portal",
@@ -17,5 +21,42 @@
17
21
  "address": "192.168.1.1",
18
22
  "adapter": "xiaomi",
19
23
  "fallbackDns": "223.5.5.5"
24
+ },
25
+ "caddy": {
26
+ "sitesDir": "/opt/aio-portal/caddy/sites",
27
+ "prefix": "portal-",
28
+ "bind": "192.168.1.10",
29
+ "allow": ["192.168.1.0/24", "10.144.144.0/24"],
30
+ "denyStatus": 404,
31
+ "binary": "caddy"
32
+ },
33
+ "providers": {
34
+ "dns": "local-dnsmasq",
35
+ "connectivity": "caddy",
36
+ "certificate": "internal-ca",
37
+ "policy": "lan-allowlist",
38
+ "cloud": {
39
+ "cloudflare": {
40
+ "enabled": false,
41
+ "zone": "example.internal",
42
+ "ttl": 120,
43
+ "credentialsEnv": "CLOUDFLARE_API_TOKEN",
44
+ "allowPublicWrite": false
45
+ },
46
+ "dnspod": {
47
+ "enabled": false,
48
+ "zone": "example.internal",
49
+ "ttl": 120,
50
+ "credentialsEnv": "TENCENT_CLOUD_SECRET_ID",
51
+ "allowPublicWrite": false
52
+ },
53
+ "huawei": {
54
+ "enabled": false,
55
+ "zone": "example.internal",
56
+ "ttl": 120,
57
+ "credentialsEnv": "HUAWEI_CLOUD_TOKEN",
58
+ "allowPublicWrite": false
59
+ }
60
+ }
20
61
  }
21
62
  }
package/dist/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { parseArgs } from "node:util";
5
5
 
6
6
  // src/portal/commands.ts
7
- import { existsSync as existsSync3 } from "node:fs";
7
+ import { existsSync as existsSync4 } from "node:fs";
8
8
 
9
9
  // src/config/index.ts
10
10
  import { homedir } from "node:os";
@@ -15,14 +15,20 @@ var DEFAULTS = () => ({
15
15
  mode: "lan",
16
16
  lanIp: "",
17
17
  hostsFile: "/etc/hosts",
18
- resolver: { port: 53, upstreams: ["223.5.5.5", "114.114.114.114"] },
18
+ resolver: { port: 53, upstreams: ["223.5.5.5", "114.114.114.114"], provider: "dnsmasq" },
19
19
  portal: { root: join(homedir(), ".config", "aio-portal"), mapPrefix: "10-alias", reload: true },
20
- gateway: { adapter: "xiaomi", fallbackDns: "223.5.5.5" }
20
+ gateway: { adapter: "xiaomi", fallbackDns: "223.5.5.5" },
21
+ caddy: { prefix: "portal-", allow: ["127.0.0.1/32"], denyStatus: 404 },
22
+ providers: {
23
+ dns: "local-dnsmasq",
24
+ connectivity: "caddy",
25
+ certificate: "internal-ca",
26
+ policy: "lan-allowlist"
27
+ }
21
28
  });
22
29
  function loadSettings(rootOverride) {
23
30
  const base = DEFAULTS();
24
31
  const root = resolve(rootOverride ?? process.env.AIO_PORTAL_HOME ?? base.portal.root);
25
- base.portal.root = root;
26
32
  const configFile = join(root, "aio-portal.json");
27
33
  let file = {};
28
34
  if (existsSync(configFile)) file = JSON.parse(readFileSync(configFile, "utf8"));
@@ -31,20 +37,27 @@ function loadSettings(rootOverride) {
31
37
  ...file,
32
38
  resolver: { ...base.resolver, ...file.resolver },
33
39
  portal: { ...base.portal, ...file.portal, root },
34
- gateway: { ...base.gateway, ...file.gateway }
40
+ gateway: { ...base.gateway, ...file.gateway },
41
+ caddy: { ...base.caddy, ...file.caddy },
42
+ providers: { ...base.providers, ...file.providers }
35
43
  };
36
44
  if (!merged.suffix) throw new Error(`\u7F3A\u5C11\u57DF\u540D\u540E\u7F00\u914D\u7F6E\uFF0C\u8BF7\u5728 ${configFile} \u4E2D\u8BBE\u7F6E suffix`);
37
45
  merged.portal.root = root;
38
46
  merged.resolver.configFile ??= join(root, "dns", "aio-portal.conf");
39
47
  merged.portal.aliasesDir ??= join(root, "nginx", "aliases.d");
48
+ if (merged.caddy && !merged.caddy.sitesDir) merged.caddy.sitesDir = join(root, "caddy", "sites");
40
49
  return merged;
41
50
  }
42
51
  var registryFile = (s) => join(s.portal.root, "aliases.json");
52
+ function normalizeAlias(item) {
53
+ const visibility = item.visibility ?? (item.lan === false ? "public" : "internal");
54
+ return { ...item, visibility, lan: visibility === "internal" };
55
+ }
43
56
  function loadRegistry(s) {
44
57
  const file = registryFile(s);
45
58
  if (!existsSync(file)) return { aliases: [] };
46
59
  const parsed = JSON.parse(readFileSync(file, "utf8"));
47
- return { aliases: parsed.aliases ?? [] };
60
+ return { aliases: (parsed.aliases ?? []).map(normalizeAlias) };
48
61
  }
49
62
 
50
63
  // src/portal/gateway.ts
@@ -163,6 +176,10 @@ function writeRegistry(s, registry) {
163
176
  `, { mode: 384 });
164
177
  renameSync(tmp, file);
165
178
  }
179
+ function snapshot(s) {
180
+ const file = registryFile(s);
181
+ return existsSync2(file) ? readFileSync2(file, "utf8") : null;
182
+ }
166
183
  function upsert(s, entry) {
167
184
  const registry = loadRegistry(s);
168
185
  const next = registry.aliases.filter((item) => item.alias !== entry.alias);
@@ -186,13 +203,475 @@ function readAliases(s) {
186
203
 
187
204
  // src/portal/render.ts
188
205
  import { execFileSync } from "node:child_process";
189
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
206
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
190
207
  import { join as join2 } from "node:path";
208
+
209
+ // src/portal/providers.ts
210
+ var fqdn2 = (settings2, alias) => `${alias}.${settings2.suffix}`;
211
+ var isInternal = (alias) => (alias.visibility ?? (alias.lan === false ? "public" : "internal")) === "internal";
212
+ var cloudConfig = (settings2, alias) => {
213
+ const provider = alias.providers?.dns ?? settings2.providers?.dns ?? "local-dnsmasq";
214
+ return { provider, config: settings2.providers?.cloud?.[provider] };
215
+ };
216
+ var credential = (settings2, alias) => {
217
+ const { config } = cloudConfig(settings2, alias);
218
+ return config?.credentialsEnv ? process.env[config.credentialsEnv] : void 0;
219
+ };
220
+ var timeout = () => AbortSignal.timeout(15e3);
221
+ var redact = (text) => text.replace(/(token|key|secret)[^&\s"]*[=:]\s*[^&\s"]+/gi, "$1=<redacted>");
222
+ var CloudDnsProvider = class {
223
+ kind = "dns";
224
+ async plan(alias, settings2) {
225
+ const zone = this.zone(settings2, alias);
226
+ const { config } = cloudConfig(settings2, alias);
227
+ const warnings = [];
228
+ const changes = [];
229
+ if (!zone) {
230
+ warnings.push(`\u672A\u914D\u7F6E ${this.name}.zone\uFF0C\u65E0\u6CD5\u4EA7\u751F\u4E91 DNS \u8BA1\u5212`);
231
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
232
+ }
233
+ if (isInternal(alias)) {
234
+ return {
235
+ provider: this.name,
236
+ kind: this.kind,
237
+ changes: [{ kind: "noop", resource: fqdn2(settings2, alias.alias), detail: "internal \u522B\u540D\u7981\u6B62\u521B\u5EFA\u516C\u7F51\u8BB0\u5F55" }],
238
+ warnings
239
+ };
240
+ }
241
+ if (!config?.allowPublicWrite) {
242
+ warnings.push("\u516C\u7F51 DNS \u5199\u5165\u672A\u663E\u5F0F\u5F00\u542F\uFF08allowPublicWrite=false\uFF09\uFF0C\u4EC5\u751F\u6210\u8BA1\u5212");
243
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
244
+ }
245
+ if (!alias.authorizedBy) {
246
+ warnings.push("public \u522B\u540D\u7F3A\u5C11 authorizedBy\uFF0C\u62D2\u7EDD\u516C\u7F51\u5199\u5165");
247
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
248
+ }
249
+ const token = credential(settings2, alias);
250
+ if (!token) {
251
+ warnings.push(`\u7F3A\u5C11\u51ED\u636E\u73AF\u5883\u53D8\u91CF ${config.credentialsEnv ?? "\uFF08\u672A\u914D\u7F6E\uFF09"}\uFF0C\u4E0D\u6267\u884C\u771F\u5B9E\u5199\u5165`);
252
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
253
+ }
254
+ const name = fqdn2(settings2, alias.alias);
255
+ const value = settings2.lanIp ?? "";
256
+ if (!value) {
257
+ warnings.push("\u672A\u914D\u7F6E lanIp\uFF0C\u516C\u7F51 A \u8BB0\u5F55\u503C\u5C06\u4E3A\u7A7A");
258
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
259
+ }
260
+ const record = { name, value, ttl: config.ttl ?? 120 };
261
+ try {
262
+ const existing = await this.list(zone, name, token);
263
+ if (!existing) {
264
+ changes.push({ kind: "create", resource: `${this.name}:${name}`, detail: `A ${value} ttl=${record.ttl}` });
265
+ } else if (existing.value === value) {
266
+ changes.push({ kind: "noop", resource: `${this.name}:${name}`, detail: `\u5DF2\u662F A ${value}` });
267
+ } else {
268
+ changes.push({ kind: "update", resource: `${this.name}:${name}`, detail: `A ${existing.value} -> ${value}` });
269
+ }
270
+ return { provider: this.name, kind: this.kind, changes, warnings };
271
+ } catch (error) {
272
+ warnings.push(`\u8BFB\u53D6\u8FDC\u7AEF\u5931\u8D25\uFF0C\u672A\u751F\u6210\u5199\u5165\u8BA1\u5212\uFF1A${redact(error instanceof Error ? error.message : String(error))}`);
273
+ return { provider: this.name, kind: this.kind, changes, warnings, blocked: true };
274
+ }
275
+ }
276
+ async apply(alias, settings2, plan2) {
277
+ if (plan2.blocked) throw new Error(`${this.name}: \u8BA1\u5212\u672A\u901A\u8FC7\u5B89\u5168\u68C0\u67E5\uFF0C\u62D2\u7EDD\u5199\u5165`);
278
+ const zone = this.zone(settings2, alias);
279
+ const token = credential(settings2, alias);
280
+ if (!zone || !token) throw new Error(`${this.name}: \u7F3A\u5C11 zone \u6216\u51ED\u636E`);
281
+ const { config } = cloudConfig(settings2, alias);
282
+ const name = fqdn2(settings2, alias.alias);
283
+ const record = { name, value: settings2.lanIp ?? "", ttl: config?.ttl ?? 120 };
284
+ const change = plan2.changes.find((item) => item.kind !== "noop");
285
+ if (!change) return;
286
+ const existing = await this.list(zone, name, token);
287
+ if (change.kind === "create") {
288
+ await this.create(zone, record, token);
289
+ return;
290
+ }
291
+ if (change.kind === "update" && existing) {
292
+ await this.update(zone, record, existing, token);
293
+ return;
294
+ }
295
+ throw new Error(`${this.name}: \u8FDC\u7AEF\u72B6\u6001\u4E0E\u8BA1\u5212\u4E0D\u4E00\u81F4\uFF08${change.kind}\uFF09\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C plan`);
296
+ }
297
+ async verify(alias, settings2) {
298
+ if (isInternal(alias)) return { ok: true, detail: "internal \u522B\u540D\u4E0D\u68C0\u67E5\u516C\u7F51 DNS" };
299
+ const zone = this.zone(settings2, alias);
300
+ const token = credential(settings2, alias);
301
+ if (!zone || !token) return { ok: false, detail: "\u7F3A\u5C11 zone \u6216\u51ED\u636E\uFF0C\u65E0\u6CD5\u6821\u9A8C\u516C\u7F51\u8BB0\u5F55" };
302
+ try {
303
+ const existing = await this.list(zone, fqdn2(settings2, alias.alias), token);
304
+ const value = settings2.lanIp ?? "";
305
+ if (!existing) return { ok: false, detail: "\u516C\u7F51\u8BB0\u5F55\u4E0D\u5B58\u5728" };
306
+ return existing.value === value ? { ok: true, detail: `\u516C\u7F51 A ${existing.value} \u5DF2\u5B58\u5728` } : { ok: false, detail: `\u516C\u7F51 A \u4E3A ${existing.value}\uFF0C\u671F\u671B ${value}` };
307
+ } catch (error) {
308
+ return { ok: false, detail: `\u516C\u7F51\u6821\u9A8C\u5931\u8D25\uFF1A${redact(error instanceof Error ? error.message : String(error))}` };
309
+ }
310
+ }
311
+ };
312
+ async function jsonRequest(url, init) {
313
+ const response = await fetch(url, { ...init, signal: timeout() });
314
+ const text = await response.text();
315
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${redact(text).slice(0, 500)}`);
316
+ return text ? JSON.parse(text) : {};
317
+ }
318
+ var CloudflareDnsProvider = class extends CloudDnsProvider {
319
+ name = "cloudflare";
320
+ zone(settings2, alias) {
321
+ return cloudConfig(settings2, alias).config?.zone;
322
+ }
323
+ base(zone) {
324
+ return `https://api.cloudflare.com/client/v4/zones/${zone}/dns_records`;
325
+ }
326
+ headers(token) {
327
+ return { authorization: `Bearer ${token}`, "content-type": "application/json" };
328
+ }
329
+ async list(zone, name, token) {
330
+ const data = await jsonRequest(`${this.base(zone)}?type=A&name=${encodeURIComponent(name)}`, { headers: this.headers(token) });
331
+ const rows = data.result ?? [];
332
+ const row = rows[0];
333
+ if (!row) return null;
334
+ return { id: String(row.id), value: String(row.content) };
335
+ }
336
+ async create(zone, record, token) {
337
+ await jsonRequest(this.base(zone), {
338
+ method: "POST",
339
+ headers: this.headers(token),
340
+ body: JSON.stringify({ type: "A", name: record.name, content: record.value, ttl: record.ttl, proxied: false })
341
+ });
342
+ }
343
+ async update(zone, record, existing, token) {
344
+ await jsonRequest(`${this.base(zone)}/${existing.id}`, {
345
+ method: "PATCH",
346
+ headers: this.headers(token),
347
+ body: JSON.stringify({ type: "A", name: record.name, content: record.value, ttl: record.ttl, proxied: false })
348
+ });
349
+ }
350
+ };
351
+ var DnspodDnsProvider = class extends CloudDnsProvider {
352
+ name = "dnspod";
353
+ zone(settings2, alias) {
354
+ return cloudConfig(settings2, alias).config?.zone;
355
+ }
356
+ async call(path, form, token) {
357
+ const body = new URLSearchParams({ login_token: token, format: "json", lang: "cn", ...form });
358
+ const response = await fetch(`https://dnsapi.cn/${path}`, {
359
+ method: "POST",
360
+ headers: { "content-type": "application/x-www-form-urlencoded" },
361
+ body,
362
+ signal: timeout()
363
+ });
364
+ const text = await response.text();
365
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${redact(text).slice(0, 500)}`);
366
+ const data = JSON.parse(text);
367
+ if (data.status?.code !== "1") throw new Error(`DNSPod: ${data.status?.code ?? "?"} ${data.status?.message ?? text}`);
368
+ return data;
369
+ }
370
+ split(zone, name) {
371
+ if (name === zone) return { domain: zone, sub: "@" };
372
+ if (!name.endsWith(`.${zone}`)) throw new Error(`DNSPod: ${name} \u4E0D\u5C5E\u4E8E zone ${zone}`);
373
+ return { domain: zone, sub: name.slice(0, -(zone.length + 1)) };
374
+ }
375
+ async list(zone, name, token) {
376
+ const { domain, sub } = this.split(zone, name);
377
+ const data = await this.call("Record.List", { domain, sub_domain: sub, record_type: "A" }, token);
378
+ const rows = data.records ?? [];
379
+ const row = rows.find((item) => item.type === "A");
380
+ if (!row) return null;
381
+ return { id: String(row.id), value: String(row.value) };
382
+ }
383
+ async create(zone, record, token) {
384
+ const { domain, sub } = this.split(zone, record.name);
385
+ await this.call("Record.Create", {
386
+ domain,
387
+ sub_domain: sub,
388
+ record_type: "A",
389
+ record_line: "\u9ED8\u8BA4",
390
+ value: record.value,
391
+ ttl: String(record.ttl)
392
+ }, token);
393
+ }
394
+ async update(zone, record, existing, token) {
395
+ const { domain, sub } = this.split(zone, record.name);
396
+ await this.call("Record.Modify", {
397
+ domain,
398
+ record_id: existing.id,
399
+ sub_domain: sub,
400
+ record_type: "A",
401
+ record_line: "\u9ED8\u8BA4",
402
+ value: record.value,
403
+ ttl: String(record.ttl)
404
+ }, token);
405
+ }
406
+ };
407
+ var HuaweiDnsProvider = class extends CloudDnsProvider {
408
+ name = "huawei";
409
+ zone(settings2, alias) {
410
+ return cloudConfig(settings2, alias).config?.zone;
411
+ }
412
+ base(zone) {
413
+ return `https://dns.myhuaweicloud.com/v2/zones/${zone}/recordsets`;
414
+ }
415
+ headers(token) {
416
+ return { "x-auth-token": token, "content-type": "application/json" };
417
+ }
418
+ async list(zone, name, token) {
419
+ const url = `${this.base(zone)}?name=${encodeURIComponent(`${name}.`)}&type=A`;
420
+ const data = await jsonRequest(url, { headers: this.headers(token) });
421
+ const rows = data.recordsets ?? [];
422
+ const row = rows.find((item) => item.type === "A");
423
+ if (!row) return null;
424
+ const values = row.records ?? [];
425
+ return { id: String(row.id), value: String(values[0] ?? "") };
426
+ }
427
+ async create(zone, record, token) {
428
+ await jsonRequest(this.base(zone), {
429
+ method: "POST",
430
+ headers: this.headers(token),
431
+ body: JSON.stringify({ name: `${record.name}.`, type: "A", ttl: record.ttl, records: [record.value] })
432
+ });
433
+ }
434
+ async update(zone, record, existing, token) {
435
+ await jsonRequest(`${this.base(zone)}/${existing.id}`, {
436
+ method: "PUT",
437
+ headers: this.headers(token),
438
+ body: JSON.stringify({ name: `${record.name}.`, type: "A", ttl: record.ttl, records: [record.value] })
439
+ });
440
+ }
441
+ };
442
+ var LocalDnsmasqProvider = class {
443
+ name = "local-dnsmasq";
444
+ kind = "dns";
445
+ async plan(alias, settings2) {
446
+ const name = fqdn2(settings2, alias.alias);
447
+ if (!isInternal(alias)) {
448
+ return { provider: this.name, kind: this.kind, changes: [], warnings: ["public \u522B\u540D\u4E0D\u4F7F\u7528\u5185\u7F51 DNS \u8986\u76D6"] };
449
+ }
450
+ return {
451
+ provider: this.name,
452
+ kind: this.kind,
453
+ changes: [{ kind: "create", resource: name, detail: `address=/${name}/${settings2.lanIp ?? ""}` }],
454
+ warnings: settings2.lanIp ? [] : ["\u672A\u914D\u7F6E lanIp"],
455
+ blocked: !settings2.lanIp
456
+ };
457
+ }
458
+ async apply() {
459
+ }
460
+ async verify(alias, settings2) {
461
+ return { ok: isInternal(alias) && Boolean(settings2.lanIp), detail: isInternal(alias) ? "\u7B49\u5F85 render \u540E\u6821\u9A8C" : "public \u522B\u540D\u4E0D\u5199\u5185\u7F51 DNS" };
462
+ }
463
+ };
464
+ var CaddyConnectivityProvider = class {
465
+ name = "caddy";
466
+ kind = "connectivity";
467
+ async plan(alias, settings2) {
468
+ const name = fqdn2(settings2, alias.alias);
469
+ const warnings = [];
470
+ if (isInternal(alias) && !settings2.caddy?.bind) warnings.push("internal \u522B\u540D\u7F3A\u5C11 caddy.bind\uFF0C\u516C\u7F51\u5165\u53E3\u4ECD\u53EF\u80FD\u547D\u4E2D");
471
+ if (isInternal(alias) && !settings2.caddy?.allow?.length) warnings.push("internal \u522B\u540D\u7F3A\u5C11 caddy.allow\uFF0C\u6765\u6E90\u4E0D\u53D7\u9650");
472
+ return {
473
+ provider: this.name,
474
+ kind: this.kind,
475
+ changes: [{ kind: "update", resource: `caddy:${name}`, detail: `reverse_proxy ${alias.scheme}://${alias.host}:${alias.port}` }],
476
+ warnings,
477
+ blocked: isInternal(alias) && (!settings2.caddy?.bind || !settings2.caddy?.allow?.length)
478
+ };
479
+ }
480
+ async apply() {
481
+ }
482
+ async verify(alias, settings2) {
483
+ if (!isInternal(alias)) return { ok: true, detail: "public \u522B\u540D\u4E0D\u505A\u6765\u6E90\u9650\u5236" };
484
+ const ok = Boolean(settings2.caddy?.bind && settings2.caddy?.allow?.length);
485
+ return { ok, detail: ok ? "internal \u7AD9\u70B9\u5DF2\u914D\u7F6E bind + allowlist" : "\u7F3A\u5C11 bind \u6216 allowlist" };
486
+ }
487
+ };
488
+ var InternalCaProvider = class {
489
+ name = "internal-ca";
490
+ kind = "certificate";
491
+ async plan() {
492
+ return { provider: this.name, kind: this.kind, changes: [], warnings: ["\u8BC1\u4E66\u7531 localmesh Caddy \u7684 internal issuer \u7EDF\u4E00\u7B7E\u53D1"] };
493
+ }
494
+ async apply() {
495
+ }
496
+ async verify() {
497
+ return { ok: true, detail: "internal-ca \u7531 Caddy \u8D1F\u8D23" };
498
+ }
499
+ };
500
+ var AcmeProvider = class {
501
+ name = "acme";
502
+ kind = "certificate";
503
+ async plan() {
504
+ return { provider: this.name, kind: this.kind, changes: [], warnings: ["ACME \u9700\u8981\u5916\u90E8\u53EF\u8FBE\u7684 80/443 \u4E0E DNS \u51ED\u636E\uFF0C\u672C\u5730\u4E0D\u4F2A\u9020\u7B7E\u53D1"], blocked: true };
505
+ }
506
+ async apply() {
507
+ throw new Error("ACME \u672A\u914D\u7F6E\u6388\u6743\uFF0C\u62D2\u7EDD\u5199\u5165");
508
+ }
509
+ async verify() {
510
+ return { ok: false, detail: "ACME \u9700\u8981\u771F\u5B9E\u8FDC\u7AEF\u9A8C\u8BC1" };
511
+ }
512
+ };
513
+ var LanAllowlistProvider = class {
514
+ name = "lan-allowlist";
515
+ kind = "policy";
516
+ async plan(alias, settings2) {
517
+ if (!isInternal(alias)) return { provider: this.name, kind: this.kind, changes: [], warnings: ["public \u522B\u540D\u9700\u663E\u5F0F\u6388\u6743"] };
518
+ return {
519
+ provider: this.name,
520
+ kind: this.kind,
521
+ changes: [{ kind: "noop", resource: fqdn2(settings2, alias.alias), detail: `allow ${settings2.caddy?.allow?.join(" ") ?? ""} deny ${settings2.caddy?.denyStatus ?? 404}` }],
522
+ warnings: []
523
+ };
524
+ }
525
+ async apply() {
526
+ }
527
+ async verify(alias, settings2) {
528
+ if (!isInternal(alias) && !alias.authorizedBy) return { ok: false, detail: "public \u522B\u540D\u7F3A\u5C11 authorizedBy" };
529
+ return { ok: true, detail: "\u7B56\u7565\u6EE1\u8DB3" };
530
+ }
531
+ };
532
+ function dnsProvider(settings2, alias) {
533
+ const name = alias.providers?.dns ?? settings2.providers?.dns ?? "local-dnsmasq";
534
+ switch (name) {
535
+ case "local-dnsmasq":
536
+ return new LocalDnsmasqProvider();
537
+ case "cloudflare":
538
+ return new CloudflareDnsProvider();
539
+ case "dnspod":
540
+ return new DnspodDnsProvider();
541
+ case "huawei":
542
+ return new HuaweiDnsProvider();
543
+ default:
544
+ throw new Error(`\u4E0D\u652F\u6301\u7684 DNS provider\uFF1A${String(name)}`);
545
+ }
546
+ }
547
+ function connectivityProvider(settings2, alias) {
548
+ const name = alias.providers?.connectivity ?? settings2.providers?.connectivity ?? "caddy";
549
+ switch (name) {
550
+ case "caddy":
551
+ return new CaddyConnectivityProvider();
552
+ case "aio-tunnel":
553
+ case "cloudflared":
554
+ return {
555
+ name,
556
+ kind: "connectivity",
557
+ async plan() {
558
+ return {
559
+ provider: name,
560
+ kind: "connectivity",
561
+ changes: [],
562
+ warnings: [`${name} \u7531 AIO \u8BBE\u5907\u96A7\u9053/\u5916\u90E8\u96A7\u9053\u7BA1\u7406\uFF1B\u8BF7\u5728\u96A7\u9053\u4FA7\u7528\u8BBE\u5907\u914D\u5BF9\u4E0E\u77ED\u65F6\u7968\u636E\u6821\u9A8C\uFF0C\u4E0D\u5728\u672C\u5730 Caddy \u4F2A\u9020\u6210\u529F`],
563
+ blocked: true
564
+ };
565
+ },
566
+ async apply() {
567
+ throw new Error(`${name} \u9700\u8981\u5916\u90E8\u96A7\u9053\u7F16\u6392\uFF0C\u62D2\u7EDD\u672C\u5730\u5199\u5165`);
568
+ },
569
+ async verify() {
570
+ return { ok: false, detail: `${name} \u9700\u8981\u771F\u5B9E\u96A7\u9053\u5065\u5EB7\u68C0\u67E5\uFF08AIO \u8BBE\u5907\u5728\u7EBF + \u76EE\u6807\u5141\u8BB8 + \u7968\u636E\u6709\u6548\uFF09` };
571
+ }
572
+ };
573
+ default:
574
+ throw new Error(`\u4E0D\u652F\u6301\u7684\u8FDE\u901A\u6027 provider\uFF1A${String(name)}`);
575
+ }
576
+ }
577
+ function certificateProvider(settings2, alias) {
578
+ const name = alias.providers?.certificate ?? settings2.providers?.certificate ?? "internal-ca";
579
+ return name === "acme" ? new AcmeProvider() : new InternalCaProvider();
580
+ }
581
+ function policyProvider(settings2, alias) {
582
+ const name = alias.providers?.policy ?? settings2.providers?.policy ?? "lan-allowlist";
583
+ if (name === "public") {
584
+ return {
585
+ name,
586
+ kind: "policy",
587
+ async plan() {
588
+ return { provider: name, kind: "policy", changes: [], warnings: ["\u9700\u663E\u5F0F public \u6388\u6743"] };
589
+ },
590
+ async apply() {
591
+ },
592
+ async verify() {
593
+ return { ok: true, detail: "public \u7B56\u7565\u7531\u6CE8\u518C\u8868\u6388\u6743\u5B57\u6BB5\u4FDD\u8BC1" };
594
+ }
595
+ };
596
+ }
597
+ if (name === "aio-auth") {
598
+ return {
599
+ name,
600
+ kind: "policy",
601
+ async plan() {
602
+ return { provider: name, kind: "policy", changes: [], warnings: ["AIO \u8BA4\u8BC1\u7531\u5E94\u7528\u81EA\u8EAB\u8D1F\u8D23"] };
603
+ },
604
+ async apply() {
605
+ },
606
+ async verify() {
607
+ return { ok: true, detail: "AIO \u8BA4\u8BC1\u7531\u5E94\u7528\u8D1F\u8D23" };
608
+ }
609
+ };
610
+ }
611
+ return new LanAllowlistProvider();
612
+ }
613
+ function providersFor(settings2, alias) {
614
+ return [
615
+ dnsProvider(settings2, alias),
616
+ connectivityProvider(settings2, alias),
617
+ certificateProvider(settings2, alias),
618
+ policyProvider(settings2, alias)
619
+ ];
620
+ }
621
+
622
+ // src/portal/render.ts
191
623
  var writeLines = (path, lines) => {
192
624
  writeFileSync2(path, lines.length ? `${lines.join("\n")}
193
625
  ` : "");
194
626
  };
195
- function render(s) {
627
+ var quote = (value) => /^[A-Za-z0-9_.:/\[\]-]+$/.test(value) ? value : JSON.stringify(value);
628
+ function caddySite(settings2, item) {
629
+ const name = aliasFqdn(settings2, item.alias);
630
+ const target = `${item.scheme}://${item.host}:${item.port}`;
631
+ const lines = [`# generated by aio-plugin-portal; alias=${item.alias} visibility=${item.visibility ?? "internal"}`, `${name} {`, " import defaults"];
632
+ if (isInternal(item)) {
633
+ const bind = settings2.caddy?.bind;
634
+ if (bind) lines.push(` bind ${quote(bind)}`);
635
+ const allow = settings2.caddy?.allow?.length ? settings2.caddy.allow : ["127.0.0.1/32"];
636
+ lines.push(` @internal remote_ip ${allow.join(" ")}`);
637
+ lines.push(" handle @internal {", " encode gzip", ` reverse_proxy ${quote(target)}`, " }");
638
+ lines.push(" handle {", ` respond ${settings2.caddy?.denyStatus ?? 404}`, " }");
639
+ } else {
640
+ lines.push(" encode gzip", ` reverse_proxy ${quote(target)}`);
641
+ }
642
+ lines.push("}", "");
643
+ return lines.join("\n");
644
+ }
645
+ function cleanGenerated(dir, prefix) {
646
+ if (!existsSync3(dir)) return [];
647
+ const removed = [];
648
+ for (const name of readdirSync(dir)) {
649
+ if (!name.startsWith(prefix) || !name.endsWith(".caddy")) continue;
650
+ const path = join2(dir, name);
651
+ const text = readFileSync3(path, "utf8");
652
+ if (text.startsWith("# generated by aio-plugin-portal")) {
653
+ rmSync(path);
654
+ removed.push(path);
655
+ }
656
+ }
657
+ return removed;
658
+ }
659
+ function renderCaddy(settings2) {
660
+ const caddy = settings2.caddy;
661
+ if (!caddy?.sitesDir) return [];
662
+ const prefix = caddy.prefix ?? "portal-";
663
+ mkdirSync2(caddy.sitesDir, { recursive: true });
664
+ cleanGenerated(caddy.sitesDir, prefix);
665
+ const files = [];
666
+ for (const item of readAliases(settings2)) {
667
+ const path = join2(caddy.sitesDir, `${prefix}${item.alias}.caddy`);
668
+ writeFileSync2(path, caddySite(settings2, item));
669
+ files.push(path);
670
+ }
671
+ return files;
672
+ }
673
+ function render(settings2) {
674
+ const s = settings2;
196
675
  const aliases = readAliases(s);
197
676
  const dir = s.portal.aliasesDir;
198
677
  mkdirSync2(dir, { recursive: true });
@@ -216,9 +695,10 @@ function render(s) {
216
695
  writeLines(files[2], host);
217
696
  const resolver = s.resolver.configFile;
218
697
  mkdirSync2(join2(resolver, ".."), { recursive: true });
219
- const overrides = s.mode === "none" || !s.lanIp ? [] : aliases.filter((item) => item.lan).map((item) => `address=/${aliasFqdn(s, item.alias)}/${s.lanIp}`);
220
- const lines = [
221
- `# \u7531 aio-portal \u751F\u6210\uFF0C\u8BF7\u52FF\u624B\u6539\uFF1B\u6539 ${aliasFqdn(s, "aio-portal")} \u7684\u6CE8\u518C\u8868\u540E\u6267\u884C sync`,
698
+ const overrides = s.mode === "none" || !s.lanIp ? [] : aliases.filter((item) => isInternal(item)).map((item) => `address=/${aliasFqdn(s, item.alias)}/${s.lanIp}`);
699
+ const header = `# \u7531 aio-portal \u751F\u6210\uFF0C\u8BF7\u52FF\u624B\u6539\uFF1B\u6539 ${aliasFqdn(s, "aio-portal")} \u7684\u6CE8\u518C\u8868\u540E\u6267\u884C sync`;
700
+ const lines = s.resolver.fragment ? [header, ...overrides] : [
701
+ header,
222
702
  `port=${s.resolver.port}`,
223
703
  `listen-address=${s.lanIp || "127.0.0.1"}`,
224
704
  "bind-interfaces",
@@ -230,7 +710,7 @@ function render(s) {
230
710
  ];
231
711
  writeFileSync2(resolver, `${lines.join("\n")}
232
712
  `);
233
- return { aliases: aliases.length, files, resolver };
713
+ return { aliases: aliases.length, files, resolver, caddy: renderCaddy(s) };
234
714
  }
235
715
  var run = (program, args) => {
236
716
  try {
@@ -241,16 +721,76 @@ var run = (program, args) => {
241
721
  return { code: err.status ?? 1, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() || (err.message ?? "") };
242
722
  }
243
723
  };
244
- function reloadResolver(s) {
245
- const binary = s.resolver.dnsmasqBinary ?? "dnsmasq";
246
- const test = run(binary, ["--test", `--conf-file=${s.resolver.configFile}`]);
724
+ function validateCaddy(settings2) {
725
+ const s = settings2;
726
+ if (!s.caddy?.binary) return "\u5DF2\u8DF3\u8FC7 caddy validate\uFF08\u672A\u914D\u7F6E caddy.binary\uFF09";
727
+ const config = s.caddy.sitesDir ? join2(s.caddy.sitesDir, "..", "Caddyfile") : "";
728
+ if (!existsSync3(config)) return `Caddy \u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A\u627E\u4E0D\u5230 ${config}`;
729
+ const args = ["validate", "--config", config];
730
+ const test = run(s.caddy.binary, args);
731
+ return test.code === 0 ? "Caddy \u914D\u7F6E\u6821\u9A8C\u901A\u8FC7" : `Caddy \u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A${test.out}`;
732
+ }
733
+ function reloadCaddy(settings2) {
734
+ const command = settings2.caddy?.reload;
735
+ if (!command) return "\u5DF2\u8DF3\u8FC7 Caddy \u70ED\u52A0\u8F7D\uFF08\u672A\u914D\u7F6E caddy.reload\uFF09";
736
+ const [program, ...args] = command.trim().split(/\s+/);
737
+ const result = run(program, args);
738
+ return result.code === 0 ? "Caddy \u5DF2\u70ED\u52A0\u8F7D" : `Caddy \u70ED\u52A0\u8F7D\u5931\u8D25\uFF1A${result.out}`;
739
+ }
740
+ function reloadResolver(settings2) {
741
+ if (settings2.resolver.provider === "none") return "\u5DF2\u8DF3\u8FC7\u5185\u7F51\u89E3\u6790\u5237\u65B0\uFF08resolver.provider=none\uFF09";
742
+ const binary = settings2.resolver.dnsmasqBinary ?? "dnsmasq";
743
+ const test = run(binary, ["--test", `--conf-file=${settings2.resolver.configFile}`]);
247
744
  if (test.code !== 0) return `\u89E3\u6790\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A${test.out}`;
248
- if (s.resolver.service) {
249
- const reload2 = run("systemctl", ["reload-or-restart", s.resolver.service]);
250
- return reload2.code === 0 ? "\u5185\u7F51\u89E3\u6790\u5DF2\u5237\u65B0" : `\u5185\u7F51\u89E3\u6790\u5237\u65B0\u5931\u8D25\uFF1A${reload2.out}`;
745
+ if (settings2.resolver.service) {
746
+ const reload = run("systemctl", ["reload-or-restart", settings2.resolver.service]);
747
+ return reload.code === 0 ? "\u5185\u7F51\u89E3\u6790\u5DF2\u5237\u65B0" : `\u5185\u7F51\u89E3\u6790\u5237\u65B0\u5931\u8D25\uFF1A${reload.out}`;
748
+ }
749
+ return "\u89E3\u6790\u914D\u7F6E\u5DF2\u751F\u6210\uFF08\u672A\u914D\u7F6E resolver.service\uFF0C\u672A\u89E6\u78B0\u5BBF\u4E3B\u670D\u52A1\uFF09";
750
+ }
751
+
752
+ // src/portal/reconcile.ts
753
+ async function reconcile(settings2, options = {}) {
754
+ const aliases = readAliases(settings2).filter((item) => !options.alias || item.alias === options.alias);
755
+ const items = [];
756
+ for (const item of aliases) {
757
+ const plans = [];
758
+ const applied = [];
759
+ const verify = [];
760
+ for (const provider of providersFor(settings2, item)) {
761
+ const plan2 = await provider.plan(item, settings2);
762
+ plans.push(plan2);
763
+ if (options.apply && !plan2.blocked) {
764
+ await provider.apply(item, settings2, plan2);
765
+ applied.push(provider.name);
766
+ }
767
+ const result = await provider.verify(item, settings2);
768
+ verify.push({ provider: provider.name, ...result });
769
+ }
770
+ items.push({
771
+ alias: item.alias,
772
+ visibility: item.visibility ?? "internal",
773
+ plans,
774
+ applied,
775
+ verify,
776
+ ok: plans.every((plan2) => !plan2.blocked) && verify.every((result) => result.ok)
777
+ });
778
+ }
779
+ return { items, ok: items.every((item) => item.ok) };
780
+ }
781
+ function formatReport(report) {
782
+ const lines = [];
783
+ for (const item of report.items) {
784
+ lines.push(`${item.alias} (${item.visibility}) ${item.ok ? "OK" : "NEEDS ATTENTION"}`);
785
+ for (const plan2 of item.plans) {
786
+ const changes = plan2.changes.length ? plan2.changes : [{ kind: "noop", resource: plan2.provider, detail: "\u65E0\u53D8\u66F4" }];
787
+ for (const change of changes) lines.push(` [${plan2.provider}] ${change.kind} ${change.resource} ${change.detail}`);
788
+ for (const warning of plan2.warnings) lines.push(` [${plan2.provider}] warning: ${warning}`);
789
+ }
790
+ for (const result of item.verify) lines.push(` [${result.provider}] verify ${result.ok ? "ok" : "failed"}: ${result.detail}`);
251
791
  }
252
- const reload = run(binary, [`--conf-file=${s.resolver.configFile}`]);
253
- return reload.code === 0 ? "\u5185\u7F51\u89E3\u6790\u5DF2\u5237\u65B0" : `\u5185\u7F51\u89E3\u6790\u5237\u65B0\u5931\u8D25\uFF1A${reload.out}`;
792
+ lines.push(report.ok ? "\u534F\u8C03\u5B8C\u6210" : "\u5B58\u5728\u672A\u6EE1\u8DB3\u7684 provider \u8BA1\u5212\u6216\u6821\u9A8C");
793
+ return lines.join("\n");
254
794
  }
255
795
 
256
796
  // src/portal/commands.ts
@@ -267,18 +807,21 @@ function add(options) {
267
807
  if (!isAlias(options.alias)) throw new Error("\u522B\u540D\u53EA\u80FD\u4F7F\u7528\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u8FDE\u5B57\u7B26");
268
808
  if (!isHost(options.host)) throw new Error("\u76EE\u6807\u4E3B\u673A\u4E0D\u5408\u6CD5");
269
809
  if (options.scheme !== "http" && options.scheme !== "https") throw new Error("\u534F\u8BAE\u53EA\u80FD\u662F http \u6216 https");
810
+ if (options.visibility === "public" && !options.authorizedBy) throw new Error("public \u522B\u540D\u5FC5\u987B\u63D0\u4F9B --authorized-by\uFF0C\u660E\u786E\u8BB0\u5F55\u6388\u6743\u4EBA");
270
811
  const entry = {
271
812
  alias: options.alias,
272
813
  host: options.host,
273
814
  port: parsePort(options.port),
274
815
  scheme: options.scheme,
275
- lan: options.lan && s.mode !== "none",
816
+ visibility: options.visibility,
817
+ lan: options.visibility === "internal" && s.mode !== "none",
818
+ authorizedBy: options.authorizedBy,
276
819
  note: options.note ?? ""
277
820
  };
278
821
  const registry = upsert(s, entry);
279
822
  const result = render(s);
280
823
  return [
281
- `\u5DF2\u767B\u8BB0 ${aliasFqdn(s, entry.alias)} -> ${entry.scheme}://${entry.host}:${entry.port}\uFF08\u5171 ${result.aliases} \u6761\uFF09`,
824
+ `\u5DF2\u767B\u8BB0 ${aliasFqdn(s, entry.alias)} -> ${entry.scheme}://${entry.host}:${entry.port}\uFF08${entry.visibility}\uFF0C\u5171 ${result.aliases} \u6761\uFF09`,
282
825
  renderReload(s)
283
826
  ].join("\n");
284
827
  }
@@ -293,12 +836,31 @@ function list(options) {
293
836
  const s = settings(options.home);
294
837
  const aliases = readAliases(s);
295
838
  if (!aliases.length) return "\uFF08\u7A7A\uFF09";
296
- return aliases.map((item) => ` ${aliasFqdn(s, item.alias).padEnd(30)} -> ${item.scheme}://${item.host}:${item.port} \u5185\u7F51:${item.lan ? "\u5F00" : "\u5173"} ${item.note ?? ""}`).join("\n");
839
+ return aliases.map((item) => ` ${aliasFqdn(s, item.alias).padEnd(30)} -> ${item.scheme}://${item.host}:${item.port} ${item.visibility ?? "internal"} ${item.note ?? ""}`).join("\n");
297
840
  }
298
- function sync(options) {
841
+ async function sync(options) {
299
842
  const s = settings(options.home);
843
+ const before = snapshot(s);
300
844
  const result = render(s);
301
- return [`\u5DF2\u751F\u6210 ${result.aliases} \u6761\u6620\u5C04`, renderReload(s)].join("\n");
845
+ const report = await reconcile(s, { apply: options.apply });
846
+ const after = snapshot(s);
847
+ if (before !== after) throw new Error("\u540C\u6B65\u8FC7\u7A0B\u4E2D\u6CE8\u518C\u8868\u88AB\u5176\u5B83\u8FDB\u7A0B\u4FEE\u6539\uFF0C\u8BF7\u91CD\u8BD5");
848
+ const lines = [
849
+ `\u5DF2\u751F\u6210 ${result.aliases} \u6761\u6620\u5C04\u4E0E ${result.caddy.length} \u4E2A Caddy \u7AD9\u70B9`,
850
+ renderReload(s),
851
+ validateCaddy(s),
852
+ reloadCaddy(s),
853
+ formatReport(report)
854
+ ];
855
+ if (!report.ok) {
856
+ throw new Error(`${lines.join("\n")}
857
+ \u540C\u6B65\u88AB\u62D2\u7EDD\uFF1A\u5B58\u5728\u672A\u6EE1\u8DB3\u7684 provider \u8BA1\u5212\u6216\u6821\u9A8C\uFF0C\u672A\u53D1\u5E03\u4EFB\u4F55\u53D8\u66F4`);
858
+ }
859
+ return lines.join("\n");
860
+ }
861
+ async function plan(options) {
862
+ const s = settings(options.home);
863
+ return formatReport(await reconcile(s, { apply: false }));
302
864
  }
303
865
  function renderReload(s) {
304
866
  if (!s.portal.reload) return "\u5DF2\u8DF3\u8FC7\u670D\u52A1\u70ED\u52A0\u8F7D\uFF08portal.reload=false\uFF09";
@@ -306,8 +868,8 @@ function renderReload(s) {
306
868
  }
307
869
  function doctor(options) {
308
870
  const s = settings(options.home);
309
- const resolved = existsSync3(s.resolver.configFile);
310
- const rendered = existsSync3(`${s.portal.aliasesDir}/${s.portal.mapPrefix}.map`);
871
+ const resolved = existsSync4(s.resolver.configFile);
872
+ const rendered = existsSync4(`${s.portal.aliasesDir}/${s.portal.mapPrefix}.map`);
311
873
  const lines = [
312
874
  ` \u57DF\u540D\u540E\u7F00 ${s.suffix}`,
313
875
  ` \u89E3\u6790\u6A21\u5F0F ${s.mode}`,
@@ -315,9 +877,13 @@ function doctor(options) {
315
877
  ` \u6CE8\u518C\u8868 ${readAliases(s).length} \u6761`,
316
878
  ` \u6620\u5C04\u7247\u6BB5 ${rendered ? "\u5DF2\u751F\u6210" : "\u7F3A\u5931"}`,
317
879
  ` \u89E3\u6790\u914D\u7F6E ${resolved ? "\u5DF2\u751F\u6210" : "\u7F3A\u5931"}`,
880
+ ` Caddy provider ${s.providers?.connectivity ?? "caddy"} sites=${s.caddy?.sitesDir ?? "\uFF08\u672A\u914D\u7F6E\uFF09"} bind=${s.caddy?.bind ?? "\uFF08\u672A\u914D\u7F6E\uFF09"}`,
318
881
  ` \u7F51\u5173\u9002\u914D\u5668 ${s.gateway.adapter}${s.gateway.address ? ` @ ${s.gateway.address}` : "\uFF08\u672A\u914D\u7F6E\u5730\u5740\uFF09"}`
319
882
  ];
320
- const ok = rendered && resolved && Boolean(s.suffix) && Boolean(s.lanIp || s.mode !== "lan");
883
+ const internal = readAliases(s).filter((item) => (item.visibility ?? "internal") === "internal");
884
+ const guard = internal.every((item) => Boolean(s.caddy?.bind) && Boolean(s.caddy?.allow?.length));
885
+ if (!guard) lines.push(" \u5185\u7F51\u9632\u62A4 internal \u522B\u540D\u7F3A\u5C11 caddy.bind \u6216 caddy.allow");
886
+ const ok = rendered && resolved && Boolean(s.suffix) && Boolean(s.lanIp || s.mode !== "lan") && guard;
321
887
  return [...lines, ok ? "\u4F53\u68C0\u901A\u8FC7" : "\u5B58\u5728\u7F3A\u5931\u9879\uFF0C\u8BF7\u5148\u6267\u884C sync \u6216\u8865\u9F50\u914D\u7F6E"].join("\n");
322
888
  }
323
889
  async function warden(options) {
@@ -353,10 +919,13 @@ async function warden(options) {
353
919
  var USAGE = `aio-plugin-portal <\u547D\u4EE4> [\u9009\u9879]
354
920
 
355
921
  \u547D\u4EE4:
356
- add <\u522B\u540D> --port <\u7AEF\u53E3> [--host <\u4E3B\u673A>] [--scheme http|https] [--note <\u5907\u6CE8>] [--no-lan]
922
+ add <\u522B\u540D> --port <\u7AEF\u53E3> [--host <\u4E3B\u673A>] [--scheme http|https] [--note <\u5907\u6CE8>]
923
+ add <\u522B\u540D> --port <\u7AEF\u53E3> --visibility public --authorized-by <\u6388\u6743\u4EBA>
924
+ \uFF08public \u5FC5\u987B\u663E\u5F0F\u6388\u6743\uFF1B\u65E7 --no-lan \u53EA\u8868\u793A\u4E0D\u505A\u5185\u7F51 DNS\uFF0C\u4E0D\u518D\u81EA\u52A8\u7B97\u6388\u6743\uFF09
357
925
  remove <\u522B\u540D>
358
926
  list
359
- sync
927
+ plan
928
+ sync [--apply]
360
929
  doctor
361
930
  warden [--password <\u7F51\u5173\u5BC6\u7801>] [--apply] [--restore]
362
931
 
@@ -376,6 +945,8 @@ async function main() {
376
945
  note: { type: "string" },
377
946
  lan: { type: "boolean", default: true },
378
947
  "no-lan": { type: "boolean", default: false },
948
+ visibility: { type: "string" },
949
+ "authorized-by": { type: "string" },
379
950
  home: { type: "string" },
380
951
  password: { type: "string" },
381
952
  apply: { type: "boolean", default: false },
@@ -384,7 +955,7 @@ async function main() {
384
955
  });
385
956
  const [command, argument] = positionals;
386
957
  if (values.version) {
387
- console.log("0.1.0");
958
+ console.log("0.2.1-dev.6.gd90568e1c0fd");
388
959
  return;
389
960
  }
390
961
  if (values.help || !command) {
@@ -395,8 +966,10 @@ async function main() {
395
966
  case "add": {
396
967
  if (!argument) throw new Error("add \u9700\u8981\u522B\u540D");
397
968
  if (values.port === void 0) throw new Error("add \u9700\u8981 --port");
398
- const lan = values.lan && !values["no-lan"];
399
- console.log(add({ alias: argument, host: values.host, port: values.port, scheme: values.scheme, note: values.note, lan, home: values.home }));
969
+ const visibility = values.visibility ?? (values["no-lan"] || !values.lan ? "public" : "internal");
970
+ if (visibility !== "internal" && visibility !== "public") throw new Error("--visibility \u53EA\u80FD\u662F internal \u6216 public");
971
+ const authorizedBy = values["authorized-by"];
972
+ console.log(add({ alias: argument, host: values.host, port: values.port, scheme: values.scheme, note: values.note, visibility, authorizedBy, home: values.home }));
400
973
  return;
401
974
  }
402
975
  case "remove": {
@@ -407,8 +980,11 @@ async function main() {
407
980
  case "list":
408
981
  console.log(list({ home: values.home }));
409
982
  return;
983
+ case "plan":
984
+ console.log(await plan({ home: values.home }));
985
+ return;
410
986
  case "sync":
411
- console.log(sync({ home: values.home }));
987
+ console.log(await sync({ home: values.home, apply: values.apply }));
412
988
  return;
413
989
  case "doctor":
414
990
  console.log(doctor({ home: values.home }));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aio-plugin-portal",
3
- "version": "0.1.0",
4
- "description": "登记别名到内网端口映射,生成反向代理分流与内网 DNS 覆盖,并让网关把整片局域网指向本机解析器。",
3
+ "version": "0.2.1-dev.6.gd90568e1c0fd",
4
+ "description": "在期望态里协调 DNS(本地/Cloudflare/腾讯云/华为云)、Caddy 反代、证书与内网访问策略,把别名站点的可见范围钉死在登记表里。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -32,5 +32,23 @@
32
32
  "repository": {
33
33
  "type": "git",
34
34
  "url": "git+https://github.com/org-aio/aio-plugin-portal.git"
35
+ },
36
+ "aio": {
37
+ "cli": {
38
+ "id": "aio-plugin-portal",
39
+ "title": "端口门户",
40
+ "command": "aio-plugin-portal",
41
+ "platforms": [
42
+ "macos",
43
+ "linux"
44
+ ],
45
+ "setup": [],
46
+ "uninstall": []
47
+ },
48
+ "source": {
49
+ "repository": "org-aio/aio-plugin-portal",
50
+ "revision": "d90568e1c0fd0fa30b4299b40222968678d204e6",
51
+ "reference": "refs/heads/main"
52
+ }
35
53
  }
36
54
  }
@@ -1,36 +1,73 @@
1
1
  ---
2
2
  name: aio-plugin-portal
3
- description: 维护端口门户:登记“别名→内网 host:port 映射”,生成反向代理按 Host 分流与内网 DNS 覆盖,并把网关上游 DNS 指向本机解析器,让局域网所有设备无感走内网。当用户要新增/删除 xxx.addzero.site 之类的别名站点、排查内外网解析差异、或让同事整片局域网零配置走内网时使用。
3
+ description: 维护端口门户:登记“别名→内网 host:port 映射”,在一套期望态里协调 DNS(本地 dnsmasq / Cloudflare / 腾讯云 DNSPod / 华为云)、反向代理(Caddy)、证书与访问策略,让指定域名仅内网可达。当用户要新增/删除 xxx.addzero.site 之类的别名站点、排查内外网解析差异、要求“仅内网访问不允许公网访问”、或让同事整片局域网零配置走内网时使用。
4
4
  ---
5
5
 
6
6
  # 端口门户
7
7
 
8
- 一次登记同时产生两件事:反向代理按 Host 把 `<别名>.<suffix>` 分流到目标端口;内网 DNS 把该名字解析到本机,于是**内网直达、外网仍走原隧道**。
8
+ 一次登记同时产生并校验:内网 DNS 覆盖(只覆盖 `visibility=internal`)、按 Host 分流的反代站点、
9
+ 证书来源、访问策略。**内网直达本机,公网是否可达由登记项的 `visibility` 决定。**
9
10
 
10
11
  先执行 `aio-plugin-portal --help` 核对当前命令,`aio-plugin-portal --version` 确认版本。未全局安装时用 `npx -y aio-plugin-portal`。
11
12
 
12
13
  ## 常用命令
13
14
 
14
15
  ```sh
15
- aio-plugin-portal add tc --port 8111 --host 192.168.31.10 --note TeamCity
16
- aio-plugin-portal add only-public --port 9000 --no-lan # 只做分流,不做内网覆盖
16
+ aio-plugin-portal add tc --port 8111 --host 127.0.0.1 --note TeamCity
17
+ aio-plugin-portal add only-public --port 9000 --visibility public --authorized-by alice
17
18
  aio-plugin-portal list
18
19
  aio-plugin-portal remove tc
19
- aio-plugin-portal sync # 按注册表重建映射+解析并热加载
20
- aio-plugin-portal doctor # 配置与产物体检
21
- aio-plugin-portal warden --password '<网关密码>' # 只读探测网关
22
- aio-plugin-portal warden --password '<网关密码>' --apply # 让整片局域网走内网
23
- aio-plugin-portal warden --password '<网关密码>' --restore # 回滚网关 DNS
20
+ aio-plugin-portal plan # 只出计划,不写任何东西
21
+ aio-plugin-portal sync # 渲染产物 + 执行 provider + 校验
22
+ aio-plugin-portal sync --apply # 允许 provider 真实写入
23
+ aio-plugin-portal doctor # 配置与产物体检
24
+ aio-plugin-portal warden --password '<网关密码>' # 只读探测网关
25
+ aio-plugin-portal warden --password '<网关密码>' --apply # 让整片局域网走内网
26
+ aio-plugin-portal warden --password '<网关密码>' --restore # 回滚网关 DNS
24
27
  ```
25
28
 
29
+ ## 内网 / 公网边界
30
+
31
+ `visibility` 是安全边界,不是展示字段:
32
+
33
+ - `internal`(默认):永不产生公网 DNS 记录;Caddy 站点要求 `bind <入口地址>` + `remote_ip` 白名单,
34
+ 未命中 `respond 404`。缺 `caddy.bind` 时 verify 判失败。
35
+ - `public`:必须同时满足 `allowPublicWrite: true` + 有凭据 + 登记了 `authorizedBy`,否则计划被标记
36
+ `blocked`,`sync --apply` 拒绝执行。
37
+
38
+ `blocked` 的计划一律不 apply;verify 重新读取远端/产物,不用本地计划冒充成功。
39
+
40
+ > 边界代价:只在经过本机 Caddy 的路径上成立。若同名域名在公网 DNS 或别的入口仍指向旧隧道,
41
+ > 那部分流量不归本机管;真正“仅内网”还要清掉对应公网记录,先确认目标记录再动。
42
+
43
+ ## provider 组合
44
+
45
+ 四类 provider 独立组合,登记项可用 `providers` 字段单条覆盖:
46
+
47
+ | 类别 | 可用值 |
48
+ |---|---|
49
+ | `dns` | `local-dnsmasq`、`cloudflare`、`dnspod`(腾讯云)、`huawei`(华为云) |
50
+ | `connectivity` | `caddy`、`aio-tunnel`、`cloudflared` |
51
+ | `certificate` | `internal-ca`、`acme` |
52
+ | `policy` | `lan-allowlist`、`aio-auth`、`public` |
53
+
54
+ 云 DNS 凭据只从环境变量读取(`credentialsEnv` 指向变量名),不写进注册表或配置文件。
55
+ 腾讯云 DNSPod 走 `Record.List/Create/Modify`;华为云用 Zone ID + IAM `X-Auth-Token`,
56
+ 长期 AK/SK 签名尚未实现,未配置 token 时只给可审计的计划。
57
+
26
58
  ## 配置
27
59
 
28
60
  宿主相关的值全部来自配置,源码不含域名、网段、账号或部署目录:
29
61
 
30
62
  - 默认读取 `~/.config/aio-portal/aio-portal.json`,可用 `--home` 或环境变量 `AIO_PORTAL_HOME` 覆盖。
31
- - 必填 `suffix`(域名后缀);`mode`、`lanIp`、`resolver.upstreams/port/service`、`portal.aliasesDir/mapPrefix/reload`、`gateway.address/adapter/fallbackDns` 均可配置。
63
+ - 必填 `suffix`(域名后缀);`mode`、`lanIp`、`resolver.*`、`portal.*`、`gateway.*`、`caddy.*`、`providers.*` 均可配置。
32
64
  - 新增站点只改注册表(`aio-plugin-portal add` 或编辑 `aliases.json`),无需改代码。
33
65
 
66
+ ## 与配对机制的边界
67
+
68
+ 设备配对复用 `aio-plugin-device` 的短时票据闭环(签发 → 登录确认 → 轮询换取凭据 → 心跳/租约 → 可撤销)。
69
+ 本工具只负责把已存在的目标端口登记成可达站点并钉死可见范围,不另造隧道。
70
+
34
71
  ## 约定
35
72
 
36
73
  - 网关改动默认只读,写入必须显式 `--apply`,并支持 `--restore` 回滚;会同时影响经该网关上网的所有设备。