network-proxy-guard 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.
@@ -0,0 +1,13 @@
1
+ # Offline mobile QR delivery
2
+
3
+ Use the same `node.json` used for Clash setup. The CLI builds a VLESS Reality URI and renders it locally as `shadowrocket-qr.png`; it also writes `vless-link.txt`. No online QR service is used. Reality X25519 public keys must be exactly 43 base64url characters. `flow` is included only when explicitly present in the node config; do not blindly add Vision to every VLESS node.
4
+
5
+ ```bash
6
+ network-proxy-guard qr --config /private/path/node.json --output /private/path/mobile-qr
7
+ # Or as part of an approved setup:
8
+ network-proxy-guard setup --config /private/path/node.json --yes --qr-dir /private/path/mobile-qr
9
+ ```
10
+
11
+ The QR payload is the complete VLESS URI. Both files are sensitive credentials and are created with owner-only permissions. Report file paths, not the URI, and never commit or upload them.
12
+
13
+ Shadowrocket versions with VLESS Reality support can scan the QR. Other mobile apps may require a different URI or import format; do not promise universal compatibility. After import, use rule mode where appropriate and verify the egress matches the computer. Regenerate both desktop and mobile artifacts after an IP, port, UUID or Reality parameter change.
@@ -0,0 +1,31 @@
1
+ # Xray VLESS Reality server runbook
2
+
3
+ Use this runbook only when the user explicitly asks to create or change a server. It is not part of client-side setup by default.
4
+
5
+ ## Before changing anything
6
+
7
+ - Confirm the VPS provider, OS, SSH access, intended domain/port, and whether a snapshot/backup exists.
8
+ - Show the planned packages, services, ports, firewall/security-group changes, and restart window. Get separate VPS approval.
9
+ - Preserve password login until key login has been tested in a second independent session. Do not lock the user out.
10
+ - Use official Xray releases or another user-approved source; prefer pinned versions and integrity checks over piping an unknown script to a root shell.
11
+
12
+ ## Port diagnosis
13
+
14
+ Check in order: Xray process/listener, host firewall, provider security group and instance association, provider upstream restrictions, local-to-VPS reachability, and external probes. `timeout` differs from `connection refused`. One `nc`, ping, or proxy-contaminated curl is not enough.
15
+
16
+ Reality does not require TCP 443. Do not claim that 443 is inherently unblockable. Choose a reachable port based on evidence and keep only required ports exposed.
17
+
18
+ ## Reality parameters
19
+
20
+ - Generate UUID/key material on the server and never expose the Reality private key in client files, logs, tickets, GitHub, or QR output.
21
+ - Keep server `dest`/`serverNames` consistent with client SNI and public key/short ID.
22
+ - Test candidate `dest` sites from the VPS repeatedly. A result for Apple, Microsoft, or any other domain on one machine is not a universal recommendation.
23
+ - For intermittent handshakes, check duplicate Xray processes/SO_REUSEPORT, old test listeners, mismatched client/server configuration, and both debug logs before reinstalling.
24
+
25
+ ## Performance and acceptance
26
+
27
+ BBR, `fq`, and buffer changes are optional experiments, not guaranteed optimizations. Record the previous sysctl values and compare repeated measurements before keeping them.
28
+
29
+ Validate with a server-side loopback client and then a real external client. Check repeated TLS/HTTP success, egress IP, domestic DIRECT behavior, and speed. Remove temporary listeners/configs only after identifying them; do not use broad process kills. Leave a sanitized backup and maintenance commands.
30
+
31
+ A provider IP replacement may also change the host, SSH keys, security groups, and open ports. Revalidate all of them before updating client profiles or mobile QR codes.
@@ -0,0 +1,227 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ assertInsideHome, atomicWrite, ensureDir, ensurePrivateDir, exists,
5
+ homeDir, relativeDisplay, run, timestamp,
6
+ } from './utils.js';
7
+
8
+ const SHELL_FILES = [
9
+ '.zshrc', '.zprofile', '.bashrc', '.bash_profile', '.profile',
10
+ '.config/fish/config.fish',
11
+ ];
12
+ const LIBRARY_DIRS = [
13
+ 'Application Support', 'Caches', 'Preferences', 'HTTPStorages',
14
+ 'WebKit', 'Logs', 'Saved Application State', 'LaunchAgents',
15
+ ];
16
+ const TRACE_NAME = /claude|cc[._ -]?switch|com\.anthropic\./i;
17
+ const ENV_NAME = '(?:ANTHROPIC_[A-Z0-9_]+|CLAUDE_[A-Z0-9_]+|CC[._]?SWITCH[A-Z0-9_]*)';
18
+ const SHELL_ENV = new RegExp(`^\\s*(?:export\\s+)?${ENV_NAME}\\s*=`);
19
+ const FISH_ENV = new RegExp(`^\\s*set\\s+(?:-[A-Za-z]+\\s+)*${ENV_NAME}\\b`);
20
+ const LAUNCH_ENV = [
21
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
22
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL',
23
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'CLAUDE_CODE_USE_BEDROCK',
24
+ 'CLAUDE_CODE_USE_VERTEX',
25
+ ];
26
+
27
+ function sharedSkillDirs(home) {
28
+ const claudeDir = path.join(home, '.claude');
29
+ if (!exists(claudeDir) || fs.lstatSync(claudeDir).isSymbolicLink()) return [];
30
+ return fs.readdirSync(claudeDir)
31
+ .filter((entry) => entry.toLowerCase() === 'skills')
32
+ .map((entry) => path.join(claudeDir, entry));
33
+ }
34
+
35
+ function canonical(target) {
36
+ try {
37
+ return fs.realpathSync(target);
38
+ } catch {
39
+ return path.resolve(target);
40
+ }
41
+ }
42
+
43
+ function related(left, right) {
44
+ return left === right
45
+ || left.startsWith(`${right}${path.sep}`)
46
+ || right.startsWith(`${left}${path.sep}`);
47
+ }
48
+
49
+ function assertSafeTarget(target, protectedPaths) {
50
+ const resolved = path.resolve(target);
51
+ const real = canonical(resolved);
52
+ for (const protectedPath of protectedPaths) {
53
+ if (related(resolved, path.resolve(protectedPath)) || related(real, canonical(protectedPath))) {
54
+ throw new Error(`Refusing protected or shared path: ${resolved}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ function addTarget(set, target, protectedPaths) {
60
+ if (!exists(target)) return;
61
+ const resolved = path.resolve(target);
62
+ assertSafeTarget(resolved, protectedPaths);
63
+ set.add(resolved);
64
+ }
65
+
66
+ function scanTargets(home, extraPaths = []) {
67
+ const targets = new Set();
68
+ const claudeDir = path.join(home, '.claude');
69
+ if (exists(claudeDir) && fs.lstatSync(claudeDir).isSymbolicLink()) {
70
+ throw new Error('Refusing to traverse a symlinked ~/.claude; review it manually');
71
+ }
72
+ const skillDirs = sharedSkillDirs(home);
73
+ const scanProtected = [
74
+ ...skillDirs, path.join(home, '.agents'), path.join(home, '.codex'),
75
+ path.join(home, '.network-proxy-guard'),
76
+ ];
77
+ if (exists(claudeDir)) {
78
+ for (const entry of fs.readdirSync(claudeDir)) {
79
+ if (entry.toLowerCase() !== 'skills') addTarget(targets, path.join(claudeDir, entry), scanProtected);
80
+ }
81
+ }
82
+ addTarget(targets, path.join(home, '.claude.json'), scanProtected);
83
+ addTarget(targets, path.join(home, '.cc-switch'), scanProtected);
84
+ for (const directory of LIBRARY_DIRS) {
85
+ const parent = path.join(home, 'Library', directory);
86
+ if (!exists(parent)) continue;
87
+ for (const entry of fs.readdirSync(parent)) {
88
+ if (TRACE_NAME.test(entry)) addTarget(targets, path.join(parent, entry), scanProtected);
89
+ }
90
+ }
91
+ const extraProtected = [claudeDir, ...scanProtected];
92
+ for (const extra of extraPaths) {
93
+ const resolved = path.resolve(extra);
94
+ assertInsideHome(resolved, home);
95
+ if (resolved === path.resolve(home)) throw new Error('Refusing to reset HOME itself');
96
+ if (!/claude|cc[._ -]?switch|anthropic/i.test(path.basename(resolved))) {
97
+ throw new Error(`Extra path must be a specifically named Claude/Anthropic/CC Switch item: ${resolved}`);
98
+ }
99
+ addTarget(targets, resolved, extraProtected);
100
+ }
101
+ const sorted = [...targets].sort();
102
+ return sorted.filter((target) => !sorted.some((parent) => parent !== target && target.startsWith(`${parent}${path.sep}`)));
103
+ }
104
+
105
+ function shellPlan(home) {
106
+ const edits = [];
107
+ const warnings = [];
108
+ for (const relative of SHELL_FILES) {
109
+ const file = path.join(home, relative);
110
+ if (!exists(file)) continue;
111
+ if (fs.lstatSync(file).isSymbolicLink()) {
112
+ warnings.push(`Symlinked shell file needs manual review: ${relative}`);
113
+ continue;
114
+ }
115
+ const original = fs.readFileSync(file, 'utf8');
116
+ const lines = original.split(/(?<=\n)/);
117
+ const content = lines.filter((line) => !SHELL_ENV.test(line) && !FISH_ENV.test(line)).join('');
118
+ const removed = lines.filter((line) => SHELL_ENV.test(line) || FISH_ENV.test(line)).length;
119
+ if (content !== original) edits.push({ file, relative, original, content, removed });
120
+ }
121
+ return { edits, warnings };
122
+ }
123
+
124
+ async function defaultLaunchEnv(home) {
125
+ if (process.platform !== 'darwin' || home !== homeDir()) return [];
126
+ const set = [];
127
+ for (const name of LAUNCH_ENV) {
128
+ const result = await run('launchctl', ['getenv', name], { timeout: 3000 });
129
+ if (result.ok && result.stdout.trim()) set.push(name);
130
+ }
131
+ return set;
132
+ }
133
+
134
+ async function defaultUnsetLaunchEnv(name) {
135
+ const result = await run('launchctl', ['unsetenv', name], { timeout: 3000 });
136
+ if (!result.ok) throw new Error(`Could not unset launchd variable ${name}`);
137
+ }
138
+
139
+ export async function resetClaude(options = {}) {
140
+ const home = path.resolve(options.home ?? homeDir());
141
+ const yes = Boolean(options.yes);
142
+ const extraPaths = options.extraPaths ?? [];
143
+ const targets = scanTargets(home, extraPaths);
144
+ const { edits, warnings } = shellPlan(home);
145
+ const launchEnv = await (options.runtime?.listLaunchEnv ?? defaultLaunchEnv)(home);
146
+ const unsetLaunchEnv = options.runtime?.unsetLaunchEnv ?? defaultUnsetLaunchEnv;
147
+ const preserved = [...sharedSkillDirs(home), path.join(home, '.agents'), path.join(home, '.codex')]
148
+ .filter(exists).map((target) => relativeDisplay(target, home));
149
+ const plan = {
150
+ mode: yes ? 'apply' : 'dry-run',
151
+ targets: targets.map((target) => relativeDisplay(target, home)),
152
+ shellEdits: edits.map((edit) => ({ file: edit.relative, removedLines: edit.removed })),
153
+ launchEnv,
154
+ preserved,
155
+ programs: 'preserved; reset does not uninstall Claude Code or desktop apps',
156
+ warnings: [
157
+ ...warnings,
158
+ 'Local reset does not revoke service-side tokens',
159
+ 'A local backup will retain the removed data until you delete it yourself',
160
+ ],
161
+ };
162
+ if (!yes) return { plan, backup: null };
163
+ if (!targets.length && !edits.length && !launchEnv.length) return { plan: { ...plan, mode: 'no-op' }, backup: null };
164
+
165
+ const backup = path.join(home, '.network-proxy-guard', 'backups', `claude-reset-${timestamp()}`);
166
+ const moved = [];
167
+ const edited = [];
168
+ const launchCleared = [];
169
+ try {
170
+ ensurePrivateDir(backup);
171
+ for (const target of targets) {
172
+ const relative = path.relative(home, target);
173
+ const destination = path.join(backup, 'files', relative);
174
+ ensureDir(path.dirname(destination));
175
+ fs.renameSync(target, destination);
176
+ moved.push({ target, destination });
177
+ }
178
+ for (const edit of edits) {
179
+ const destination = path.join(backup, 'shell', edit.relative);
180
+ ensureDir(path.dirname(destination));
181
+ fs.writeFileSync(destination, edit.original, { mode: 0o600 });
182
+ atomicWrite(edit.file, edit.content);
183
+ edited.push({ ...edit, destination });
184
+ }
185
+ } catch (error) {
186
+ for (const edit of edited.reverse()) atomicWrite(edit.file, edit.original);
187
+ for (const { target, destination } of moved.reverse()) {
188
+ ensureDir(path.dirname(target));
189
+ fs.renameSync(destination, target);
190
+ }
191
+ throw error;
192
+ }
193
+ const launchEnvFailed = [];
194
+ for (const name of launchEnv) {
195
+ try {
196
+ await unsetLaunchEnv(name);
197
+ launchCleared.push(name);
198
+ } catch {
199
+ launchEnvFailed.push(name);
200
+ }
201
+ }
202
+ const resultWarnings = [...warnings];
203
+ if (launchEnvFailed.length) resultWarnings.push(`Could not clear launchd variables: ${launchEnvFailed.join(', ')}`);
204
+ const manifest = {
205
+ createdAt: new Date().toISOString(),
206
+ moved: moved.map(({ target }) => relativeDisplay(target, home)),
207
+ shellEdited: edited.map(({ relative }) => relative),
208
+ launchEnvCleared: launchCleared,
209
+ launchEnvFailed,
210
+ preserved,
211
+ };
212
+ try {
213
+ atomicWrite(path.join(backup, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
214
+ } catch (error) {
215
+ resultWarnings.push(`Could not write backup manifest: ${error.message}`);
216
+ }
217
+ return {
218
+ plan: {
219
+ ...plan,
220
+ mode: 'applied',
221
+ launchEnvCleared: launchCleared,
222
+ launchEnvFailed,
223
+ warnings: resultWarnings,
224
+ },
225
+ backup: relativeDisplay(backup, home),
226
+ };
227
+ }
@@ -0,0 +1,157 @@
1
+ import dns from 'node:dns';
2
+ import net from 'node:net';
3
+ import { URL } from 'node:url';
4
+ import { displayUrl, publicEnv, run } from './utils.js';
5
+
6
+ const GEO_SERVICES = [
7
+ { name: 'ipinfo', url: 'https://ipinfo.io/json' },
8
+ { name: 'ipwhois', url: 'https://ipwho.is/' },
9
+ { name: 'country.is', url: 'https://api.country.is/' },
10
+ ];
11
+
12
+ function shellQuote(value) {
13
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
14
+ }
15
+
16
+ function sshParts(options = {}) {
17
+ if (!options.sshTarget) return null;
18
+ if (options.sshTarget.startsWith('-')) throw new Error('Invalid SSH target');
19
+ const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8'];
20
+ if (options.sshPort) args.push('-p', String(options.sshPort));
21
+ if (options.identityFile) args.push('-i', options.identityFile);
22
+ args.push(options.sshTarget);
23
+ return args;
24
+ }
25
+
26
+ async function curl(url, options = {}) {
27
+ const args = ['--ipv4', '--noproxy', '*', '-sS', '--connect-timeout', '8', '--max-time', '25'];
28
+ if (options.headers) args.push('-D', '-', '-o', '/dev/null');
29
+ args.push(url);
30
+ const ssh = sshParts(options);
31
+ if (ssh) return run('ssh', [...ssh, `curl ${args.map(shellQuote).join(' ')}`], { timeout: 35_000 });
32
+ return run('curl', args, { timeout: 30_000 });
33
+ }
34
+
35
+ function headerValue(headers, name) {
36
+ const matches = [...headers.matchAll(new RegExp(`^${name}:\\s*(.+)$`, 'gim'))];
37
+ return matches.length ? matches.at(-1)[1].trim() : null;
38
+ }
39
+
40
+ function normalizeGeo(name, data) {
41
+ if (name === 'ipinfo') return { service: name, ip: data.ip, country: data.country, city: data.city, org: data.org };
42
+ if (name === 'ipwhois') return { service: name, ip: data.ip, country: data.country_code, city: data.city, org: data.connection?.asn_org };
43
+ return { service: name, ip: data.ip, country: data.country };
44
+ }
45
+
46
+ export function classifyGoogleRegion({ location, geo, serverDirect }) {
47
+ const evidenceIp = sharedUsIpv4(geo);
48
+ const hk = /google\.com\.hk(?:[/:]|$)/i.test(location ?? '');
49
+ if (hk && !serverDirect) return 'needs-vps-direct-test';
50
+ if (hk && evidenceIp) return 'google-region-mismatch';
51
+ if (hk) return 'provider-or-geo-data-mismatch';
52
+ if (location) return 'google-other-region-redirect';
53
+ return 'no-google-hk-redirect';
54
+ }
55
+
56
+ function sharedUsIpv4(geo) {
57
+ const counts = new Map();
58
+ for (const entry of geo) {
59
+ if (entry.country?.toUpperCase() === 'US' && net.isIP(entry.ip) === 4) {
60
+ counts.set(entry.ip, (counts.get(entry.ip) ?? 0) + 1);
61
+ }
62
+ }
63
+ return [...counts].find(([, count]) => count >= 2)?.[0] ?? null;
64
+ }
65
+
66
+ export async function detectGoogleRegion(options = {}) {
67
+ const serverDirect = Boolean(options.sshTarget || options.onVps);
68
+ const google = await curl('https://www.google.com/', { ...options, headers: true });
69
+ if (!google.ok) throw new Error(`Google test failed: ${google.stderr || 'network error'}`);
70
+ const location = headerValue(google.stdout, 'location');
71
+ const geo = await Promise.all(GEO_SERVICES.map(async ({ name, url }) => {
72
+ const result = await curl(url, options);
73
+ if (!result.ok) return { service: name, error: 'query failed' };
74
+ try {
75
+ return normalizeGeo(name, JSON.parse(result.stdout));
76
+ } catch {
77
+ return { service: name, error: 'invalid response' };
78
+ }
79
+ }));
80
+ const evidenceIp = sharedUsIpv4(geo);
81
+ return {
82
+ testedAt: new Date().toISOString(),
83
+ perspective: options.sshTarget ? 'VPS direct over SSH' : options.onVps ? 'VPS local direct' : 'local direct only',
84
+ serverDirect,
85
+ evidenceIp,
86
+ google: { url: 'https://www.google.com/', location },
87
+ geo,
88
+ classification: classifyGoogleRegion({ location, geo, serverDirect }),
89
+ };
90
+ }
91
+
92
+ function clean(value) {
93
+ return String(value ?? 'unknown').replace(/[|\r\n]/g, '/');
94
+ }
95
+
96
+ export function buildLisaTicket(report) {
97
+ if (report.classification !== 'google-region-mismatch') {
98
+ throw new Error(`Evidence does not justify this ticket yet: ${report.classification}`);
99
+ }
100
+ if (!report.serverDirect) throw new Error('A VPS-direct test is required before creating the ticket');
101
+ const ip = report.evidenceIp;
102
+ if (!ip || net.isIP(ip) !== 4) throw new Error('Two geolocation providers must agree on the same US IPv4');
103
+ const evidence = report.geo.filter((entry) => entry.ip === ip);
104
+ if (evidence.length < 2 || evidence.some((entry) => entry.country?.toUpperCase() !== 'US')) {
105
+ throw new Error('Ticket evidence is inconsistent');
106
+ }
107
+ const rows = evidence.map((entry) => `| ${clean(entry.service)} | ${clean(entry.country)} | ${clean(entry.city)} |`).join('\n');
108
+ return `主题:美国洛杉矶 VPS 的 IPv4 被 Google 判定为香港,申请核查并更换正确识别为美国的 IP\n\n你好,\n\n我购买的是美国洛杉矶地区的 VPS,当前实例公网 IPv4 为:${clean(ip)}。\n\n我在 VPS 本机直接访问 Google,未经过本地代理、VPN、浏览器 Cookie 或 Google 账号,访问 https://www.google.com/ 时跳转至:${clean(report.google.location)}。\n\n测试视角:${clean(report.perspective)}\n测试时间:${clean(report.testedAt)}\n\n第三方地理库结果:\n\n| 服务 | 国家/地区 | 城市 |\n|---|---|---|\n${rows}\n\n由于测试是在 VPS 本机直接完成,可以排除本地电脑代理规则、浏览器 Cookie 和本地 DNS 的影响。目前看起来是 Google 对该 IP 的地区识别与实例所在地区不一致,可能影响依赖 Google 地区判定的服务。\n\n请协助:\n1. 核查该 IP 是否存在历史地区标记或地区广播异常;\n2. 如无法修正,请更换一个能够被 Google 正确识别为美国的 IPv4;\n3. 操作前请说明是仅更换 IP,还是需要迁移实例/宿主机;\n4. 请勿直接重装、重置或迁移实例。如涉及这些操作,请先与我确认;\n5. 请确认更换后原实例数据、剩余使用时长及流量额度是否保留。\n\n我可以提供完整的响应头和地理库查询结果。谢谢。\n`;
109
+ }
110
+
111
+ async function curlMetrics(url, proxy) {
112
+ const args = ['-sS', '--connect-timeout', '8', '--max-time', '20', '-o', '/dev/null'];
113
+ if (proxy) args.push('-x', proxy);
114
+ else args.push('--noproxy', '*');
115
+ args.push('-w', '%{http_code}\\n%{remote_ip}\\n%{time_total}', url);
116
+ const result = await run('curl', args, { timeout: 25_000 });
117
+ const [httpCode, remoteIp, seconds] = result.stdout.trim().split('\n');
118
+ const connectionError = result.ok ? undefined : [url, proxy].filter(Boolean).reduce(
119
+ (message, sensitive) => message.split(sensitive).join(displayUrl(sensitive)),
120
+ result.stderr,
121
+ );
122
+ return { ok: result.ok, httpCode, remoteIp, seconds, error: connectionError };
123
+ }
124
+
125
+ async function systemProxy() {
126
+ if (process.platform !== 'darwin') return null;
127
+ const result = await run('scutil', ['--proxy'], { timeout: 3000 });
128
+ if (!result.ok) return null;
129
+ const output = {};
130
+ for (const name of ['HTTPEnable', 'HTTPProxy', 'HTTPPort', 'HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', 'SOCKSEnable', 'SOCKSProxy', 'SOCKSPort', 'ProxyAutoConfigEnable']) {
131
+ const match = result.stdout.match(new RegExp(`${name}\\s*:\\s*([^\\n]+)`));
132
+ if (match) output[name] = match[1].trim();
133
+ }
134
+ return output;
135
+ }
136
+
137
+ export async function probeNetwork(options = {}) {
138
+ const target = new URL(options.url ?? 'https://www.google.com/generate_204');
139
+ if (!['http:', 'https:'].includes(target.protocol)) throw new Error('Only HTTP(S) probe URLs are supported');
140
+ const result = {
141
+ platform: process.platform,
142
+ arch: process.arch,
143
+ proxyEnv: publicEnv(),
144
+ systemProxy: await systemProxy(),
145
+ url: displayUrl(target.toString()),
146
+ proxy: displayUrl(options.proxy),
147
+ };
148
+ if (!options.offline) {
149
+ try {
150
+ result.dns = await dns.promises.lookup(target.hostname, { all: true });
151
+ } catch (error) {
152
+ result.dns = { error: error.message };
153
+ }
154
+ result.connection = await curlMetrics(target.toString(), options.proxy);
155
+ }
156
+ return result;
157
+ }
package/src/install.js ADDED
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const TARGETS = {
7
+ agents: ['.agents', 'skills'],
8
+ claude: ['.claude', 'skills'],
9
+ codex: ['.codex', 'skills'],
10
+ };
11
+
12
+ export function installSkill(options = {}) {
13
+ const home = options.home ?? os.homedir();
14
+ const source = fileURLToPath(new URL('../skill/network-proxy-guard/', import.meta.url));
15
+ const root = options.target && !TARGETS[options.target]
16
+ ? path.resolve(options.target)
17
+ : path.join(home, ...(TARGETS[options.target ?? 'agents'] ?? TARGETS.agents));
18
+ const destination = path.join(root, 'network-proxy-guard');
19
+ if (fs.existsSync(destination) && !options.force) {
20
+ throw new Error(`Skill already exists: ${destination}. Use --force to back it up and replace it.`);
21
+ }
22
+ fs.mkdirSync(root, { recursive: true });
23
+ const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
24
+ try {
25
+ fs.cpSync(source, temporary, { recursive: true, errorOnExist: true, force: false });
26
+ } catch (error) {
27
+ fs.rmSync(temporary, { recursive: true, force: true });
28
+ throw error;
29
+ }
30
+ let backup = null;
31
+ try {
32
+ if (fs.existsSync(destination)) {
33
+ backup = `${destination}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
34
+ fs.renameSync(destination, backup);
35
+ }
36
+ fs.renameSync(temporary, destination);
37
+ } catch (error) {
38
+ fs.rmSync(temporary, { recursive: true, force: true });
39
+ if (backup && fs.existsSync(backup) && !fs.existsSync(destination)) {
40
+ fs.renameSync(backup, destination);
41
+ }
42
+ throw error;
43
+ }
44
+ return { destination, backup };
45
+ }
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+
4
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+ const SHORT_ID = /^[0-9a-f]{2,16}$/i;
6
+
7
+ export const SENSITIVE_SUFFIXES = [
8
+ 'anthropic.com', 'claude.ai', 'openai.com', 'chatgpt.com', 'openrouter.ai',
9
+ 'stripe.com', 'stripe.network', 'stripe.global',
10
+ ];
11
+
12
+ export function readNodeConfig(file) {
13
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
14
+ return validateNodeConfig(parsed);
15
+ }
16
+
17
+ export function validateNodeConfig(input) {
18
+ const required = ['name', 'address', 'port', 'uuid', 'publicKey', 'shortId', 'serverName'];
19
+ for (const key of required) {
20
+ if (input[key] === undefined || input[key] === null || input[key] === '') {
21
+ throw new Error(`Missing node field: ${key}`);
22
+ }
23
+ }
24
+ const node = {
25
+ name: String(input.name).trim(),
26
+ address: String(input.address).trim(),
27
+ port: Number(input.port),
28
+ uuid: String(input.uuid).trim(),
29
+ publicKey: String(input.publicKey).trim(),
30
+ shortId: String(input.shortId).trim(),
31
+ serverName: String(input.serverName).trim(),
32
+ flow: input.flow === undefined ? '' : String(input.flow ?? '').trim(),
33
+ fingerprint: String(input.fingerprint ?? 'chrome').trim(),
34
+ spiderX: String(input.spiderX ?? '/').trim(),
35
+ };
36
+ if (!node.name || /[\r\n]/.test(node.name)) throw new Error('Invalid node name');
37
+ if (!Number.isInteger(node.port) || node.port < 1 || node.port > 65535) throw new Error('Invalid node port');
38
+ if (!UUID.test(node.uuid)) throw new Error('Invalid UUID');
39
+ if (!SHORT_ID.test(node.shortId) || node.shortId.length % 2 !== 0) throw new Error('Short ID must be even-length hex');
40
+ if (!/^[A-Za-z0-9_-]{43}$/.test(node.publicKey) || /REPLACE|EXAMPLE/i.test(node.publicKey)) {
41
+ throw new Error('Public key is missing or still a placeholder');
42
+ }
43
+ if (!/^[A-Za-z0-9.-]+$/.test(node.serverName)) throw new Error('Invalid serverName');
44
+ if (!/^[A-Za-z0-9:.-]+$/.test(node.address)) throw new Error('Invalid node address');
45
+ return node;
46
+ }
47
+
48
+ export function buildVlessUri(nodeInput) {
49
+ const node = validateNodeConfig(nodeInput);
50
+ const host = net.isIP(node.address) === 6 ? `[${node.address}]` : node.address;
51
+ const params = new URLSearchParams({
52
+ type: 'tcp',
53
+ security: 'reality',
54
+ encryption: 'none',
55
+ sni: node.serverName,
56
+ fp: node.fingerprint,
57
+ pbk: node.publicKey,
58
+ sid: node.shortId,
59
+ spx: node.spiderX,
60
+ });
61
+ if (node.flow) params.set('flow', node.flow);
62
+ return `vless://${node.uuid}@${host}:${node.port}?${params.toString()}#${encodeURIComponent(node.name)}`;
63
+ }
64
+
65
+ export function buildMihomoProfile(nodeInput) {
66
+ const node = validateNodeConfig(nodeInput);
67
+ const group = '节点选择';
68
+ const addressVersion = net.isIP(node.address);
69
+ const serverRule = addressVersion === 4
70
+ ? `IP-CIDR,${node.address}/32,DIRECT,no-resolve`
71
+ : addressVersion === 6
72
+ ? `IP-CIDR6,${node.address}/128,DIRECT,no-resolve`
73
+ : `DOMAIN,${node.address},DIRECT`;
74
+ const rules = [
75
+ serverRule,
76
+ ...SENSITIVE_SUFFIXES.map((suffix) => `DOMAIN-SUFFIX,${suffix},${group}`),
77
+ `GEOSITE,google,${group}`,
78
+ 'GEOSITE,CN,DIRECT',
79
+ 'GEOIP,CN,DIRECT,no-resolve',
80
+ `MATCH,${group}`,
81
+ ];
82
+ return {
83
+ proxies: [{
84
+ name: node.name,
85
+ type: 'vless',
86
+ server: node.address,
87
+ port: node.port,
88
+ uuid: node.uuid,
89
+ network: 'tcp',
90
+ udp: true,
91
+ tls: true,
92
+ servername: node.serverName,
93
+ ...(node.flow ? { flow: node.flow } : {}),
94
+ 'client-fingerprint': node.fingerprint,
95
+ 'reality-opts': {
96
+ 'public-key': node.publicKey,
97
+ 'short-id': node.shortId,
98
+ },
99
+ }],
100
+ 'proxy-groups': [{ name: group, type: 'select', proxies: [node.name] }],
101
+ rules,
102
+ };
103
+ }
package/src/qr.js ADDED
@@ -0,0 +1,21 @@
1
+ import path from 'node:path';
2
+ import QRCode from 'qrcode';
3
+ import { buildVlessUri } from './node-config.js';
4
+ import { atomicWrite, ensurePrivateDir, exists } from './utils.js';
5
+
6
+ export async function generateMobileQr(node, outputDir) {
7
+ const uri = buildVlessUri(node);
8
+ const directory = path.resolve(outputDir);
9
+ if (!exists(directory)) ensurePrivateDir(directory);
10
+ const linkFile = path.join(directory, 'vless-link.txt');
11
+ const qrFile = path.join(directory, 'shadowrocket-qr.png');
12
+ const png = await QRCode.toBuffer(uri, {
13
+ errorCorrectionLevel: 'M',
14
+ margin: 3,
15
+ width: 512,
16
+ color: { dark: '#000000', light: '#FFFFFF' },
17
+ });
18
+ atomicWrite(linkFile, `${uri}\n`);
19
+ atomicWrite(qrFile, png);
20
+ return { linkFile, qrFile };
21
+ }