iphealth 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # iphealth
2
+
3
+ 在新 VPS 上一键按需部署完整套件:**VLESS + Reality + Hysteria2**、**代理订阅服务**、**IP 健康监控**(VictoriaMetrics + blackbox + vmagent + nginx /vm)及系统/网络优化。
4
+
5
+ ## 使用方式
6
+
7
+ ```bash
8
+ sudo -E npx iphealth@latest
9
+ ```
10
+
11
+ 进入交互式 CLI,按提示勾选模块并输入必要参数(敏感信息仅通过交互或环境变量注入,不入库、不打印明文)。
12
+
13
+ ## 模块说明
14
+
15
+ | 模块 ID | 说明 |
16
+ |--------|------|
17
+ | **系统基础与安全** | |
18
+ | `base` | 基础初始化:apt update/upgrade(可选)、curl/jq/unzip/nginx、时区 |
19
+ | `ssh_harden` | SSH 安全加固:禁用密码登录、修改端口(有断连风险,需二次确认) |
20
+ | `firewall` | UFW:放行 22 / 80 / 443,开启前二次确认 |
21
+ | **网络加速与系统调优** | |
22
+ | `bbr` | BBR + TCP 调优,写入 `/etc/sysctl.d/99-iphealth.conf`,可回滚 |
23
+ | `ulimit` | 提高 NOFILE,作用于 xray / vmagent / victoriametrics 等服务 |
24
+ | **代理内核** | |
25
+ | `xray_core` | 安装 Xray 到 `/usr/local/bin/xray`,systemd 单元不自动启动 |
26
+ | `xray_config` | 生成 `/etc/xray/config.json`(VLESS+Reality,端口默认 443),权限 600 |
27
+ | **代理订阅服务** | |
28
+ | `sub_source` | 生成 `/etc/iphealth/subscription/source.json`(节点源数据),权限 600 |
29
+ | `sub_builder` | 订阅构建服务:127.0.0.1:29100,systemd `iphealth-sub.service`,X-Sub-Token / query token |
30
+ | `sub_nginx` | nginx 80 暴露 `/sub/` → 127.0.0.1:29100,不占用 443 |
31
+ | `sub_policy` | 订阅内容策略:最小 Clash/Stash YAML(proxies + PROXY/DIRECT + MATCH,PROXY) |
32
+ | **IPhealth 监控栈** | |
33
+ | `vm` | VictoriaMetrics,127.0.0.1:8428,retention 默认 60d |
34
+ | `blackbox` | blackbox_exporter,127.0.0.1:9115,modules: http/https/icmp/github_head |
35
+ | `vmagent` | 抓取 blackbox → 写入本地 VM,127.0.0.1:8429,探测目标运行时输入 |
36
+ | `nginx_vm` | nginx 80 暴露 `/vm/`,X-VM-Token 鉴权,反代到本机 18080(VM 只读) |
37
+
38
+ ## 订阅 URL 示例(占位)
39
+
40
+ - Clash:`http://<DOMAIN>/sub/clash?token=<TOKEN>`
41
+ 或 Header:`X-Sub-Token: <TOKEN>`
42
+ - Stash:`http://<DOMAIN>/sub/stash?token=<TOKEN>`
43
+
44
+ 将 `<DOMAIN>` 替换为服务器域名或 IP,`<TOKEN>` 替换为部署时生成/输入的订阅 Token。
45
+
46
+ ## 监控 /vm URL 示例(占位)
47
+
48
+ - 只读查询(GET):`http://<DOMAIN>/vm/api/v1/query?query=...`
49
+ 请求头:`X-VM-Token: <VM_TOKEN>`
50
+
51
+ 将 `<DOMAIN>` 替换为 nginx 中配置的 `server_name`,`<VM_TOKEN>` 为部署时生成/输入的 X-VM-Token。
52
+
53
+ ## 安全提示
54
+
55
+ - **不要将任何生成的配置或密钥提交到 Git**。`/etc/xray/config.json`、`/etc/iphealth/subscription/source.json`、`token`、X-VM-Token 等均为敏感信息。
56
+ - 使用**密码管理器**保存 UUID、Reality 私钥/公钥、订阅 Token、VM Token。
57
+ - 本工具不在 NPM 包、Git 仓库或模板中包含域名、IP、UUID、密钥、Token;所有敏感信息仅在**运行时交互输入或环境变量**注入。
58
+ - 落盘敏感文件均为 **权限 600、归属 root**;CLI 日志**不打印敏感明文**,最多显示末尾 4 位用于确认。
59
+ - 涉及系统级变更(sysctl / 防火墙 / SSH)时,会**先展示变更摘要并二次确认**后再执行。
60
+
61
+ ## 回滚建议
62
+
63
+ - **sysctl**:删除 `/etc/sysctl.d/99-iphealth.conf` 后执行 `sysctl -p` 或重启。
64
+ - **systemd 服务**:`sudo systemctl stop <service>; sudo systemctl disable <service>`(如 xray、iphealth-sub、victoriametrics、blackbox_exporter、vmagent)。
65
+ - **nginx**:删除 `/etc/nginx/conf.d/iphealth-*.conf` 后 `nginx -t && systemctl reload nginx`。
66
+
67
+ ## NPM 发布说明
68
+
69
+ ```bash
70
+ npm login
71
+ npm publish --access public
72
+ ```
73
+
74
+ 发布后用户可通过 `npx iphealth@latest` 使用。
75
+
76
+ ## 本地开发 / 试运行
77
+
78
+ ```bash
79
+ cd iphealth-cli
80
+ node bin/cli.js
81
+ # 或仅生成计划不执行
82
+ node bin/cli.js --dry-run
83
+ ```
84
+
85
+ ## 示例运行输出(无敏感信息)
86
+
87
+ ```
88
+ iphealth — 一键按需部署:VLESS+Reality / Hysteria2 / 订阅 / IP 健康监控
89
+
90
+ 可选模块(按组):
91
+ [系统基础与安全]
92
+ base: 基础初始化 (apt/nginx/时区)
93
+ ...
94
+ 输入要启用的模块 ID,逗号分隔: base,xray_core,xray_config,sub_source,sub_builder,sub_nginx
95
+ 已选(含依赖): base, xray_core, xray_config, sub_source, sub_builder, sub_nginx
96
+ UUID: ...xyz1
97
+ Reality privateKey: ...abcd
98
+ X-Sub-Token: ...ef12
99
+ --- 执行计划 (plan.json) ---
100
+ { "version": "1.0", "modules": [...], "options": {...}, "secretRefs": { "xray_uuid": "/tmp/iphealth-secrets.xxx/xray_uuid", ... } }
101
+ Ports: 22 (SSH), 80, 443; 443 (Xray); 29100 (subscription, 127.0.0.1); 80 /sub/
102
+ 是否执行 apply?(将运行 sudo bash apply.sh) (y/N):
103
+ ```
104
+
105
+ ## 依赖
106
+
107
+ - Node.js >= 18
108
+ - 执行 apply 的机器需具备:`jq`(解析 plan.json)、`sudo`、root 权限以写入 /etc 与敏感文件。
109
+ - **二进制**:apply 脚本仅部署配置与 systemd 单元,不自动下载 Xray / blackbox_exporter / VictoriaMetrics / vmagent。部署前请自行将对应二进制安装到 `/usr/local/bin/`(如从 GitHub releases 下载),或先选 `base` 安装 curl/jq 后手动执行下载脚本。
110
+
111
+ ## License
112
+
113
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { getPackageRoot, mask, backupSuffix } = require('../lib/utils');
8
+ const { ask, askConfirm, close, validatePort, validateUuid, randomHex, generateUuid, logMasked } = require('../lib/prompts');
9
+ const { getModules, resolveSelection, printModuleList } = require('../lib/menu');
10
+ const { buildPlan } = require('../lib/plan');
11
+ const { emit } = require('../lib/emit');
12
+ const { runApply } = require('../lib/runner');
13
+
14
+ const DRY_RUN = process.argv.includes('--dry-run');
15
+
16
+ function isRoot() {
17
+ return process.getuid && process.getuid() === 0;
18
+ }
19
+
20
+ async function main() {
21
+ console.log('iphealth — 一键按需部署:VLESS+Reality / Hysteria2 / 订阅 / IP 健康监控\n');
22
+
23
+ if (!DRY_RUN && process.platform === 'linux' && !isRoot()) {
24
+ console.log('提示:建议使用 sudo -E npx iphealth@latest 以正确写入敏感文件权限。\n');
25
+ }
26
+
27
+ printModuleList();
28
+ const sel = await ask('输入要启用的模块 ID,逗号分隔(如 base,xray_core,xray_config,sub_source,sub_builder,sub_nginx)');
29
+ const selectedIds = sel ? sel.split(',').map((s) => s.trim()).filter(Boolean) : [];
30
+ if (selectedIds.length === 0) {
31
+ console.log('未选择任何模块,退出。');
32
+ close();
33
+ process.exit(0);
34
+ }
35
+
36
+ const resolved = resolveSelection(selectedIds);
37
+ console.log('\n已选(含依赖):', resolved.join(', '));
38
+
39
+ const options = {};
40
+ const secretRefs = {};
41
+ const secretsDir = path.join(os.tmpdir(), `iphealth-secrets.${Date.now()}`);
42
+ fs.mkdirSync(secretsDir, { recursive: true });
43
+ if (process.getuid && process.getuid() === 0) {
44
+ try { fs.chmodSync(secretsDir, 0o700); } catch (_) {}
45
+ }
46
+
47
+ for (const id of resolved) {
48
+ if (id === 'base') {
49
+ options.apt_upgrade = await askConfirm('执行 apt update && upgrade?', false);
50
+ options.timezone = await ask('时区 (留空跳过)', { default: '' });
51
+ }
52
+ if (id === 'ssh_harden') {
53
+ console.log('SSH 加固可能导致断连,请确保已用 key 登录。');
54
+ options.ssh_disable_password = await askConfirm('禁用密码登录?', false);
55
+ options.ssh_port = await ask('SSH 端口 (留空保持 22)', { default: '22' });
56
+ }
57
+ if (id === 'firewall') {
58
+ console.log('将放行:22 (SSH), 80, 443。请二次确认。');
59
+ const ok = await askConfirm('确认启用 UFW 并应用上述规则?', false);
60
+ if (!ok) { resolved.splice(resolved.indexOf(id), 1); continue; }
61
+ }
62
+ if (id === 'bbr') {
63
+ const ok = await askConfirm('将写入 /etc/sysctl.d/99-iphealth.conf 并应用 BBR,确认?', true);
64
+ if (!ok) { resolved.splice(resolved.indexOf(id), 1); continue; }
65
+ }
66
+ if (id === 'xray_core') {
67
+ options.xray_version = await ask('Xray 版本 (如 1.8.4,留空用 latest)', { default: '1.8.4' });
68
+ }
69
+ if (id === 'xray_config') {
70
+ options.xray_port = parseInt(await ask('Xray 监听端口', { default: '443' }), 10) || 443;
71
+ const uuidInput = await ask('UUID (留空自动生成)');
72
+ const uuid = uuidInput && validateUuid(uuidInput) ? uuidInput : generateUuid();
73
+ const uuidPath = path.join(secretsDir, 'xray_uuid');
74
+ fs.writeFileSync(uuidPath, uuid, 'utf8');
75
+ if (process.getuid === 0) fs.chmodSync(uuidPath, 0o600);
76
+ secretRefs.xray_uuid = uuidPath;
77
+ logMasked('UUID', uuid);
78
+
79
+ const genReality = await askConfirm('自动生成 Reality 私钥?(公钥需用 xray x25519 从私钥导出)', true);
80
+ let privKey, pubKey;
81
+ if (genReality) {
82
+ privKey = require('crypto').randomBytes(32).toString('base64').replace(/=/g, '');
83
+ console.log(' → 请在本机或 VPS 上运行: xray x25519 (将生成的 private key 替换当前私钥,或直接输入下方公钥)');
84
+ pubKey = await ask('Reality publicKey (base64,由 xray x25519 输出)');
85
+ } else {
86
+ privKey = await ask('Reality privateKey (base64)');
87
+ pubKey = await ask('Reality publicKey (base64)');
88
+ }
89
+ if (privKey) {
90
+ fs.writeFileSync(path.join(secretsDir, 'xray_private_key'), privKey, 'utf8');
91
+ if (process.getuid === 0) fs.chmodSync(path.join(secretsDir, 'xray_private_key'), 0o600);
92
+ secretRefs.xray_private_key = path.join(secretsDir, 'xray_private_key');
93
+ }
94
+ if (pubKey) {
95
+ fs.writeFileSync(path.join(secretsDir, 'reality_public_key'), pubKey, 'utf8');
96
+ if (process.getuid === 0) fs.chmodSync(path.join(secretsDir, 'reality_public_key'), 0o600);
97
+ secretRefs.reality_public_key = path.join(secretsDir, 'reality_public_key');
98
+ }
99
+ logMasked('Reality privateKey', privKey);
100
+ options.reality_short_ids = [(await ask('shortId (留空自动生成)', { default: randomHex(4) })) || randomHex(4)];
101
+ options.reality_server_names = [(await ask('serverName / SNI (如 www.cloudflare.com)', { default: 'www.cloudflare.com' }))];
102
+ options.reality_dest = await ask('Reality dest (如 www.cloudflare.com:443)', { default: 'www.cloudflare.com:443' });
103
+ options.xray_flow = await ask('flow', { default: 'xtls-rprx-vision' });
104
+ options.sub_node_name = await ask('订阅节点名称', { default: 'node1' });
105
+ options.sub_address = await ask('订阅显示的地址 (域名)', { default: options.reality_dest?.split(':')[0] || 'example.com' });
106
+ }
107
+ if (id === 'sub_builder') {
108
+ const tok = await ask('订阅 Token (留空自动生成)');
109
+ const subToken = tok || randomHex(24);
110
+ fs.writeFileSync(path.join(secretsDir, 'sub_token'), subToken, 'utf8');
111
+ if (process.getuid === 0) fs.chmodSync(path.join(secretsDir, 'sub_token'), 0o600);
112
+ secretRefs.sub_token = path.join(secretsDir, 'sub_token');
113
+ logMasked('X-Sub-Token', subToken);
114
+ }
115
+ if (id === 'sub_nginx') {
116
+ options.sub_domain = await ask('订阅域名 (留空则用 server_name _)', { default: '' });
117
+ }
118
+ if (id === 'vm') {
119
+ options.vm_retention = await ask('VictoriaMetrics 保留期', { default: '60d' });
120
+ options.vm_data_path = await ask('数据目录', { default: '/var/lib/victoriametrics' });
121
+ }
122
+ if (id === 'vmagent') {
123
+ options.probe_target = await ask('探测目标 URL (如 https://example.com)', { default: 'https://example.com' });
124
+ options.probe_target_host = await ask('探测目标主机 (icmp)', { default: 'example.com' });
125
+ }
126
+ if (id === 'nginx_vm') {
127
+ options.vm_nginx_server_name = await ask('监控 /vm 的 server_name (域名)', { default: 'monitor.example.com' });
128
+ const vt = await ask('X-VM-Token (留空自动生成)');
129
+ const vmToken = vt || randomHex(24);
130
+ fs.writeFileSync(path.join(secretsDir, 'vm_token'), vmToken, 'utf8');
131
+ if (process.getuid === 0) fs.chmodSync(path.join(secretsDir, 'vm_token'), 0o600);
132
+ secretRefs.vm_token = path.join(secretsDir, 'vm_token');
133
+ logMasked('X-VM-Token', vmToken);
134
+ }
135
+ }
136
+
137
+ const plan = buildPlan(resolved, options, secretRefs);
138
+ const outDir = path.join(os.tmpdir(), `iphealth-apply.${Date.now()}`);
139
+ emit(plan, outDir, secretsDir);
140
+
141
+ const planPath = path.join(outDir, 'plan.json');
142
+ const applyPath = path.join(outDir, 'apply.sh');
143
+ console.log('\n--- 执行计划 (plan.json) ---');
144
+ console.log(JSON.stringify(plan, null, 2).replace(/\n/g, '\n '));
145
+ console.log('\n--- 敏感信息仅存于文件,未写入 plan ---');
146
+ console.log('Ports:', plan.summary?.ports?.join('; ') || '');
147
+ console.log('Files:', plan.summary?.files?.join(', ') || '');
148
+ console.log('Services:', plan.summary?.services?.join(', ') || '');
149
+
150
+ const proceed = await askConfirm('\n是否执行 apply?(将运行 sudo bash apply.sh)', false);
151
+ close();
152
+
153
+ if (!proceed) {
154
+ console.log('已取消。计划与脚本位于:', outDir);
155
+ console.log('可稍后手动执行: sudo bash', applyPath);
156
+ process.exit(0);
157
+ }
158
+
159
+ try {
160
+ await runApply(applyPath);
161
+ console.log('\n部署完成。请查看上方 summary。');
162
+ } catch (e) {
163
+ console.error(e.message);
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ main().catch((err) => {
169
+ console.error(err);
170
+ process.exit(1);
171
+ });
package/lib/emit.js ADDED
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { resolveInPackage, render } = require('./utils');
6
+
7
+ const TEMPLATES_DIR = 'templates';
8
+ const SCRIPTS_DIR = 'scripts';
9
+
10
+ function loadTemplate(name) {
11
+ const p = resolveInPackage(TEMPLATES_DIR, name);
12
+ if (!fs.existsSync(p)) throw new Error(`Template not found: ${name}`);
13
+ return fs.readFileSync(p, 'utf8');
14
+ }
15
+
16
+ function loadScript(name) {
17
+ const p = resolveInPackage(SCRIPTS_DIR, name);
18
+ if (!fs.existsSync(p)) throw new Error(`Script not found: ${name}`);
19
+ return fs.readFileSync(p, 'utf8');
20
+ }
21
+
22
+ /**
23
+ * Emit all artifacts to outDir.
24
+ * - Rendered config files (with placeholders like __SECRET_FILE:xray_private_key__ that apply.sh will replace)
25
+ * - plan.json
26
+ * - apply.sh (from apply.sh.tpl, with plan path and secrets dir)
27
+ * Secrets are NOT in artifacts; they're in secretsDir. Plan references secretsDir paths.
28
+ */
29
+ function emit(plan, outDir, secretsDir) {
30
+ const planPath = path.join(outDir, 'plan.json');
31
+ const applyPath = path.join(outDir, 'apply.sh');
32
+ const stagedDir = path.join(outDir, 'staged');
33
+
34
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
35
+ if (!fs.existsSync(stagedDir)) fs.mkdirSync(stagedDir, { recursive: true });
36
+
37
+ fs.writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf8');
38
+
39
+ const applyTpl = loadScript('apply.sh.tpl');
40
+ const applyContent = render(applyTpl, {
41
+ PLAN_PATH: planPath,
42
+ SECRETS_DIR: secretsDir,
43
+ STAGED_DIR: stagedDir,
44
+ });
45
+ fs.writeFileSync(applyPath, applyContent, 'utf8');
46
+ fs.chmodSync(applyPath, 0o755);
47
+
48
+ const modules = plan.modules || [];
49
+ const opts = plan.options || {};
50
+ const refs = plan.secretRefs || {};
51
+
52
+ if (modules.includes('bbr')) {
53
+ const tpl = loadTemplate('sysctl_iphealth.conf.tpl');
54
+ fs.writeFileSync(path.join(stagedDir, '99-iphealth.conf'), render(tpl, {}), 'utf8');
55
+ }
56
+
57
+ if (modules.includes('xray_config')) {
58
+ const tpl = loadTemplate('xray_config.json.tpl');
59
+ const content = render(tpl, {
60
+ XRAY_PORT: opts.xray_port ?? 443,
61
+ REALITY_SERVER_NAMES: JSON.stringify(opts.reality_server_names || ['example.com']),
62
+ REALITY_SHORT_IDS: JSON.stringify(opts.reality_short_ids || ['']),
63
+ REALITY_DEST: (opts.reality_dest || 'example.com:443'),
64
+ FLOW: opts.xray_flow || 'xtls-rprx-vision',
65
+ });
66
+ fs.writeFileSync(path.join(stagedDir, 'xray_config.json'), content, 'utf8');
67
+ // Private key and UUID will be injected by apply.sh from secret files
68
+ }
69
+
70
+ if (modules.includes('sub_source')) {
71
+ const tpl = loadTemplate('source.json.tpl');
72
+ const content = render(tpl, {
73
+ NODE_NAME: opts.sub_node_name || 'node1',
74
+ ADDRESS: opts.sub_address || 'example.com',
75
+ PORT: opts.xray_port ?? 443,
76
+ FLOW: opts.xray_flow || 'xtls-rprx-vision',
77
+ SERVER_NAME: opts.reality_server_names?.[0] || opts.reality_dest?.split(':')[0] || 'example.com',
78
+ SHORT_ID: opts.reality_short_ids?.[0] || '',
79
+ });
80
+ fs.writeFileSync(path.join(stagedDir, 'source.json'), content, 'utf8');
81
+ }
82
+
83
+ if (modules.includes('sub_builder')) {
84
+ const tpl = loadTemplate('subscription_service.tpl');
85
+ fs.writeFileSync(path.join(stagedDir, 'iphealth-sub.service'), render(tpl, {
86
+ LISTEN_PORT: '29100',
87
+ }), 'utf8');
88
+ const subServer = fs.readFileSync(resolveInPackage(TEMPLATES_DIR, 'sub_server.js'), 'utf8');
89
+ fs.writeFileSync(path.join(stagedDir, 'sub_server.js'), subServer, 'utf8');
90
+ }
91
+
92
+ if (modules.includes('sub_nginx')) {
93
+ const tpl = loadTemplate('nginx_sub.conf.tpl');
94
+ fs.writeFileSync(path.join(stagedDir, 'iphealth-sub.conf'), render(tpl, {
95
+ SUB_UPSTREAM_PORT: '29100',
96
+ }), 'utf8');
97
+ }
98
+
99
+ if (modules.includes('vm')) {
100
+ const tpl = loadTemplate('victoriametrics.service.tpl');
101
+ fs.writeFileSync(path.join(stagedDir, 'victoriametrics.service'), render(tpl, {
102
+ RETENTION: opts.vm_retention || '60d',
103
+ DATA_PATH: opts.vm_data_path || '/var/lib/victoriametrics',
104
+ LISTEN_ADDR: '127.0.0.1:8428',
105
+ }), 'utf8');
106
+ }
107
+
108
+ if (modules.includes('blackbox')) {
109
+ const tpl = loadTemplate('blackbox.yml.tpl');
110
+ fs.writeFileSync(path.join(stagedDir, 'blackbox.yml'), tpl, 'utf8');
111
+ const svc = loadTemplate('blackbox.service.tpl');
112
+ fs.writeFileSync(path.join(stagedDir, 'blackbox_exporter.service'), svc, 'utf8');
113
+ }
114
+
115
+ if (modules.includes('vmagent')) {
116
+ const tpl = loadTemplate('vmagent_scrape.yml.tpl');
117
+ const probeTarget = opts.probe_target || 'https://example.com';
118
+ const probeHost = opts.probe_target_host || 'example.com';
119
+ fs.writeFileSync(path.join(stagedDir, 'vmagent.yml'), render(tpl, {
120
+ PROBE_TARGET_HTTP: probeTarget,
121
+ PROBE_TARGET_HTTPS: probeTarget,
122
+ PROBE_TARGET_ICMP: probeHost,
123
+ EXTERNAL_LABELS: JSON.stringify({ env: 'iphealth' }),
124
+ }), 'utf8');
125
+ const svc = loadTemplate('vmagent.service.tpl');
126
+ fs.writeFileSync(path.join(stagedDir, 'vmagent.service'), render(svc, {
127
+ CONFIG_PATH: '/etc/vmagent/vmagent.yml',
128
+ }), 'utf8');
129
+ }
130
+
131
+ if (modules.includes('nginx_vm')) {
132
+ const tplReadonly = loadTemplate('nginx_vm_readonly.conf.tpl');
133
+ fs.writeFileSync(path.join(stagedDir, 'iphealth-vm-readonly.conf'), render(tplReadonly, {
134
+ VM_BACKEND: '127.0.0.1:8428',
135
+ LISTEN_VM: '18080',
136
+ }), 'utf8');
137
+ const tpl80 = loadTemplate('nginx_vm_80.conf.tpl');
138
+ fs.writeFileSync(path.join(stagedDir, 'iphealth-vm-80.conf'), render(tpl80, {
139
+ SERVER_NAME: opts.vm_nginx_server_name || 'monitor.example.com',
140
+ VM_UPSTREAM: '127.0.0.1:18080',
141
+ }), 'utf8');
142
+ }
143
+
144
+ if (modules.includes('xray_core') || modules.includes('xray_config')) {
145
+ const tpl = loadTemplate('xray.service.tpl');
146
+ fs.writeFileSync(path.join(stagedDir, 'xray.service'), render(tpl, {}), 'utf8');
147
+ }
148
+
149
+ return { planPath, applyPath, stagedDir };
150
+ }
151
+
152
+ module.exports = { loadTemplate, loadScript, emit };
package/lib/menu.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const MODULES = [
4
+ { id: 'base', label: '基础初始化 (apt/nginx/时区)', group: '系统基础与安全' },
5
+ { id: 'ssh_harden', label: 'SSH 安全加固 (禁用密码/改端口)', group: '系统基础与安全' },
6
+ { id: 'firewall', label: '防火墙 UFW (放行 SSH/80/443)', group: '系统基础与安全' },
7
+ { id: 'bbr', label: 'BBR / TCP 调优 (sysctl)', group: '网络加速与系统调优' },
8
+ { id: 'ulimit', label: '资源限制 ulimit (NOFILE)', group: '网络加速与系统调优' },
9
+ { id: 'xray_core', label: '安装 Xray 核心', group: '代理内核' },
10
+ { id: 'xray_config', label: '生成 Xray 配置 (VLESS+Reality)', group: '代理内核', deps: ['xray_core'] },
11
+ { id: 'sub_source', label: '订阅源数据 source.json', group: '代理订阅服务', deps: ['xray_config'] },
12
+ { id: 'sub_builder', label: '订阅构建服务 (29100)', group: '代理订阅服务', deps: ['sub_source'] },
13
+ { id: 'sub_nginx', label: '订阅公网出口 nginx /sub/', group: '代理订阅服务', deps: ['sub_builder'] },
14
+ { id: 'sub_policy', label: '订阅内容策略 (Clash/Stash)', group: '代理订阅服务', deps: ['sub_builder'] },
15
+ { id: 'vm', label: '安装 VictoriaMetrics', group: 'IPhealth 监控栈' },
16
+ { id: 'blackbox', label: '安装 blackbox_exporter', group: 'IPhealth 监控栈' },
17
+ { id: 'vmagent', label: '安装 vmagent', group: 'IPhealth 监控栈', deps: ['vm', 'blackbox'] },
18
+ { id: 'nginx_vm', label: 'nginx 暴露 /vm (X-VM-Token)', group: 'IPhealth 监控栈', deps: ['vm'] },
19
+ ];
20
+
21
+ const GROUPS = [...new Set(MODULES.map((m) => m.group))];
22
+
23
+ function getModules() {
24
+ return MODULES;
25
+ }
26
+
27
+ function getModuleById(id) {
28
+ return MODULES.find((m) => m.id === id);
29
+ }
30
+
31
+ /** Resolve selection: add required deps (by id), return ordered list */
32
+ function resolveSelection(selectedIds) {
33
+ const set = new Set(selectedIds);
34
+ let changed = true;
35
+ while (changed) {
36
+ changed = false;
37
+ for (const m of MODULES) {
38
+ if (!set.has(m.id)) continue;
39
+ const deps = m.deps || [];
40
+ for (const d of deps) {
41
+ if (!set.has(d)) {
42
+ set.add(d);
43
+ changed = true;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const order = [];
49
+ const added = new Set();
50
+ function add(id) {
51
+ if (added.has(id)) return;
52
+ const m = getModuleById(id);
53
+ (m.deps || []).forEach(add);
54
+ order.push(id);
55
+ added.add(id);
56
+ }
57
+ [...set].forEach(add);
58
+ return order;
59
+ }
60
+
61
+ /** Print module list by group for checkbox-style selection */
62
+ function printModuleList() {
63
+ console.log('\n可选模块(按组):\n');
64
+ for (const g of GROUPS) {
65
+ console.log(`[${g}]`);
66
+ MODULES.filter((m) => m.group === g).forEach((m, i) => {
67
+ const dep = m.deps && m.deps.length ? ` (依赖: ${m.deps.join(', ')})` : '';
68
+ console.log(` ${m.id}: ${m.label}${dep}`);
69
+ });
70
+ console.log('');
71
+ }
72
+ }
73
+
74
+ module.exports = {
75
+ getModules,
76
+ getModuleById,
77
+ resolveSelection,
78
+ printModuleList,
79
+ MODULES,
80
+ GROUPS,
81
+ };
package/lib/plan.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Build plan.json (human-readable, no plaintext secrets).
5
+ * Secrets are written to files under secretsDir; plan only stores paths in secretRefs.
6
+ */
7
+ function buildPlan(selectedModules, options, secretRefs) {
8
+ const plan = {
9
+ version: '1.0',
10
+ generatedAt: new Date().toISOString(),
11
+ modules: selectedModules,
12
+ options: { ...options },
13
+ secretRefs: { ...secretRefs },
14
+ summary: {
15
+ ports: [],
16
+ files: [],
17
+ services: [],
18
+ },
19
+ };
20
+
21
+ if (selectedModules.includes('firewall')) {
22
+ plan.summary.ports.push('22 (SSH), 80, 443');
23
+ }
24
+ if (selectedModules.includes('xray_config')) {
25
+ plan.summary.ports.push(`${options.xray_port || 443} (Xray)`);
26
+ }
27
+ if (selectedModules.includes('sub_builder')) {
28
+ plan.summary.ports.push('29100 (subscription, 127.0.0.1)');
29
+ }
30
+ if (selectedModules.includes('sub_nginx')) {
31
+ plan.summary.ports.push('80 /sub/');
32
+ }
33
+ if (selectedModules.includes('vm')) {
34
+ plan.summary.ports.push('8428 (VictoriaMetrics, 127.0.0.1)');
35
+ }
36
+ if (selectedModules.includes('nginx_vm')) {
37
+ plan.summary.ports.push('80 /vm (X-VM-Token)');
38
+ }
39
+ if (selectedModules.includes('blackbox')) {
40
+ plan.summary.ports.push('9115 (blackbox, 127.0.0.1)');
41
+ }
42
+ if (selectedModules.includes('vmagent')) {
43
+ plan.summary.ports.push('8429 (vmagent, 127.0.0.1)');
44
+ }
45
+
46
+ const files = [];
47
+ if (selectedModules.includes('bbr')) files.push('/etc/sysctl.d/99-iphealth.conf');
48
+ if (selectedModules.includes('xray_config')) files.push('/etc/xray/config.json');
49
+ if (selectedModules.includes('sub_source')) files.push('/etc/iphealth/subscription/source.json');
50
+ if (selectedModules.includes('sub_builder')) {
51
+ files.push('/etc/iphealth/subscription/token');
52
+ files.push('/etc/systemd/system/iphealth-sub.service');
53
+ }
54
+ if (selectedModules.includes('sub_nginx')) files.push('/etc/nginx/conf.d/iphealth-sub.conf');
55
+ if (selectedModules.includes('vm')) files.push('/etc/systemd/system/victoriametrics.service');
56
+ if (selectedModules.includes('blackbox')) {
57
+ files.push('/etc/blackbox_exporter/blackbox.yml');
58
+ files.push('/etc/systemd/system/blackbox_exporter.service');
59
+ }
60
+ if (selectedModules.includes('vmagent')) {
61
+ files.push('/etc/vmagent/vmagent.yml');
62
+ files.push('/etc/systemd/system/vmagent.service');
63
+ }
64
+ if (selectedModules.includes('nginx_vm')) {
65
+ files.push('/etc/nginx/conf.d/iphealth-vm-readonly.conf');
66
+ files.push('/etc/nginx/conf.d/iphealth-vm-80.conf');
67
+ }
68
+ plan.summary.files = [...new Set(files)];
69
+
70
+ const services = [];
71
+ if (selectedModules.includes('xray_core') || selectedModules.includes('xray_config')) services.push('xray');
72
+ if (selectedModules.includes('sub_builder')) services.push('iphealth-sub');
73
+ if (selectedModules.includes('vm')) services.push('victoriametrics');
74
+ if (selectedModules.includes('blackbox')) services.push('blackbox_exporter');
75
+ if (selectedModules.includes('vmagent')) services.push('vmagent');
76
+ plan.summary.services = services;
77
+
78
+ return plan;
79
+ }
80
+
81
+ module.exports = { buildPlan };