aio-plugin-portal 0.0.0-stage → 0.1.0

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,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # 端口门户
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把「别名 + 内网端口」登记成一条 `https://<别名>.<suffix>`:**内网**直接把该名字解析到本机
4
+ (不经公网),**外网**仍按原隧道/代理访问,两条路互不影响。以后新增站点只改注册表,不用改代码。
5
+
6
+ ## 安装与使用
7
+
8
+ ```sh
9
+ npm install --global aio-plugin-portal # 或 npx -y aio-plugin-portal
10
+ aio-plugin-portal --help
11
+ ```
12
+
13
+ 首次使用先写配置(默认 `~/.config/aio-portal/aio-portal.json`,可用 `--home`/`AIO_PORTAL_HOME` 覆盖),
14
+ 必填域名后缀,其余按需设置;样例见 `config/aio-portal.example.json`。
15
+
16
+ ```sh
17
+ aio-plugin-portal add tc --port 8111 --host 192.168.31.10 --note TeamCity
18
+ aio-plugin-portal list
19
+ aio-plugin-portal sync
20
+ aio-plugin-portal doctor
21
+ aio-plugin-portal remove tc
22
+ ```
23
+
24
+ ## 让整片局域网无感走内网
25
+
26
+ 客户端零配置的前提是**网关替所有设备做 DNS 覆盖**:把网关上游 DNS 指向本机解析器。
27
+
28
+ ```sh
29
+ aio-plugin-portal warden --password '<网关密码>' # 只读探测
30
+ aio-plugin-portal warden --password '<网关密码>' --apply # 写入
31
+ aio-plugin-portal warden --password '<网关密码>' --restore # 回滚
32
+ ```
33
+
34
+ - 默认只读,写入必须显式 `--apply`;网关改动会影响**所有**经它上网的设备。
35
+ - 只想让个别机器走内网时,不动网关,改用客户端按域名分流(如 macOS `/etc/resolver/<suffix>`)。
36
+ - 网关适配器通过配置选择;不含任何厂商地址、账号或密码。
37
+
38
+ 发布与市场配置见 [AIO 自动发布说明](AIO.md)。
39
+
40
+ `skills/aio-plugin-portal/SKILL.md` 是随包的 AI 使用指南;使用 AIO 2026.9.18+ 从市场安装后会进入
41
+ `~/.agents/skills/aio-plugin-portal/`。
@@ -0,0 +1,21 @@
1
+ {
2
+ "suffix": "example.internal",
3
+ "mode": "lan",
4
+ "lanIp": "192.168.1.10",
5
+ "hostsFile": "/etc/hosts",
6
+ "resolver": {
7
+ "port": 53,
8
+ "upstreams": ["223.5.5.5", "114.114.114.114"],
9
+ "service": "aio-portal-dns"
10
+ },
11
+ "portal": {
12
+ "root": "/opt/aio-portal",
13
+ "mapPrefix": "10-alias",
14
+ "reload": true
15
+ },
16
+ "gateway": {
17
+ "address": "192.168.1.1",
18
+ "adapter": "xiaomi",
19
+ "fallbackDns": "223.5.5.5"
20
+ }
21
+ }
package/dist/cli.mjs ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // src/portal/commands.ts
7
+ import { existsSync as existsSync3 } from "node:fs";
8
+
9
+ // src/config/index.ts
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ var DEFAULTS = () => ({
14
+ suffix: "",
15
+ mode: "lan",
16
+ lanIp: "",
17
+ hostsFile: "/etc/hosts",
18
+ resolver: { port: 53, upstreams: ["223.5.5.5", "114.114.114.114"] },
19
+ portal: { root: join(homedir(), ".config", "aio-portal"), mapPrefix: "10-alias", reload: true },
20
+ gateway: { adapter: "xiaomi", fallbackDns: "223.5.5.5" }
21
+ });
22
+ function loadSettings(rootOverride) {
23
+ const base = DEFAULTS();
24
+ const root = resolve(rootOverride ?? process.env.AIO_PORTAL_HOME ?? base.portal.root);
25
+ base.portal.root = root;
26
+ const configFile = join(root, "aio-portal.json");
27
+ let file = {};
28
+ if (existsSync(configFile)) file = JSON.parse(readFileSync(configFile, "utf8"));
29
+ const merged = {
30
+ ...base,
31
+ ...file,
32
+ resolver: { ...base.resolver, ...file.resolver },
33
+ portal: { ...base.portal, ...file.portal, root },
34
+ gateway: { ...base.gateway, ...file.gateway }
35
+ };
36
+ if (!merged.suffix) throw new Error(`\u7F3A\u5C11\u57DF\u540D\u540E\u7F00\u914D\u7F6E\uFF0C\u8BF7\u5728 ${configFile} \u4E2D\u8BBE\u7F6E suffix`);
37
+ merged.portal.root = root;
38
+ merged.resolver.configFile ??= join(root, "dns", "aio-portal.conf");
39
+ merged.portal.aliasesDir ??= join(root, "nginx", "aliases.d");
40
+ return merged;
41
+ }
42
+ var registryFile = (s) => join(s.portal.root, "aliases.json");
43
+ function loadRegistry(s) {
44
+ const file = registryFile(s);
45
+ if (!existsSync(file)) return { aliases: [] };
46
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
47
+ return { aliases: parsed.aliases ?? [] };
48
+ }
49
+
50
+ // src/portal/gateway.ts
51
+ import { createHash } from "node:crypto";
52
+ var Http = class {
53
+ constructor(origin, password) {
54
+ this.origin = origin;
55
+ this.password = password;
56
+ }
57
+ origin;
58
+ password;
59
+ async call(path, options = {}) {
60
+ const body = options.form ? new URLSearchParams(options.form).toString() : void 0;
61
+ const headers = { referer: `${this.origin}/`, "user-agent": "Mozilla/5.0" };
62
+ if (body) headers["content-type"] = "application/x-www-form-urlencoded";
63
+ const response = await fetch(`${this.origin}${path}`, {
64
+ method: options.method ?? "GET",
65
+ headers,
66
+ body,
67
+ signal: AbortSignal.timeout(options.timeoutMs ?? 12e3)
68
+ });
69
+ const text = await response.text();
70
+ try {
71
+ return JSON.parse(text);
72
+ } catch {
73
+ throw new Error(`\u7F51\u5173\u8FD4\u56DE\u975E JSON \u54CD\u5E94\uFF08HTTP ${response.status}\uFF09\uFF1A${text.slice(0, 120)}`);
74
+ }
75
+ }
76
+ /** 先明文登录;部分固件需要 nonce + SHA1 流程。 */
77
+ async login() {
78
+ const plain = await this.call("/cgi-bin/luci/api/xqsystem/login", {
79
+ method: "POST",
80
+ form: { username: "admin", logtype: "2", password: this.password }
81
+ });
82
+ const token = plain.token ?? plain.stok;
83
+ if (plain.code === 0 && token) return token;
84
+ const first = await this.call("/cgi-bin/luci/api/xqsystem/login");
85
+ const nonce = first.nonce;
86
+ if (nonce) {
87
+ const pw = createHash("sha1").update(this.password).digest("hex");
88
+ const key = createHash("sha1").update(nonce + pw).digest("hex");
89
+ const second = await this.call("/cgi-bin/luci/api/xqsystem/login", {
90
+ method: "POST",
91
+ form: { username: "admin", logtype: "2", nonce, password: key }
92
+ });
93
+ const token2 = second.token ?? second.stok;
94
+ if (second.code === 0 && token2) return token2;
95
+ throw new Error(`\u7F51\u5173\u767B\u5F55\u5931\u8D25\uFF1A${String(second.msg ?? second)}`);
96
+ }
97
+ throw new Error(`\u7F51\u5173\u767B\u5F55\u5931\u8D25\uFF1A${String(plain.msg ?? plain)}`);
98
+ }
99
+ };
100
+ var XiaomiGateway = class {
101
+ constructor(address, password) {
102
+ this.address = address;
103
+ this.http = new Http(`http://${address}`, password);
104
+ }
105
+ address;
106
+ name = "xiaomi";
107
+ http;
108
+ async api(token, path, form) {
109
+ return await this.http.call(`/cgi-bin/luci/;stok=${token}/api/${path}`, form ? { method: "POST", form } : {});
110
+ }
111
+ async probe() {
112
+ const token = await this.http.login();
113
+ const info = await this.api(token, "xqnetwork/wan_info");
114
+ const root = info.info ?? {};
115
+ const details = root.details ?? {};
116
+ if (!("dnsAddrs" in root)) throw new Error("\u8BE5\u56FA\u4EF6\u7684\u4E0A\u7F51\u8BBE\u7F6E\u672A\u66B4\u9732 DNS \u5B57\u6BB5\uFF0C\u8BF7\u6539\u7528\u5BA2\u6237\u7AEF\u65B9\u5F0F");
117
+ return {
118
+ adapter: this.name,
119
+ wanType: details.wanType,
120
+ current: { dns1: root.dnsAddrs ?? "", dns2: root.dnsAddrs1 ?? "" },
121
+ detail: { username: details.username, mru: details.mru }
122
+ };
123
+ }
124
+ async submit(token, dns, current) {
125
+ const form = {
126
+ wanType: "pppoe",
127
+ pppoeName: String(current.username ?? ""),
128
+ pppoePwd: String(current.password ?? ""),
129
+ autoset: "0",
130
+ mtu: String(current.mru ?? "1480"),
131
+ service: String(current.service ?? ""),
132
+ dns1: dns.dns1,
133
+ dns2: dns.dns2
134
+ };
135
+ const result = await this.api(token, "xqnetwork/set_wan", form);
136
+ if (result.code !== 0) throw new Error(`\u7F51\u5173\u5199\u5165\u5931\u8D25\uFF1A${result.msg ?? JSON.stringify(result)}`);
137
+ }
138
+ async apply(dns) {
139
+ const token = await this.http.login();
140
+ const info = await this.api(token, "xqnetwork/wan_info");
141
+ await this.submit(token, dns, (info.info ?? {}).details ?? {});
142
+ }
143
+ async restore() {
144
+ const token = await this.http.login();
145
+ await this.submit(token, { dns1: "", dns2: "" }, {});
146
+ }
147
+ };
148
+ function gatewayAdapter(config, password) {
149
+ if (config.adapter === "none" || !config.address) throw new Error("\u672A\u914D\u7F6E\u7F51\u5173\u5730\u5740\u6216\u5DF2\u7981\u7528\u7F51\u5173\u9002\u914D\u5668");
150
+ if (config.adapter === "xiaomi") return new XiaomiGateway(config.address, password);
151
+ throw new Error(`\u4E0D\u652F\u6301\u7684\u7F51\u5173\u9002\u914D\u5668\uFF1A${config.adapter}`);
152
+ }
153
+
154
+ // src/portal/registry.ts
155
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
156
+ import { dirname } from "node:path";
157
+ var fqdn = (s, alias) => `${alias}.${s.suffix}`;
158
+ function writeRegistry(s, registry) {
159
+ const file = registryFile(s);
160
+ mkdirSync(dirname(file), { recursive: true, mode: 448 });
161
+ const tmp = `${file}.tmp`;
162
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}
163
+ `, { mode: 384 });
164
+ renameSync(tmp, file);
165
+ }
166
+ function upsert(s, entry) {
167
+ const registry = loadRegistry(s);
168
+ const next = registry.aliases.filter((item) => item.alias !== entry.alias);
169
+ next.push(entry);
170
+ next.sort((a, b) => a.alias.localeCompare(b.alias));
171
+ const updated = { aliases: next };
172
+ writeRegistry(s, updated);
173
+ return updated;
174
+ }
175
+ function remove(s, alias) {
176
+ const registry = loadRegistry(s);
177
+ const removed = registry.aliases.find((item) => item.alias === alias);
178
+ const updated = { aliases: registry.aliases.filter((item) => item.alias !== alias) };
179
+ writeRegistry(s, updated);
180
+ return { registry: updated, removed };
181
+ }
182
+ var aliasFqdn = fqdn;
183
+ function readAliases(s) {
184
+ return loadRegistry(s).aliases.slice().sort((a, b) => a.alias.localeCompare(b.alias));
185
+ }
186
+
187
+ // src/portal/render.ts
188
+ import { execFileSync } from "node:child_process";
189
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
190
+ import { join as join2 } from "node:path";
191
+ var writeLines = (path, lines) => {
192
+ writeFileSync2(path, lines.length ? `${lines.join("\n")}
193
+ ` : "");
194
+ };
195
+ function render(s) {
196
+ const aliases = readAliases(s);
197
+ const dir = s.portal.aliasesDir;
198
+ mkdirSync2(dir, { recursive: true });
199
+ const map = [];
200
+ const scheme = [];
201
+ const host = [];
202
+ for (const item of aliases) {
203
+ const name = aliasFqdn(s, item.alias);
204
+ const target = `${item.host}:${item.port}`;
205
+ map.push(` ${name} ${target};`);
206
+ scheme.push(` ${name} ${item.scheme};`);
207
+ host.push(` ${name} ${target};`);
208
+ }
209
+ const files = [
210
+ join2(dir, `${s.portal.mapPrefix}.map`),
211
+ join2(dir, `${s.portal.mapPrefix}.scheme`),
212
+ join2(dir, `${s.portal.mapPrefix}.host`)
213
+ ];
214
+ writeLines(files[0], map);
215
+ writeLines(files[1], scheme);
216
+ writeLines(files[2], host);
217
+ const resolver = s.resolver.configFile;
218
+ 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`,
222
+ `port=${s.resolver.port}`,
223
+ `listen-address=${s.lanIp || "127.0.0.1"}`,
224
+ "bind-interfaces",
225
+ "no-resolv",
226
+ "no-hosts",
227
+ "cache-size=1000",
228
+ ...s.resolver.upstreams.map((up) => `server=${up}`),
229
+ ...overrides
230
+ ];
231
+ writeFileSync2(resolver, `${lines.join("\n")}
232
+ `);
233
+ return { aliases: aliases.length, files, resolver };
234
+ }
235
+ var run = (program, args) => {
236
+ try {
237
+ const out = execFileSync(program, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
238
+ return { code: 0, out: out.trim() };
239
+ } catch (error) {
240
+ const err = error;
241
+ return { code: err.status ?? 1, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() || (err.message ?? "") };
242
+ }
243
+ };
244
+ function reloadResolver(s) {
245
+ const binary = s.resolver.dnsmasqBinary ?? "dnsmasq";
246
+ const test = run(binary, ["--test", `--conf-file=${s.resolver.configFile}`]);
247
+ 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}`;
251
+ }
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}`;
254
+ }
255
+
256
+ // src/portal/commands.ts
257
+ var isAlias = (value) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value);
258
+ var isHost = (value) => /^[A-Za-z0-9_.:-]+$/.test(value);
259
+ var parsePort = (value) => {
260
+ const port = Number.parseInt(value, 10);
261
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`\u7AEF\u53E3\u4E0D\u5408\u6CD5\uFF1A${value}`);
262
+ return port;
263
+ };
264
+ var settings = (home) => loadSettings(home);
265
+ function add(options) {
266
+ const s = settings(options.home);
267
+ 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
+ if (!isHost(options.host)) throw new Error("\u76EE\u6807\u4E3B\u673A\u4E0D\u5408\u6CD5");
269
+ if (options.scheme !== "http" && options.scheme !== "https") throw new Error("\u534F\u8BAE\u53EA\u80FD\u662F http \u6216 https");
270
+ const entry = {
271
+ alias: options.alias,
272
+ host: options.host,
273
+ port: parsePort(options.port),
274
+ scheme: options.scheme,
275
+ lan: options.lan && s.mode !== "none",
276
+ note: options.note ?? ""
277
+ };
278
+ const registry = upsert(s, entry);
279
+ const result = render(s);
280
+ return [
281
+ `\u5DF2\u767B\u8BB0 ${aliasFqdn(s, entry.alias)} -> ${entry.scheme}://${entry.host}:${entry.port}\uFF08\u5171 ${result.aliases} \u6761\uFF09`,
282
+ renderReload(s)
283
+ ].join("\n");
284
+ }
285
+ function removeAlias(options) {
286
+ const s = settings(options.home);
287
+ const result = remove(s, options.alias);
288
+ if (!result.removed) throw new Error(`\u672A\u627E\u5230\u522B\u540D\uFF1A${options.alias}`);
289
+ const rendered = render(s);
290
+ return [`\u5DF2\u5220\u9664 ${aliasFqdn(s, options.alias)}\uFF08\u5269\u4F59 ${rendered.aliases} \u6761\uFF09`, renderReload(s)].join("\n");
291
+ }
292
+ function list(options) {
293
+ const s = settings(options.home);
294
+ const aliases = readAliases(s);
295
+ 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");
297
+ }
298
+ function sync(options) {
299
+ const s = settings(options.home);
300
+ const result = render(s);
301
+ return [`\u5DF2\u751F\u6210 ${result.aliases} \u6761\u6620\u5C04`, renderReload(s)].join("\n");
302
+ }
303
+ function renderReload(s) {
304
+ if (!s.portal.reload) return "\u5DF2\u8DF3\u8FC7\u670D\u52A1\u70ED\u52A0\u8F7D\uFF08portal.reload=false\uFF09";
305
+ return reloadResolver(s);
306
+ }
307
+ function doctor(options) {
308
+ const s = settings(options.home);
309
+ const resolved = existsSync3(s.resolver.configFile);
310
+ const rendered = existsSync3(`${s.portal.aliasesDir}/${s.portal.mapPrefix}.map`);
311
+ const lines = [
312
+ ` \u57DF\u540D\u540E\u7F00 ${s.suffix}`,
313
+ ` \u89E3\u6790\u6A21\u5F0F ${s.mode}`,
314
+ ` \u5185\u7F51 IP ${s.lanIp || "\uFF08\u672A\u914D\u7F6E\uFF09"}`,
315
+ ` \u6CE8\u518C\u8868 ${readAliases(s).length} \u6761`,
316
+ ` \u6620\u5C04\u7247\u6BB5 ${rendered ? "\u5DF2\u751F\u6210" : "\u7F3A\u5931"}`,
317
+ ` \u89E3\u6790\u914D\u7F6E ${resolved ? "\u5DF2\u751F\u6210" : "\u7F3A\u5931"}`,
318
+ ` \u7F51\u5173\u9002\u914D\u5668 ${s.gateway.adapter}${s.gateway.address ? ` @ ${s.gateway.address}` : "\uFF08\u672A\u914D\u7F6E\u5730\u5740\uFF09"}`
319
+ ];
320
+ const ok = rendered && resolved && Boolean(s.suffix) && Boolean(s.lanIp || s.mode !== "lan");
321
+ 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
+ }
323
+ async function warden(options) {
324
+ const s = settings(options.home);
325
+ const target = s.lanIp || "\u672C\u673A\u5185\u7F51 IP";
326
+ if (!options.password) {
327
+ return [
328
+ "\u7F51\u5173\u7A97\u53E3\uFF1A\u628A\u7F51\u5173\u4E0A\u6E38 DNS \u6307\u5411\u672C\u673A\u89E3\u6790\u5668\uFF0C\u8BA9\u5C40\u57DF\u7F51\u6240\u6709\u8BBE\u5907\u81EA\u52A8\u8D70\u5185\u7F51\u3002",
329
+ ` \u7F51\u5173\u9002\u914D\u5668\uFF1A${s.gateway.adapter}${s.gateway.address ? ` @ ${s.gateway.address}` : "\uFF08\u672A\u914D\u7F6E address\uFF09"}`,
330
+ ` DNS1 \u76EE\u6807\uFF1A${target} DNS2 \u515C\u5E95\uFF1A${s.gateway.fallbackDns}`,
331
+ "",
332
+ " aio-plugin-portal warden --password <\u7F51\u5173\u5BC6\u7801> # \u53EA\u8BFB\u63A2\u6D4B",
333
+ " aio-plugin-portal warden --password <\u7F51\u5173\u5BC6\u7801> --apply # \u5199\u5165",
334
+ " aio-plugin-portal warden --password <\u7F51\u5173\u5BC6\u7801> --restore # \u56DE\u6EDA",
335
+ "",
336
+ "\u624B\u52A8\u7B49\u4EF7\u64CD\u4F5C\uFF1A\u7F51\u5173\u7BA1\u7406\u9875 \u2192 \u4E0A\u7F51\u8BBE\u7F6E \u2192 DNS1 \u586B\u672C\u673A IP\u3001DNS2 \u586B\u516C\u7F51\u89E3\u6790\u5668\u3002"
337
+ ].join("\n");
338
+ }
339
+ const adapter = gatewayAdapter(s.gateway, options.password);
340
+ if (options.restore) {
341
+ await adapter.restore();
342
+ return "\u5DF2\u6062\u590D\u7F51\u5173\u4E0A\u6E38 DNS \u4E3A\u81EA\u52A8\u83B7\u53D6\u3002";
343
+ }
344
+ const probe = await adapter.probe();
345
+ const head = `\u7F51\u5173\u9002\u914D\u5668 ${probe.adapter}\uFF1A\u5F53\u524D ${probe.wanType ?? "-"}\uFF0CDNS ${probe.current.dns1 || "\u81EA\u52A8"} / ${probe.current.dns2 || "\u81EA\u52A8"}`;
346
+ if (!options.apply) return [head, "\u53EA\u8BFB\u63A2\u6D4B\u5B8C\u6210\uFF0C\u672A\u505A\u4FEE\u6539\u3002\u786E\u8BA4\u540E\u52A0 --apply \u5199\u5165\u3002"].join("\n");
347
+ if (!s.lanIp) throw new Error("\u672A\u914D\u7F6E\u5185\u7F51 IP\uFF08lanIp\uFF09\uFF0C\u65E0\u6CD5\u5199\u5165\u7F51\u5173");
348
+ await adapter.apply({ dns1: s.lanIp, dns2: s.gateway.fallbackDns ?? "223.5.5.5" });
349
+ return [head, `\u5DF2\u628A\u7F51\u5173\u4E0A\u6E38 DNS \u8BBE\u4E3A ${s.lanIp}\uFF08\u515C\u5E95 ${s.gateway.fallbackDns}\uFF09\u3002\u5C40\u57DF\u7F51\u8BBE\u5907\u65E0\u9700\u4EFB\u4F55\u8BBE\u7F6E\u5373\u81EA\u52A8\u8D70\u5185\u7F51\u3002`].join("\n");
350
+ }
351
+
352
+ // src/cli.ts
353
+ var USAGE = `aio-plugin-portal <\u547D\u4EE4> [\u9009\u9879]
354
+
355
+ \u547D\u4EE4:
356
+ add <\u522B\u540D> --port <\u7AEF\u53E3> [--host <\u4E3B\u673A>] [--scheme http|https] [--note <\u5907\u6CE8>] [--no-lan]
357
+ remove <\u522B\u540D>
358
+ list
359
+ sync
360
+ doctor
361
+ warden [--password <\u7F51\u5173\u5BC6\u7801>] [--apply] [--restore]
362
+
363
+ \u901A\u7528\u9009\u9879:
364
+ --home <\u76EE\u5F55> \u95E8\u6237\u914D\u7F6E\u6839\u76EE\u5F55\uFF08\u9ED8\u8BA4 AIO_PORTAL_HOME \u6216 ~/.config/aio-portal\uFF09
365
+ --version \u663E\u793A\u7248\u672C
366
+ --help \u663E\u793A\u5E2E\u52A9`;
367
+ async function main() {
368
+ const { values, positionals } = parseArgs({
369
+ allowPositionals: true,
370
+ options: {
371
+ version: { type: "boolean" },
372
+ help: { type: "boolean" },
373
+ host: { type: "string", default: "127.0.0.1" },
374
+ port: { type: "string" },
375
+ scheme: { type: "string", default: "http" },
376
+ note: { type: "string" },
377
+ lan: { type: "boolean", default: true },
378
+ "no-lan": { type: "boolean", default: false },
379
+ home: { type: "string" },
380
+ password: { type: "string" },
381
+ apply: { type: "boolean", default: false },
382
+ restore: { type: "boolean", default: false }
383
+ }
384
+ });
385
+ const [command, argument] = positionals;
386
+ if (values.version) {
387
+ console.log("0.1.0");
388
+ return;
389
+ }
390
+ if (values.help || !command) {
391
+ console.log(USAGE);
392
+ return;
393
+ }
394
+ switch (command) {
395
+ case "add": {
396
+ if (!argument) throw new Error("add \u9700\u8981\u522B\u540D");
397
+ 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 }));
400
+ return;
401
+ }
402
+ case "remove": {
403
+ if (!argument) throw new Error("remove \u9700\u8981\u522B\u540D");
404
+ console.log(removeAlias({ alias: argument, home: values.home }));
405
+ return;
406
+ }
407
+ case "list":
408
+ console.log(list({ home: values.home }));
409
+ return;
410
+ case "sync":
411
+ console.log(sync({ home: values.home }));
412
+ return;
413
+ case "doctor":
414
+ console.log(doctor({ home: values.home }));
415
+ return;
416
+ case "warden":
417
+ console.log(await warden({ password: values.password, apply: values.apply, restore: values.restore, home: values.home }));
418
+ return;
419
+ default:
420
+ throw new Error(`\u672A\u77E5\u547D\u4EE4\uFF1A${command}
421
+
422
+ ${USAGE}`);
423
+ }
424
+ }
425
+ main().catch((error) => {
426
+ console.error(error.message);
427
+ process.exitCode = 1;
428
+ });
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "aio-plugin-portal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "登记别名到内网端口映射,生成反向代理分流与内网 DNS 覆盖,并让网关把整片局域网指向本机解析器。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "aio-plugin-portal": "dist/cli.mjs"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "config",
13
+ "README.md",
14
+ "skills"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.14.0"
18
+ },
19
+ "scripts": {
20
+ "build": "node scripts/build.mjs",
21
+ "test": "npm run build && node --test",
22
+ "prepack": "npm run build",
23
+ "prepare": "node scripts/build.mjs"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "0.28.2"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/org-aio/aio-plugin-portal.git"
35
+ }
36
+ }
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: aio-plugin-portal
3
+ description: 维护端口门户:登记“别名→内网 host:port 映射”,生成反向代理按 Host 分流与内网 DNS 覆盖,并把网关上游 DNS 指向本机解析器,让局域网所有设备无感走内网。当用户要新增/删除 xxx.addzero.site 之类的别名站点、排查内外网解析差异、或让同事整片局域网零配置走内网时使用。
4
+ ---
5
+
6
+ # 端口门户
7
+
8
+ 一次登记同时产生两件事:反向代理按 Host 把 `<别名>.<suffix>` 分流到目标端口;内网 DNS 把该名字解析到本机,于是**内网直达、外网仍走原隧道**。
9
+
10
+ 先执行 `aio-plugin-portal --help` 核对当前命令,`aio-plugin-portal --version` 确认版本。未全局安装时用 `npx -y aio-plugin-portal`。
11
+
12
+ ## 常用命令
13
+
14
+ ```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 # 只做分流,不做内网覆盖
17
+ aio-plugin-portal list
18
+ 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
24
+ ```
25
+
26
+ ## 配置
27
+
28
+ 宿主相关的值全部来自配置,源码不含域名、网段、账号或部署目录:
29
+
30
+ - 默认读取 `~/.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` 均可配置。
32
+ - 新增站点只改注册表(`aio-plugin-portal add` 或编辑 `aliases.json`),无需改代码。
33
+
34
+ ## 约定
35
+
36
+ - 网关改动默认只读,写入必须显式 `--apply`,并支持 `--restore` 回滚;会同时影响经该网关上网的所有设备。
37
+ - 只想让个别机器走内网时,不动网关,改用客户端按域名分流(例如 macOS `/etc/resolver/<suffix>`)。
38
+ - 通过 AIO 市场安装后,本文件随 npm 包进入 `~/.agents/skills/aio-plugin-portal/SKILL.md`。