@juspay/shooter 1.10.0 → 1.11.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/.claude/hooks/notifier.cjs +24 -242
- package/bin/lib/tunnel-discovery.cjs +519 -0
- package/bin/shooter.cjs +204 -49
- package/build/client/_app/immutable/chunks/{DfKeHoAm.js → BlgsHm7b.js} +1 -1
- package/build/client/_app/immutable/chunks/BlgsHm7b.js.br +0 -0
- package/build/client/_app/immutable/chunks/BlgsHm7b.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{BFXEYMV8.js → C9URPhwn.js} +1 -1
- package/build/client/_app/immutable/chunks/C9URPhwn.js.br +0 -0
- package/build/client/_app/immutable/chunks/C9URPhwn.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{CmtInjm0.js → Ce85PELw.js} +1 -1
- package/build/client/_app/immutable/chunks/Ce85PELw.js.br +0 -0
- package/build/client/_app/immutable/chunks/{CmtInjm0.js.gz → Ce85PELw.js.gz} +0 -0
- package/build/client/_app/immutable/entry/{app.Dp9YhfEg.js → app.BRJS3bSR.js} +2 -2
- package/build/client/_app/immutable/entry/app.BRJS3bSR.js.br +0 -0
- package/build/client/_app/immutable/entry/app.BRJS3bSR.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.D0irEiqS.js +1 -0
- package/build/client/_app/immutable/entry/start.D0irEiqS.js.br +2 -0
- package/build/client/_app/immutable/entry/start.D0irEiqS.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.B-M6sgow.js → 0.BdenYwQ0.js} +1 -1
- package/build/client/_app/immutable/nodes/0.BdenYwQ0.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.BdenYwQ0.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.C8aY7Yn3.js → 1.Dg4RfBlr.js} +1 -1
- package/build/client/_app/immutable/nodes/1.Dg4RfBlr.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.Dg4RfBlr.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.YJZruh1H.js → 2.DW-JPzyt.js} +1 -1
- package/build/client/_app/immutable/nodes/2.DW-JPzyt.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.DW-JPzyt.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.VV-tRemY.js → 3.bF7rX0-f.js} +1 -1
- package/build/client/_app/immutable/nodes/3.bF7rX0-f.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.bF7rX0-f.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{6.CDJA8Na9.js → 6.DSMOdSb2.js} +1 -1
- package/build/client/_app/immutable/nodes/6.DSMOdSb2.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.DSMOdSb2.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.BX9znBYU.js → 7.cNNZm7gF.js} +1 -1
- package/build/client/_app/immutable/nodes/7.cNNZm7gF.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.cNNZm7gF.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{8.CmdrNdfj.js → 8.DtjRdVVT.js} +1 -1
- package/build/client/_app/immutable/nodes/8.DtjRdVVT.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.DtjRdVVT.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{9.BSleOtKF.js → 9._gEbLeEN.js} +1 -1
- package/build/client/_app/immutable/nodes/9._gEbLeEN.js.br +0 -0
- package/build/client/_app/immutable/nodes/9._gEbLeEN.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/server/chunks/{0-vSdphvn2.js → 0-Cdx5OqRN.js} +2 -2
- package/build/server/chunks/{0-vSdphvn2.js.map → 0-Cdx5OqRN.js.map} +1 -1
- package/build/server/chunks/{1-B5tn1ob0.js → 1-BQErgU_W.js} +2 -2
- package/build/server/chunks/{1-B5tn1ob0.js.map → 1-BQErgU_W.js.map} +1 -1
- package/build/server/chunks/{2-CUbzGnZ8.js → 2-BWmy5V7g.js} +2 -2
- package/build/server/chunks/{2-CUbzGnZ8.js.map → 2-BWmy5V7g.js.map} +1 -1
- package/build/server/chunks/{3-BR90tKg7.js → 3-ClpS_rbl.js} +2 -2
- package/build/server/chunks/{3-BR90tKg7.js.map → 3-ClpS_rbl.js.map} +1 -1
- package/build/server/chunks/{6-cW7umWCt.js → 6-CQHRlS4a.js} +2 -2
- package/build/server/chunks/{6-cW7umWCt.js.map → 6-CQHRlS4a.js.map} +1 -1
- package/build/server/chunks/{7-D3cb9T2g.js → 7-VdriLQgS.js} +2 -2
- package/build/server/chunks/{7-D3cb9T2g.js.map → 7-VdriLQgS.js.map} +1 -1
- package/build/server/chunks/{8-BEgZo4wA.js → 8-SAsJLBKt.js} +2 -2
- package/build/server/chunks/{8-BEgZo4wA.js.map → 8-SAsJLBKt.js.map} +1 -1
- package/build/server/chunks/{9-Dy8aTTtf.js → 9-BkY6ynKj.js} +2 -2
- package/build/server/chunks/{9-Dy8aTTtf.js.map → 9-BkY6ynKj.js.map} +1 -1
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +9 -9
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/build/client/_app/immutable/chunks/BFXEYMV8.js.br +0 -0
- package/build/client/_app/immutable/chunks/BFXEYMV8.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CmtInjm0.js.br +0 -0
- package/build/client/_app/immutable/chunks/DfKeHoAm.js.br +0 -0
- package/build/client/_app/immutable/chunks/DfKeHoAm.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.Dp9YhfEg.js.br +0 -0
- package/build/client/_app/immutable/entry/app.Dp9YhfEg.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.Bc5yZsyK.js +0 -1
- package/build/client/_app/immutable/entry/start.Bc5yZsyK.js.br +0 -2
- package/build/client/_app/immutable/entry/start.Bc5yZsyK.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.B-M6sgow.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.B-M6sgow.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.C8aY7Yn3.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.C8aY7Yn3.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.YJZruh1H.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.YJZruh1H.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.VV-tRemY.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.VV-tRemY.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.CDJA8Na9.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.CDJA8Na9.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.BX9znBYU.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.BX9znBYU.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.CmdrNdfj.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.CmdrNdfj.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.BSleOtKF.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.BSleOtKF.js.gz +0 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// Named-tunnel auto-discovery and self-heal for cloudflared.
|
|
2
|
+
//
|
|
3
|
+
// On macOS, scans ~/.cloudflared/*.yml for tunnel configs that proxy a
|
|
4
|
+
// shooter-matching localhost port, then correlates them with LaunchAgents
|
|
5
|
+
// in ~/Library/LaunchAgents/*.plist that run `cloudflared tunnel ... run`
|
|
6
|
+
// against the same config. If a discovered LaunchAgent points to a stale
|
|
7
|
+
// cloudflared binary (e.g. an Intel Homebrew path after migrating to
|
|
8
|
+
// Apple Silicon), the plist is rewritten in-place against `which
|
|
9
|
+
// cloudflared` and reloaded via `launchctl bootout` + `bootstrap`.
|
|
10
|
+
//
|
|
11
|
+
// The Linux variant is intentionally limited to discovery (no auto-heal
|
|
12
|
+
// of systemd units yet); shooter on Linux primarily uses the quick-tunnel
|
|
13
|
+
// flow today.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const CLOUDFLARED_DIR = path.join(os.homedir(), '.cloudflared');
|
|
23
|
+
const LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
24
|
+
const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
25
|
+
|
|
26
|
+
// ── YAML parsing (intentionally minimal) ────────────────────────────
|
|
27
|
+
//
|
|
28
|
+
// Cloudflared tunnel configs are flat enough that we don't need a full
|
|
29
|
+
// YAML library. We extract only:
|
|
30
|
+
// - tunnel: <uuid>
|
|
31
|
+
// - credentials-file: <path>
|
|
32
|
+
// - ingress: [{ hostname, service, path? }, ...]
|
|
33
|
+
function parseCloudflaredConfig(yamlText) {
|
|
34
|
+
const lines = yamlText.split(/\r?\n/);
|
|
35
|
+
const out = { tunnel: null, credentialsFile: null, ingress: [] };
|
|
36
|
+
let inIngress = false;
|
|
37
|
+
let current = null;
|
|
38
|
+
|
|
39
|
+
for (const raw of lines) {
|
|
40
|
+
const line = raw.replace(/\s+#.*$/, '').trimEnd();
|
|
41
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
42
|
+
|
|
43
|
+
if (!inIngress) {
|
|
44
|
+
const tunnel = line.match(/^tunnel:\s*(\S+)/);
|
|
45
|
+
if (tunnel) {
|
|
46
|
+
out.tunnel = tunnel[1].replace(/^["']|["']$/g, '');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const creds = line.match(/^credentials-file:\s*(\S+)/);
|
|
50
|
+
if (creds) {
|
|
51
|
+
out.credentialsFile = creds[1].replace(/^["']|["']$/g, '');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (/^ingress:\s*$/.test(line)) {
|
|
55
|
+
inIngress = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// We're inside ingress:
|
|
62
|
+
if (/^[A-Za-z]/.test(line)) {
|
|
63
|
+
// A new top-level key terminates the ingress block.
|
|
64
|
+
if (current) out.ingress.push(current);
|
|
65
|
+
current = null;
|
|
66
|
+
inIngress = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const itemStart = line.match(/^\s*-\s*(\S+):\s*(.*)$/);
|
|
70
|
+
if (itemStart) {
|
|
71
|
+
if (current) out.ingress.push(current);
|
|
72
|
+
current = {};
|
|
73
|
+
current[itemStart[1]] = itemStart[2].replace(/^["']|["']$/g, '');
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const cont = line.match(/^\s+(\S+):\s*(.*)$/);
|
|
77
|
+
if (cont && current) {
|
|
78
|
+
current[cont[1]] = cont[2].replace(/^["']|["']$/g, '');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (current) out.ingress.push(current);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── plist parsing (intentionally minimal) ───────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// Apple's plist format supports XML and binary; LaunchAgents written by
|
|
88
|
+
// hand or by `cloudflared service install` are XML. We extract:
|
|
89
|
+
// - Label
|
|
90
|
+
// - ProgramArguments[] (array of string children of the array)
|
|
91
|
+
//
|
|
92
|
+
// Entities (&, <, >, ", ') are decoded so the
|
|
93
|
+
// captured values round-trip with rewritePlistBinaryPath (which encodes
|
|
94
|
+
// the same five) and compare correctly against on-disk paths used by
|
|
95
|
+
// e.g. `programArguments.includes(cfg.path)`.
|
|
96
|
+
function xmlUnescapeText(s) {
|
|
97
|
+
return s
|
|
98
|
+
.replace(/</g, '<')
|
|
99
|
+
.replace(/>/g, '>')
|
|
100
|
+
.replace(/"/g, '"')
|
|
101
|
+
.replace(/'/g, "'")
|
|
102
|
+
.replace(/&/g, '&'); // last so we don't double-decode
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseLaunchAgentPlist(xml) {
|
|
106
|
+
const out = { label: null, programArguments: [] };
|
|
107
|
+
|
|
108
|
+
const labelMatch = xml.match(/<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/);
|
|
109
|
+
if (labelMatch) out.label = xmlUnescapeText(labelMatch[1]);
|
|
110
|
+
|
|
111
|
+
const argsBlock = xml.match(/<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
112
|
+
if (argsBlock) {
|
|
113
|
+
const strs = argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g);
|
|
114
|
+
for (const m of strs) out.programArguments.push(xmlUnescapeText(m[1]));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── filesystem scans ────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function listCloudflaredConfigs() {
|
|
122
|
+
try {
|
|
123
|
+
return fs
|
|
124
|
+
.readdirSync(CLOUDFLARED_DIR)
|
|
125
|
+
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
126
|
+
.map((f) => path.join(CLOUDFLARED_DIR, f));
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function listLaunchAgents() {
|
|
133
|
+
try {
|
|
134
|
+
return fs
|
|
135
|
+
.readdirSync(LAUNCH_AGENTS_DIR)
|
|
136
|
+
.filter((f) => f.endsWith('.plist'))
|
|
137
|
+
.map((f) => path.join(LAUNCH_AGENTS_DIR, f));
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function listSystemdUserUnits() {
|
|
144
|
+
try {
|
|
145
|
+
return fs
|
|
146
|
+
.readdirSync(SYSTEMD_USER_DIR)
|
|
147
|
+
.filter((f) => f.endsWith('.service'))
|
|
148
|
+
.map((f) => path.join(SYSTEMD_USER_DIR, f));
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── matching ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function ingressMatchesPort(ingress, port) {
|
|
157
|
+
if (!ingress) return false;
|
|
158
|
+
const wanted = parseInt(port, 10);
|
|
159
|
+
if (isNaN(wanted)) return false;
|
|
160
|
+
for (const item of ingress) {
|
|
161
|
+
const svc = item.service || '';
|
|
162
|
+
// Only http[s] ingress with localhost / 127.0.0.1 host; compare the
|
|
163
|
+
// port numerically to avoid substring false-positives (e.g. port 80
|
|
164
|
+
// matching localhost:8080).
|
|
165
|
+
const m = svc.match(/^https?:\/\/(localhost|127\.0\.0\.1):(\d+)(?:\/|\?|#|$)/);
|
|
166
|
+
if (m && parseInt(m[2], 10) === wanted) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// When `port` is supplied, return the hostname of the ingress entry whose
|
|
172
|
+
// service actually targets that port — important when one tunnel routes
|
|
173
|
+
// several hostnames to several local ports. Falls back to the first
|
|
174
|
+
// hostname only if no entry matches (preserves the old behavior for
|
|
175
|
+
// callers that don't pass a port).
|
|
176
|
+
function hostnameFromIngress(ingress, port) {
|
|
177
|
+
if (!ingress) return null;
|
|
178
|
+
if (port != null) {
|
|
179
|
+
for (const item of ingress) {
|
|
180
|
+
if (item.hostname && ingressMatchesPort([item], port)) return item.hostname;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const item of ingress) {
|
|
184
|
+
if (item.hostname) return item.hostname;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── launchctl state ─────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
// Launchd labels are reverse-DNS strings; restrict to a conservative
|
|
192
|
+
// charset so a hostile plist Label can't influence argv parsing even
|
|
193
|
+
// though we already avoid the shell.
|
|
194
|
+
const LAUNCHD_LABEL_RE = /^[A-Za-z0-9._-]+$/;
|
|
195
|
+
|
|
196
|
+
function getLaunchctlState(label) {
|
|
197
|
+
if (!label || !LAUNCHD_LABEL_RE.test(label)) {
|
|
198
|
+
return { loaded: false, state: null, pid: null, lastExitCode: null };
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const uid = process.getuid
|
|
202
|
+
? process.getuid()
|
|
203
|
+
: execFileSync('id', ['-u'], {
|
|
204
|
+
encoding: 'utf8',
|
|
205
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
206
|
+
}).trim();
|
|
207
|
+
const out = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
|
|
208
|
+
encoding: 'utf8',
|
|
209
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
210
|
+
});
|
|
211
|
+
const stateMatch = out.match(/^\s*state\s*=\s*(\S+)/m);
|
|
212
|
+
const pidMatch = out.match(/^\s*pid\s*=\s*(\d+)/m);
|
|
213
|
+
const exitMatch = out.match(/^\s*last exit code\s*=\s*(.+)$/m);
|
|
214
|
+
return {
|
|
215
|
+
loaded: true,
|
|
216
|
+
state: stateMatch ? stateMatch[1] : null,
|
|
217
|
+
pid: pidMatch ? parseInt(pidMatch[1], 10) : null,
|
|
218
|
+
lastExitCode: exitMatch ? exitMatch[1] : null,
|
|
219
|
+
};
|
|
220
|
+
} catch {
|
|
221
|
+
return { loaded: false, state: null, pid: null, lastExitCode: null };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── binary path validation ──────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function isExecutable(p) {
|
|
228
|
+
try {
|
|
229
|
+
// Resolve through symlinks; if the chain dangles, fs.statSync throws.
|
|
230
|
+
fs.statSync(p);
|
|
231
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
232
|
+
return true;
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveCloudflaredBinary() {
|
|
239
|
+
// Prefer `which`, falling back to common Homebrew prefixes. We never
|
|
240
|
+
// return a path that doesn't actually exist.
|
|
241
|
+
try {
|
|
242
|
+
const p = execFileSync('which', ['cloudflared'], {
|
|
243
|
+
encoding: 'utf8',
|
|
244
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
245
|
+
}).trim();
|
|
246
|
+
if (p && isExecutable(p)) return p;
|
|
247
|
+
} catch {}
|
|
248
|
+
for (const candidate of [
|
|
249
|
+
'/opt/homebrew/bin/cloudflared',
|
|
250
|
+
'/usr/local/bin/cloudflared',
|
|
251
|
+
'/usr/bin/cloudflared',
|
|
252
|
+
path.join(os.homedir(), '.local', 'bin', 'cloudflared'),
|
|
253
|
+
]) {
|
|
254
|
+
if (isExecutable(candidate)) return candidate;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── discovery (public) ──────────────────────────────────────────────
|
|
260
|
+
//
|
|
261
|
+
// Returns an array of NamedTunnel records:
|
|
262
|
+
// {
|
|
263
|
+
// kind: 'launchd' | 'systemd',
|
|
264
|
+
// label,
|
|
265
|
+
// unitPath, // .plist on macOS, .service on Linux
|
|
266
|
+
// configPath,
|
|
267
|
+
// hostname, port,
|
|
268
|
+
// binaryPath, // what the unit *currently* says
|
|
269
|
+
// binaryPathHealthy, // does that file exist + execute?
|
|
270
|
+
// tunnelUuid, credentialsFile,
|
|
271
|
+
// launch: { loaded, state, pid, lastExitCode } | null,
|
|
272
|
+
// }
|
|
273
|
+
function discoverNamedTunnels(port) {
|
|
274
|
+
const platform = os.platform();
|
|
275
|
+
const configs = listCloudflaredConfigs()
|
|
276
|
+
.map((p) => {
|
|
277
|
+
try {
|
|
278
|
+
return { path: p, data: parseCloudflaredConfig(fs.readFileSync(p, 'utf8')) };
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
.filter((c) => c && ingressMatchesPort(c.data.ingress, port));
|
|
284
|
+
|
|
285
|
+
if (configs.length === 0) return [];
|
|
286
|
+
|
|
287
|
+
if (platform === 'darwin') {
|
|
288
|
+
const agents = listLaunchAgents()
|
|
289
|
+
.map((p) => {
|
|
290
|
+
try {
|
|
291
|
+
return { path: p, data: parseLaunchAgentPlist(fs.readFileSync(p, 'utf8')) };
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
.filter(
|
|
297
|
+
(a) =>
|
|
298
|
+
a && a.data.programArguments.length > 0 && /cloudflared$/.test(a.data.programArguments[0])
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const tunnels = [];
|
|
302
|
+
for (const cfg of configs) {
|
|
303
|
+
const agent = agents.find((a) => a.data.programArguments.includes(cfg.path));
|
|
304
|
+
if (!agent) continue;
|
|
305
|
+
const binaryPath = agent.data.programArguments[0];
|
|
306
|
+
const launch = agent.data.label ? getLaunchctlState(agent.data.label) : null;
|
|
307
|
+
tunnels.push({
|
|
308
|
+
kind: 'launchd',
|
|
309
|
+
label: agent.data.label,
|
|
310
|
+
unitPath: agent.path,
|
|
311
|
+
configPath: cfg.path,
|
|
312
|
+
hostname: hostnameFromIngress(cfg.data.ingress, port),
|
|
313
|
+
port,
|
|
314
|
+
binaryPath,
|
|
315
|
+
binaryPathHealthy: isExecutable(binaryPath),
|
|
316
|
+
tunnelUuid: cfg.data.tunnel,
|
|
317
|
+
credentialsFile: cfg.data.credentialsFile,
|
|
318
|
+
launch,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return tunnels;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (platform === 'linux') {
|
|
325
|
+
const units = listSystemdUserUnits()
|
|
326
|
+
.map((p) => {
|
|
327
|
+
try {
|
|
328
|
+
return { path: p, contents: fs.readFileSync(p, 'utf8') };
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.filter((u) => u && /cloudflared\b.*\btunnel\b/.test(u.contents));
|
|
334
|
+
|
|
335
|
+
const tunnels = [];
|
|
336
|
+
for (const cfg of configs) {
|
|
337
|
+
const unit = units.find((u) => u.contents.includes(cfg.path));
|
|
338
|
+
if (!unit) continue;
|
|
339
|
+
const execMatch = unit.contents.match(/ExecStart\s*=\s*(\S+)/);
|
|
340
|
+
const rawBinary = execMatch ? execMatch[1] : 'cloudflared';
|
|
341
|
+
// systemd unit files commonly use a bare command name
|
|
342
|
+
// (`ExecStart=cloudflared …`) and resolve it via $PATH at exec
|
|
343
|
+
// time. `isExecutable` does a literal stat, so a bare name would
|
|
344
|
+
// always report unhealthy. Resolve to a real path first.
|
|
345
|
+
const binaryPath = rawBinary.includes('/')
|
|
346
|
+
? rawBinary
|
|
347
|
+
: resolveCloudflaredBinary() || rawBinary;
|
|
348
|
+
tunnels.push({
|
|
349
|
+
kind: 'systemd',
|
|
350
|
+
label: path.basename(unit.path, '.service'),
|
|
351
|
+
unitPath: unit.path,
|
|
352
|
+
configPath: cfg.path,
|
|
353
|
+
hostname: hostnameFromIngress(cfg.data.ingress, port),
|
|
354
|
+
port,
|
|
355
|
+
binaryPath,
|
|
356
|
+
binaryPathHealthy: isExecutable(binaryPath),
|
|
357
|
+
tunnelUuid: cfg.data.tunnel,
|
|
358
|
+
credentialsFile: cfg.data.credentialsFile,
|
|
359
|
+
launch: null, // systemctl status integration is deferred
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return tunnels;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── self-heal ───────────────────────────────────────────────────────
|
|
369
|
+
//
|
|
370
|
+
// Rewrites the plist's first <string> under ProgramArguments (the
|
|
371
|
+
// program path) to `newPath`. We write a `.bak` copy first; the
|
|
372
|
+
// rewrite uses a string slice rather than an XML library to preserve
|
|
373
|
+
// the file's exact formatting / quirks. `newPath` is XML-escaped so a
|
|
374
|
+
// filesystem path containing `&`, `<`, or `>` can't corrupt the plist.
|
|
375
|
+
function xmlEscapeText(s) {
|
|
376
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function rewritePlistBinaryPath(plistPath, oldPath, newPath) {
|
|
380
|
+
const xml = fs.readFileSync(plistPath, 'utf8');
|
|
381
|
+
// Locate the first <string>...</string> inside ProgramArguments.
|
|
382
|
+
const argsStart = xml.indexOf('<key>ProgramArguments</key>');
|
|
383
|
+
if (argsStart < 0) {
|
|
384
|
+
throw new Error(`No ProgramArguments key in ${plistPath}`);
|
|
385
|
+
}
|
|
386
|
+
const arrayOpen = xml.indexOf('<array>', argsStart);
|
|
387
|
+
if (arrayOpen < 0) throw new Error(`No <array> after ProgramArguments`);
|
|
388
|
+
const firstStringOpen = xml.indexOf('<string>', arrayOpen);
|
|
389
|
+
const firstStringClose = xml.indexOf('</string>', firstStringOpen);
|
|
390
|
+
if (firstStringOpen < 0 || firstStringClose < 0) {
|
|
391
|
+
throw new Error(`Malformed ProgramArguments array`);
|
|
392
|
+
}
|
|
393
|
+
const existingRaw = xml.slice(firstStringOpen + '<string>'.length, firstStringClose);
|
|
394
|
+
// Compare in the decoded domain so paths containing XML special chars
|
|
395
|
+
// round-trip with parseLaunchAgentPlist (which decodes the same entities).
|
|
396
|
+
const existing = xmlUnescapeText(existingRaw);
|
|
397
|
+
if (existing !== oldPath) {
|
|
398
|
+
throw new Error(`ProgramArguments[0] is ${existing}, expected ${oldPath} — refusing to heal`);
|
|
399
|
+
}
|
|
400
|
+
const next =
|
|
401
|
+
xml.slice(0, firstStringOpen + '<string>'.length) +
|
|
402
|
+
xmlEscapeText(newPath) +
|
|
403
|
+
xml.slice(firstStringClose);
|
|
404
|
+
|
|
405
|
+
fs.writeFileSync(plistPath + '.bak', xml);
|
|
406
|
+
fs.writeFileSync(plistPath, next);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function reloadLaunchAgent(plistPath, label) {
|
|
410
|
+
const uid = process.getuid
|
|
411
|
+
? process.getuid()
|
|
412
|
+
: execFileSync('id', ['-u'], {
|
|
413
|
+
encoding: 'utf8',
|
|
414
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
415
|
+
}).trim();
|
|
416
|
+
// bootout may fail if not loaded; ignore.
|
|
417
|
+
try {
|
|
418
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}/${label}`], {
|
|
419
|
+
stdio: 'ignore',
|
|
420
|
+
});
|
|
421
|
+
} catch {}
|
|
422
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], {
|
|
423
|
+
stdio: 'ignore',
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function kickstartLaunchAgent(label) {
|
|
428
|
+
const uid = process.getuid
|
|
429
|
+
? process.getuid()
|
|
430
|
+
: execFileSync('id', ['-u'], {
|
|
431
|
+
encoding: 'utf8',
|
|
432
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
433
|
+
}).trim();
|
|
434
|
+
try {
|
|
435
|
+
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], {
|
|
436
|
+
stdio: 'ignore',
|
|
437
|
+
});
|
|
438
|
+
} catch {}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Heal-and-ensure-running. Returns one of:
|
|
442
|
+
// { action: 'healed', from, to }
|
|
443
|
+
// { action: 'reloaded' } - was loaded, kickstarted
|
|
444
|
+
// { action: 'started' } - was not loaded, bootstrapped
|
|
445
|
+
// { action: 'ok' } - already running, nothing done
|
|
446
|
+
// { action: 'failed', reason }
|
|
447
|
+
function healAndEnsureRunning(tunnel) {
|
|
448
|
+
if (tunnel.kind !== 'launchd') {
|
|
449
|
+
return { action: 'ok' }; // systemd auto-heal is out of scope
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
if (!tunnel.binaryPathHealthy) {
|
|
453
|
+
const realPath = resolveCloudflaredBinary();
|
|
454
|
+
if (!realPath) {
|
|
455
|
+
return {
|
|
456
|
+
action: 'failed',
|
|
457
|
+
reason: 'cloudflared binary not found on PATH',
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
rewritePlistBinaryPath(tunnel.unitPath, tunnel.binaryPath, realPath);
|
|
461
|
+
reloadLaunchAgent(tunnel.unitPath, tunnel.label);
|
|
462
|
+
return { action: 'healed', from: tunnel.binaryPath, to: realPath };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!tunnel.launch || !tunnel.launch.loaded) {
|
|
466
|
+
reloadLaunchAgent(tunnel.unitPath, tunnel.label);
|
|
467
|
+
return { action: 'started' };
|
|
468
|
+
}
|
|
469
|
+
if (tunnel.launch.state !== 'running') {
|
|
470
|
+
kickstartLaunchAgent(tunnel.label);
|
|
471
|
+
return { action: 'reloaded' };
|
|
472
|
+
}
|
|
473
|
+
return { action: 'ok' };
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return { action: 'failed', reason: err.message };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── reachability ────────────────────────────────────────────────────
|
|
480
|
+
//
|
|
481
|
+
// Curl the public URL with a short timeout. We use curl directly for
|
|
482
|
+
// the same reason apn.ts does: native http modules occasionally fail
|
|
483
|
+
// at TLS on this user's Node.
|
|
484
|
+
function probeReachability(url, timeoutMs = 5000) {
|
|
485
|
+
try {
|
|
486
|
+
const out = execFileSync(
|
|
487
|
+
'curl',
|
|
488
|
+
[
|
|
489
|
+
'-s',
|
|
490
|
+
'-o',
|
|
491
|
+
'/dev/null',
|
|
492
|
+
'-w',
|
|
493
|
+
'%{http_code}',
|
|
494
|
+
'--max-time',
|
|
495
|
+
String(Math.ceil(timeoutMs / 1000)),
|
|
496
|
+
url,
|
|
497
|
+
],
|
|
498
|
+
{ encoding: 'utf8', timeout: timeoutMs + 2000 }
|
|
499
|
+
).trim();
|
|
500
|
+
const code = parseInt(out, 10);
|
|
501
|
+
return { ok: code >= 200 && code < 500, status: isNaN(code) ? null : code };
|
|
502
|
+
} catch {
|
|
503
|
+
return { ok: false, status: null };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
module.exports = {
|
|
508
|
+
discoverNamedTunnels,
|
|
509
|
+
healAndEnsureRunning,
|
|
510
|
+
probeReachability,
|
|
511
|
+
resolveCloudflaredBinary,
|
|
512
|
+
xmlEscapeText,
|
|
513
|
+
// exported for tests
|
|
514
|
+
parseCloudflaredConfig,
|
|
515
|
+
parseLaunchAgentPlist,
|
|
516
|
+
rewritePlistBinaryPath,
|
|
517
|
+
ingressMatchesPort,
|
|
518
|
+
hostnameFromIngress,
|
|
519
|
+
};
|