network-proxy-guard 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +169 -2
- package/bin/network-proxy-guard.js +165 -0
- package/evals/evals.json +35 -0
- package/package.json +49 -4
- package/skill/network-proxy-guard/SKILL.md +66 -0
- package/skill/network-proxy-guard/assets/lisa-google-region-ticket.md +23 -0
- package/skill/network-proxy-guard/assets/node.example.json +12 -0
- package/skill/network-proxy-guard/references/auto-setup.md +35 -0
- package/skill/network-proxy-guard/references/claude-reset.md +24 -0
- package/skill/network-proxy-guard/references/diagnostics.md +31 -0
- package/skill/network-proxy-guard/references/google-ip-region.md +35 -0
- package/skill/network-proxy-guard/references/mobile-qr.md +13 -0
- package/skill/network-proxy-guard/references/xray-reality.md +31 -0
- package/src/claude-reset.js +227 -0
- package/src/diagnostics.js +157 -0
- package/src/install.js +45 -0
- package/src/node-config.js +103 -0
- package/src/qr.js +21 -0
- package/src/reference.js +9 -0
- package/src/setup.js +245 -0
- package/src/utils.js +117 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import YAML from 'yaml';
|
|
6
|
+
import { buildMihomoProfile } from './node-config.js';
|
|
7
|
+
import {
|
|
8
|
+
atomicWrite, ensureDir, ensurePrivateDir, exists, homeDir,
|
|
9
|
+
relativeDisplay, run, timestamp,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
export const defaultVergeDir = (home = homeDir()) => path.join(
|
|
13
|
+
home, 'Library', 'Application Support', 'io.github.clash-verge-rev.clash-verge-rev',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function parseYamlFile(file, fallback = {}) {
|
|
17
|
+
if (!exists(file)) return fallback;
|
|
18
|
+
const parsed = YAML.parse(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function uidFor(node) {
|
|
23
|
+
const digest = crypto.createHash('sha256')
|
|
24
|
+
.update(`${node.name}\0${node.address}\0${node.port}`)
|
|
25
|
+
.digest('hex').slice(0, 12);
|
|
26
|
+
return `npg-${digest}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function defaultIsRunning() {
|
|
30
|
+
if (process.platform !== 'darwin') return false;
|
|
31
|
+
const result = await run('pgrep', ['-f', 'Clash Verge'], { timeout: 3000 });
|
|
32
|
+
return result.ok;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function defaultStop() {
|
|
36
|
+
const result = await run('osascript', ['-e', 'tell application "Clash Verge" to quit']);
|
|
37
|
+
if (!result.ok) throw new Error(`Could not quit Clash Verge: ${result.stderr}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function defaultStart() {
|
|
41
|
+
const result = await run('open', ['-a', 'Clash Verge']);
|
|
42
|
+
if (!result.ok) throw new Error(`Could not reopen Clash Verge: ${result.stderr}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function defaultTunReady() {
|
|
46
|
+
return exists('/Library/PrivilegedHelperTools/clash-verge-service')
|
|
47
|
+
&& exists('/Library/Application Support/clash-verge-service');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function defaultValidate(profileFile, content) {
|
|
51
|
+
const candidates = [
|
|
52
|
+
process.env.MIHOMO_BIN,
|
|
53
|
+
'/Library/Application Support/clash-verge-service/cores/mihomo',
|
|
54
|
+
'/Library/Application Support/clash-verge-service/cores/clash-meta',
|
|
55
|
+
'/Applications/Clash Verge.app/Contents/Resources/sidecar/mihomo',
|
|
56
|
+
'/Applications/Clash Verge.app/Contents/Resources/sidecar/clash-meta',
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
let binary = candidates.find(exists);
|
|
59
|
+
if (!binary) {
|
|
60
|
+
const found = await run('sh', ['-c', 'command -v mihomo || command -v clash-meta'], { timeout: 3000 });
|
|
61
|
+
if (found.ok && found.stdout.trim()) binary = found.stdout.trim().split('\n')[0];
|
|
62
|
+
}
|
|
63
|
+
if (!binary) return { skipped: true, ok: false, reason: 'mihomo binary not found; YAML validated only' };
|
|
64
|
+
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'npg-validate-'));
|
|
65
|
+
const candidate = path.join(temporary, 'candidate.yaml');
|
|
66
|
+
fs.writeFileSync(candidate, content, { mode: 0o600 });
|
|
67
|
+
const result = await run(binary, ['-t', '-f', candidate], { timeout: 20_000 });
|
|
68
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
69
|
+
return {
|
|
70
|
+
skipped: false,
|
|
71
|
+
ok: result.ok,
|
|
72
|
+
command: binary,
|
|
73
|
+
reason: result.ok ? 'mihomo configuration test passed' : result.stderr || result.stdout,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeChange(file, content, vergeDir) {
|
|
78
|
+
if (exists(file) && fs.lstatSync(file).isSymbolicLink()) {
|
|
79
|
+
throw new Error(`Refusing to replace a symlinked Clash config: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
file,
|
|
83
|
+
content,
|
|
84
|
+
relative: path.relative(vergeDir, file),
|
|
85
|
+
original: exists(file) ? fs.readFileSync(file) : null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function setupProxy(options) {
|
|
90
|
+
const home = options.home ?? homeDir();
|
|
91
|
+
const vergeDir = path.resolve(options.vergeDir ?? defaultVergeDir(home));
|
|
92
|
+
const node = options.node;
|
|
93
|
+
const restart = Boolean(options.restart);
|
|
94
|
+
const yes = Boolean(options.yes);
|
|
95
|
+
const runtime = options.runtime ?? {};
|
|
96
|
+
const isRunning = runtime.isRunning ?? defaultIsRunning;
|
|
97
|
+
const validate = runtime.validate ?? defaultValidate;
|
|
98
|
+
const stop = runtime.stop ?? defaultStop;
|
|
99
|
+
const start = runtime.start ?? defaultStart;
|
|
100
|
+
if (!exists(vergeDir)) throw new Error(`Clash Verge config directory not found: ${vergeDir}`);
|
|
101
|
+
|
|
102
|
+
const profilesFile = path.join(vergeDir, 'profiles.yaml');
|
|
103
|
+
const configFile = path.join(vergeDir, 'config.yaml');
|
|
104
|
+
const vergeFile = path.join(vergeDir, 'verge.yaml');
|
|
105
|
+
const profiles = parseYamlFile(profilesFile, { current: null, items: [] });
|
|
106
|
+
if (!Array.isArray(profiles.items)) throw new Error('profiles.yaml has no valid items array');
|
|
107
|
+
const baseConfig = parseYamlFile(configFile, {});
|
|
108
|
+
const vergeConfig = parseYamlFile(vergeFile, {});
|
|
109
|
+
const uid = uidFor(node);
|
|
110
|
+
const profileName = `${uid}.yaml`;
|
|
111
|
+
const profileFile = path.join(vergeDir, 'profiles', profileName);
|
|
112
|
+
const item = {
|
|
113
|
+
uid,
|
|
114
|
+
type: 'local',
|
|
115
|
+
name: node.name,
|
|
116
|
+
desc: 'Managed by network-proxy-guard',
|
|
117
|
+
file: profileName,
|
|
118
|
+
updated: Math.floor(Date.now() / 1000),
|
|
119
|
+
};
|
|
120
|
+
const retained = profiles.items.filter((entry) => entry?.uid !== uid);
|
|
121
|
+
const nextProfiles = { ...profiles, current: uid, items: [item, ...retained] };
|
|
122
|
+
const nextBaseConfig = { ...baseConfig, mode: 'rule', ipv6: false };
|
|
123
|
+
const nextVergeConfig = {
|
|
124
|
+
...vergeConfig,
|
|
125
|
+
enable_system_proxy: true,
|
|
126
|
+
proxy_auto_config: false,
|
|
127
|
+
};
|
|
128
|
+
if (options.enableTun) nextVergeConfig.enable_tun_mode = true;
|
|
129
|
+
|
|
130
|
+
const changes = [
|
|
131
|
+
makeChange(profileFile, YAML.stringify(buildMihomoProfile(node)), vergeDir),
|
|
132
|
+
makeChange(profilesFile, YAML.stringify(nextProfiles), vergeDir),
|
|
133
|
+
makeChange(configFile, YAML.stringify(nextBaseConfig), vergeDir),
|
|
134
|
+
];
|
|
135
|
+
if (exists(vergeFile)) changes.push(makeChange(vergeFile, YAML.stringify(nextVergeConfig), vergeDir));
|
|
136
|
+
|
|
137
|
+
const running = await isRunning();
|
|
138
|
+
const tunReady = options.enableTun
|
|
139
|
+
? await (runtime.checkTun ?? defaultTunReady)()
|
|
140
|
+
: null;
|
|
141
|
+
const warnings = [
|
|
142
|
+
'Automatic setup writes version-sensitive Verge internal files; export-profile offers the official UI import path',
|
|
143
|
+
];
|
|
144
|
+
if (!exists(vergeFile)) warnings.push('verge.yaml not found; system proxy settings must be checked in the UI');
|
|
145
|
+
if (!exists(configFile)) warnings.push('config.yaml not found; a minimal rule-mode config will be created');
|
|
146
|
+
if (running && !restart) warnings.push('Clash Verge is running; apply requires --restart or a manually quit app');
|
|
147
|
+
if (running && restart) warnings.push('Clash Verge will be quit and reopened');
|
|
148
|
+
if (options.enableTun) {
|
|
149
|
+
warnings.push(tunReady
|
|
150
|
+
? 'TUN was requested; IPC, routes, DNS and UDP still require post-change verification'
|
|
151
|
+
: 'TUN privileged service was not detected');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const plan = {
|
|
155
|
+
mode: yes ? 'apply' : 'dry-run',
|
|
156
|
+
vergeDir,
|
|
157
|
+
activate: uid,
|
|
158
|
+
preserveProfiles: retained.length,
|
|
159
|
+
mixedPort: vergeConfig.verge_mixed_port ?? baseConfig['mixed-port'] ?? baseConfig.port ?? null,
|
|
160
|
+
tun: options.enableTun ? 'enable' : vergeConfig.enable_tun_mode ? 'unchanged-on' : 'unchanged-off',
|
|
161
|
+
tunService: tunReady === null ? undefined : tunReady ? 'detected' : 'missing',
|
|
162
|
+
files: changes.map((change) => change.relative),
|
|
163
|
+
running,
|
|
164
|
+
warnings,
|
|
165
|
+
};
|
|
166
|
+
if (!yes) return { plan, validation: null, backup: null };
|
|
167
|
+
if (options.enableTun && !tunReady) {
|
|
168
|
+
throw new Error('TUN privileged service is not ready; no files were changed');
|
|
169
|
+
}
|
|
170
|
+
if (running && !restart) throw new Error('Quit Clash Verge first, or explicitly authorize --restart');
|
|
171
|
+
|
|
172
|
+
const validation = await validate(profileFile, changes[0].content);
|
|
173
|
+
if (!validation.ok && !validation.skipped) {
|
|
174
|
+
throw new Error(`Generated profile failed validation: ${validation.reason}`);
|
|
175
|
+
}
|
|
176
|
+
if (restart && running) {
|
|
177
|
+
await stop();
|
|
178
|
+
for (let attempt = 0; attempt < 20 && await isRunning(); attempt += 1) {
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
180
|
+
}
|
|
181
|
+
if (await isRunning()) throw new Error('Clash Verge did not stop; no files were changed');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const backup = path.join(home, '.network-proxy-guard', 'backups', `setup-${timestamp()}`);
|
|
185
|
+
const applied = [];
|
|
186
|
+
try {
|
|
187
|
+
ensurePrivateDir(backup);
|
|
188
|
+
for (const change of changes) {
|
|
189
|
+
if (change.original) {
|
|
190
|
+
const backupFile = path.join(backup, 'original', change.relative);
|
|
191
|
+
ensureDir(path.dirname(backupFile));
|
|
192
|
+
fs.writeFileSync(backupFile, change.original, { mode: 0o600 });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const change of changes) {
|
|
196
|
+
atomicWrite(change.file, change.content);
|
|
197
|
+
applied.push(change);
|
|
198
|
+
}
|
|
199
|
+
const manifest = {
|
|
200
|
+
createdAt: new Date().toISOString(),
|
|
201
|
+
vergeDir,
|
|
202
|
+
files: changes.map((change) => ({ path: change.relative, existed: Boolean(change.original) })),
|
|
203
|
+
};
|
|
204
|
+
atomicWrite(path.join(backup, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
for (const change of applied.reverse()) {
|
|
207
|
+
if (change.original) atomicWrite(change.file, change.original);
|
|
208
|
+
else fs.rmSync(change.file, { force: true });
|
|
209
|
+
}
|
|
210
|
+
if (restart && running) {
|
|
211
|
+
try {
|
|
212
|
+
await start();
|
|
213
|
+
} catch (startError) {
|
|
214
|
+
error.message += `; rollback completed but Clash Verge reopen failed: ${startError.message}`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
let restartWarning = null;
|
|
220
|
+
if (restart && running) {
|
|
221
|
+
try {
|
|
222
|
+
await start();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
restartWarning = `Configuration applied, but Clash Verge reopen failed: ${error.message}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
plan: {
|
|
229
|
+
...plan,
|
|
230
|
+
mode: 'applied',
|
|
231
|
+
warnings: restartWarning ? [...warnings, restartWarning] : warnings,
|
|
232
|
+
},
|
|
233
|
+
validation,
|
|
234
|
+
backup: relativeDisplay(backup, home),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function printablePlan(result) {
|
|
239
|
+
const { plan, validation, backup } = result;
|
|
240
|
+
return {
|
|
241
|
+
...plan,
|
|
242
|
+
validation: validation ? (validation.ok ? 'passed' : validation.skipped ? 'YAML only' : 'failed') : 'not run (dry-run)',
|
|
243
|
+
backup,
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
export const execFileAsync = promisify(execFile);
|
|
8
|
+
export const homeDir = () => os.homedir();
|
|
9
|
+
export const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
10
|
+
|
|
11
|
+
export function expandHome(value) {
|
|
12
|
+
if (!value) return value;
|
|
13
|
+
if (value === '~') return homeDir();
|
|
14
|
+
if (value.startsWith('~/')) return path.join(homeDir(), value.slice(2));
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function exists(target) {
|
|
19
|
+
return fs.existsSync(target);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ensureDir(target) {
|
|
23
|
+
fs.mkdirSync(target, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensurePrivateDir(target) {
|
|
27
|
+
ensureDir(target);
|
|
28
|
+
fs.chmodSync(target, 0o700);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function atomicWrite(target, content, mode = 0o600) {
|
|
32
|
+
ensureDir(path.dirname(target));
|
|
33
|
+
const temporary = `${target}.npg-${process.pid}-${Date.now()}`;
|
|
34
|
+
try {
|
|
35
|
+
fs.writeFileSync(temporary, content, { mode });
|
|
36
|
+
fs.chmodSync(temporary, mode);
|
|
37
|
+
fs.renameSync(temporary, target);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
fs.rmSync(temporary, { force: true });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function relativeDisplay(target, home = homeDir()) {
|
|
45
|
+
const relative = path.relative(home, target);
|
|
46
|
+
return relative && !relative.startsWith('..') ? `~/${relative}` : target;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redactUrl(value) {
|
|
50
|
+
if (!value) return value;
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(value);
|
|
53
|
+
if (url.username || url.password) {
|
|
54
|
+
url.username = '***';
|
|
55
|
+
url.password = '***';
|
|
56
|
+
}
|
|
57
|
+
for (const key of url.searchParams.keys()) {
|
|
58
|
+
if (/token|key|secret|password|passwd|auth|signature|sig|credential|code/i.test(key)) {
|
|
59
|
+
url.searchParams.set(key, '***');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
url.hash = '';
|
|
63
|
+
return url.toString();
|
|
64
|
+
} catch {
|
|
65
|
+
return value.replace(/\/\/[^\s/@]+@/, '//***@').replace(/[?#].*$/, '');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function displayUrl(value) {
|
|
70
|
+
if (!value) return value;
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(redactUrl(value));
|
|
73
|
+
url.username = '';
|
|
74
|
+
url.password = '';
|
|
75
|
+
url.search = '';
|
|
76
|
+
url.hash = '';
|
|
77
|
+
return url.toString();
|
|
78
|
+
} catch {
|
|
79
|
+
return redactUrl(value).replace(/[?#].*$/, '');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function publicEnv(env = process.env) {
|
|
84
|
+
const names = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'];
|
|
85
|
+
const result = {};
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const value = env[name] ?? env[name.toLowerCase()];
|
|
88
|
+
if (value) result[name] = name === 'NO_PROXY' ? value : redactUrl(value);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function run(command, args, options = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
96
|
+
timeout: options.timeout ?? 15_000,
|
|
97
|
+
maxBuffer: options.maxBuffer ?? 2 * 1024 * 1024,
|
|
98
|
+
env: options.env ?? process.env,
|
|
99
|
+
});
|
|
100
|
+
return { ok: true, stdout, stderr, code: 0 };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
stdout: error.stdout ?? '',
|
|
105
|
+
stderr: error.stderr ?? error.message,
|
|
106
|
+
code: typeof error.code === 'number' ? error.code : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function assertInsideHome(target, home) {
|
|
112
|
+
const resolvedHome = path.resolve(home);
|
|
113
|
+
const resolvedTarget = path.resolve(target);
|
|
114
|
+
if (resolvedTarget !== resolvedHome && !resolvedTarget.startsWith(`${resolvedHome}${path.sep}`)) {
|
|
115
|
+
throw new Error(`Refusing to touch a path outside HOME: ${resolvedTarget}`);
|
|
116
|
+
}
|
|
117
|
+
}
|