repoburg 1.3.217 → 1.3.218
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/daemon/dist/agentPane.js +13 -8
- package/daemon/dist/auth/authManager.js +9 -0
- package/daemon/dist/index.js +32 -5
- package/daemon/dist/middleware/checkRemoteAuth.js +72 -0
- package/daemon/dist/middleware/checkRemoteAuth.spec.js +117 -0
- package/daemon/dist/middleware/corsOrigin.js +53 -0
- package/daemon/dist/middleware/corsOrigin.spec.js +46 -0
- package/daemon/dist/remote/access-jwt.js +67 -0
- package/daemon/dist/remote/access-jwt.spec.js +94 -0
- package/daemon/dist/remote/cloudflared.js +239 -0
- package/daemon/dist/remote/cloudflared.spec.js +117 -0
- package/daemon/dist/remote/orchestrator.js +245 -0
- package/daemon/dist/remote/orchestrator.spec.js +192 -0
- package/daemon/dist/remote/probes.js +59 -0
- package/daemon/dist/remote/provisioner.js +86 -0
- package/daemon/dist/remote/provisioner.spec.js +104 -0
- package/daemon/dist/remote/routes.js +155 -0
- package/daemon/dist/remote/routes.spec.js +245 -0
- package/daemon/dist/remote/tunnel-state.js +54 -0
- package/daemon/dist/remote/tunnel-state.spec.js +40 -0
- package/daemon/dist/staticServing.js +44 -0
- package/daemon/dist/staticServing.spec.js +85 -0
- package/package.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.defaultExecutor = exports.CLOUDFLARED_METRICS_PORT = void 0;
|
|
7
|
+
exports.detectPlatform = detectPlatform;
|
|
8
|
+
exports.assetName = assetName;
|
|
9
|
+
exports.installDir = installDir;
|
|
10
|
+
exports.cloudflaredBinaryPath = cloudflaredBinaryPath;
|
|
11
|
+
exports.isInstalled = isInstalled;
|
|
12
|
+
exports.selectChecksum = selectChecksum;
|
|
13
|
+
exports.downloadCloudflared = downloadCloudflared;
|
|
14
|
+
exports.cloudflaredMetricsUrl = cloudflaredMetricsUrl;
|
|
15
|
+
exports.systemdUnitContent = systemdUnitContent;
|
|
16
|
+
exports.launchdPlistContent = launchdPlistContent;
|
|
17
|
+
exports.systemdUnitPath = systemdUnitPath;
|
|
18
|
+
exports.launchdPlistPath = launchdPlistPath;
|
|
19
|
+
exports.installService = installService;
|
|
20
|
+
exports.removeService = removeService;
|
|
21
|
+
exports.isServiceRunning = isServiceRunning;
|
|
22
|
+
/**
|
|
23
|
+
* cloudflared installer + system service management (user-level, no root).
|
|
24
|
+
*
|
|
25
|
+
* Download spec: latest GitHub release asset per platform, verified against
|
|
26
|
+
* the release's checksums.txt. macOS binaries are shipped as .tgz; the
|
|
27
|
+
* fallback handles both plain and archived assets.
|
|
28
|
+
*/
|
|
29
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
30
|
+
const path_1 = __importDefault(require("path"));
|
|
31
|
+
const os_1 = __importDefault(require("os"));
|
|
32
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
33
|
+
const child_process_1 = require("child_process");
|
|
34
|
+
const util_1 = require("util");
|
|
35
|
+
const exec = (0, util_1.promisify)(child_process_1.execFile);
|
|
36
|
+
const RELEASE_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
37
|
+
function detectPlatform() {
|
|
38
|
+
const platform = os_1.default.platform();
|
|
39
|
+
const arch = os_1.default.arch();
|
|
40
|
+
if (platform !== 'linux' && platform !== 'darwin') {
|
|
41
|
+
throw new Error(`Unsupported platform for cloudflared: ${platform}`);
|
|
42
|
+
}
|
|
43
|
+
if (arch !== 'x64' && arch !== 'arm64') {
|
|
44
|
+
throw new Error(`Unsupported architecture for cloudflared: ${arch}`);
|
|
45
|
+
}
|
|
46
|
+
return { platform, arch };
|
|
47
|
+
}
|
|
48
|
+
/** Release asset name for a platform (plain binary). */
|
|
49
|
+
function assetName({ platform, arch }) {
|
|
50
|
+
const osPart = platform === 'darwin' ? 'darwin' : 'linux';
|
|
51
|
+
const archPart = arch === 'x64' ? 'amd64' : 'arm64';
|
|
52
|
+
return `cloudflared-${osPart}-${archPart}`;
|
|
53
|
+
}
|
|
54
|
+
function installDir(home = os_1.default.homedir()) {
|
|
55
|
+
return path_1.default.join(home, '.repoburg', 'bin');
|
|
56
|
+
}
|
|
57
|
+
function cloudflaredBinaryPath(home = os_1.default.homedir()) {
|
|
58
|
+
return path_1.default.join(installDir(home), 'cloudflared');
|
|
59
|
+
}
|
|
60
|
+
function isInstalled(home = os_1.default.homedir()) {
|
|
61
|
+
return fs_extra_1.default.existsSync(cloudflaredBinaryPath(home));
|
|
62
|
+
}
|
|
63
|
+
/** Pick the sha256 from the checksums.txt content for an asset. */
|
|
64
|
+
function selectChecksum(checksumsTxt, asset) {
|
|
65
|
+
const line = checksumsTxt.split('\n').find((entry) => entry.trim().endsWith(asset));
|
|
66
|
+
const match = line?.match(/^([0-9a-f]{64})\s+\S+$/);
|
|
67
|
+
return match ? match[1] : null;
|
|
68
|
+
}
|
|
69
|
+
async function fetchText(url) {
|
|
70
|
+
const response = await fetch(url);
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
throw new Error(`Download failed (${response.status}): ${url}`);
|
|
73
|
+
return response.text();
|
|
74
|
+
}
|
|
75
|
+
async function fetchBuffer(url) {
|
|
76
|
+
const response = await fetch(url);
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`Download failed (${response.status}): ${url}`);
|
|
79
|
+
return Buffer.from(await response.arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Download cloudflared into ~/.repoburg/bin, sha256-verified.
|
|
83
|
+
* Throws when the checksum does not match — never install an unverified binary.
|
|
84
|
+
*/
|
|
85
|
+
async function downloadCloudflared(options = {}) {
|
|
86
|
+
const home = options.home ?? os_1.default.homedir();
|
|
87
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
88
|
+
const platformInfo = options.platform ?? detectPlatform();
|
|
89
|
+
const asset = assetName(platformInfo);
|
|
90
|
+
const checksumsTxt = await (async (url) => {
|
|
91
|
+
const response = await fetchImpl(url);
|
|
92
|
+
if (!response.ok)
|
|
93
|
+
throw new Error(`Checksums download failed (${response.status})`);
|
|
94
|
+
return response.text();
|
|
95
|
+
})(`${RELEASE_BASE}/cloudflared_checksums.txt`);
|
|
96
|
+
const expected = selectChecksum(checksumsTxt, asset);
|
|
97
|
+
if (!expected)
|
|
98
|
+
throw new Error(`No checksum found for asset ${asset}`);
|
|
99
|
+
const dir = installDir(home);
|
|
100
|
+
fs_extra_1.default.ensureDirSync(dir);
|
|
101
|
+
let binary;
|
|
102
|
+
const binaryUrl = `${RELEASE_BASE}/${asset}`;
|
|
103
|
+
const binaryResponse = await fetchImpl(binaryUrl);
|
|
104
|
+
if (binaryResponse.ok) {
|
|
105
|
+
binary = Buffer.from(await binaryResponse.arrayBuffer());
|
|
106
|
+
}
|
|
107
|
+
else if (platformInfo.platform === 'darwin') {
|
|
108
|
+
// macOS releases ship as .tgz; extract with the system tar.
|
|
109
|
+
const tgz = Buffer.from(await (await fetchImpl(`${binaryUrl}.tgz`)).arrayBuffer());
|
|
110
|
+
const tgzPath = path_1.default.join(dir, `${asset}.tgz`);
|
|
111
|
+
fs_extra_1.default.writeFileSync(tgzPath, tgz);
|
|
112
|
+
await exec('tar', ['-xzf', tgzPath, '-C', dir]);
|
|
113
|
+
fs_extra_1.default.removeSync(tgzPath);
|
|
114
|
+
const binaryPath = cloudflaredBinaryPath(home);
|
|
115
|
+
if (!fs_extra_1.default.existsSync(binaryPath))
|
|
116
|
+
throw new Error('cloudflared extraction produced no binary');
|
|
117
|
+
return binaryPath;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
throw new Error(`Binary download failed (${binaryResponse.status})`);
|
|
121
|
+
}
|
|
122
|
+
const actual = crypto_1.default.createHash('sha256').update(binary).digest('hex');
|
|
123
|
+
if (actual !== expected) {
|
|
124
|
+
throw new Error(`Checksum mismatch for ${asset}: expected ${expected}, got ${actual}`);
|
|
125
|
+
}
|
|
126
|
+
const binaryPath = cloudflaredBinaryPath(home);
|
|
127
|
+
fs_extra_1.default.writeFileSync(binaryPath, binary);
|
|
128
|
+
fs_extra_1.default.chmodSync(binaryPath, 0o755);
|
|
129
|
+
return binaryPath;
|
|
130
|
+
}
|
|
131
|
+
// --- System service files (user-level, no root required) ---
|
|
132
|
+
const SERVICE_NAME_LINUX = 'repoburg-cloudflared';
|
|
133
|
+
const SERVICE_ID_DARWIN = 'com.repoburg.cloudflared';
|
|
134
|
+
/** Loopback metrics endpoint (tunnel connectivity is probed from here). */
|
|
135
|
+
exports.CLOUDFLARED_METRICS_PORT = 20241;
|
|
136
|
+
function cloudflaredMetricsUrl() {
|
|
137
|
+
return `http://127.0.0.1:${exports.CLOUDFLARED_METRICS_PORT}/metrics`;
|
|
138
|
+
}
|
|
139
|
+
function systemdUnitContent(binaryPath, tunnelToken) {
|
|
140
|
+
return ` [Unit]
|
|
141
|
+
Description=Repoburg remote access tunnel (cloudflared)
|
|
142
|
+
After=network-online.target
|
|
143
|
+
|
|
144
|
+
[Service]
|
|
145
|
+
ExecStart=${binaryPath} tunnel --metrics 127.0.0.1:${exports.CLOUDFLARED_METRICS_PORT} run --token ${tunnelToken}
|
|
146
|
+
Restart=always
|
|
147
|
+
RestartSec=5
|
|
148
|
+
|
|
149
|
+
[Install]
|
|
150
|
+
WantedBy=default.target
|
|
151
|
+
`.trimStart();
|
|
152
|
+
}
|
|
153
|
+
function launchdPlistContent(binaryPath, tunnelToken) {
|
|
154
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
156
|
+
<plist version="1.0">
|
|
157
|
+
<dict>
|
|
158
|
+
<key>Label</key><string>${SERVICE_ID_DARWIN}</string>
|
|
159
|
+
<key>ProgramArguments</key>
|
|
160
|
+
<array>
|
|
161
|
+
<string>${binaryPath}</string>
|
|
162
|
+
<string>tunnel</string>
|
|
163
|
+
<string>--metrics</string>
|
|
164
|
+
<string>127.0.0.1:${exports.CLOUDFLARED_METRICS_PORT}</string>
|
|
165
|
+
<string>run</string>
|
|
166
|
+
<string>--token</string>
|
|
167
|
+
<string>${tunnelToken}</string>
|
|
168
|
+
</array>
|
|
169
|
+
<key>RunAtLoad</key><true/>
|
|
170
|
+
<key>KeepAlive</key><true/>
|
|
171
|
+
<key>StandardOutPath</key><string>${installDir()}/cloudflared.log</string>
|
|
172
|
+
<key>StandardErrorPath</key><string>${installDir()}/cloudflared.log</string>
|
|
173
|
+
</dict>
|
|
174
|
+
</plist>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
function systemdUnitPath(home = os_1.default.homedir()) {
|
|
178
|
+
return path_1.default.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME_LINUX}.service`);
|
|
179
|
+
}
|
|
180
|
+
function launchdPlistPath(home = os_1.default.homedir()) {
|
|
181
|
+
return path_1.default.join(home, 'Library', 'LaunchAgents', `${SERVICE_ID_DARWIN}.plist`);
|
|
182
|
+
}
|
|
183
|
+
exports.defaultExecutor = exec;
|
|
184
|
+
/** Write the service file and enable it (idempotent). Returns the service file path. */
|
|
185
|
+
async function installService(tunnelToken, options = {}) {
|
|
186
|
+
const home = options.home ?? os_1.default.homedir();
|
|
187
|
+
const platform = options.platform ?? os_1.default.platform();
|
|
188
|
+
const executor = options.executor ?? exports.defaultExecutor;
|
|
189
|
+
const binaryPath = cloudflaredBinaryPath(home);
|
|
190
|
+
if (platform === 'darwin') {
|
|
191
|
+
const plistPath = launchdPlistPath(home);
|
|
192
|
+
fs_extra_1.default.ensureDirSync(path_1.default.dirname(plistPath));
|
|
193
|
+
fs_extra_1.default.writeFileSync(plistPath, launchdPlistContent(binaryPath, tunnelToken));
|
|
194
|
+
await executor('launchctl', ['unload', plistPath]).catch(() => undefined);
|
|
195
|
+
await executor('launchctl', ['load', plistPath]);
|
|
196
|
+
return plistPath;
|
|
197
|
+
}
|
|
198
|
+
const unitPath = systemdUnitPath(home);
|
|
199
|
+
fs_extra_1.default.ensureDirSync(path_1.default.dirname(unitPath));
|
|
200
|
+
fs_extra_1.default.writeFileSync(unitPath, systemdUnitContent(binaryPath, tunnelToken));
|
|
201
|
+
await executor('systemctl', ['--user', 'daemon-reload']);
|
|
202
|
+
await executor('systemctl', ['--user', 'enable', '--now', SERVICE_NAME_LINUX]);
|
|
203
|
+
return unitPath;
|
|
204
|
+
}
|
|
205
|
+
async function removeService(options = {}) {
|
|
206
|
+
const home = options.home ?? os_1.default.homedir();
|
|
207
|
+
const platform = options.platform ?? os_1.default.platform();
|
|
208
|
+
const executor = options.executor ?? exports.defaultExecutor;
|
|
209
|
+
if (platform === 'darwin') {
|
|
210
|
+
const plistPath = launchdPlistPath(home);
|
|
211
|
+
if (fs_extra_1.default.existsSync(plistPath)) {
|
|
212
|
+
await executor('launchctl', ['unload', plistPath]).catch(() => undefined);
|
|
213
|
+
fs_extra_1.default.removeSync(plistPath);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await executor('systemctl', ['--user', 'disable', '--now', SERVICE_NAME_LINUX]).catch(() => undefined);
|
|
218
|
+
fs_extra_1.default.removeSync(systemdUnitPath(home));
|
|
219
|
+
}
|
|
220
|
+
async function isServiceRunning(options = {}) {
|
|
221
|
+
const platform = options.platform ?? os_1.default.platform();
|
|
222
|
+
const executor = options.executor ?? exports.defaultExecutor;
|
|
223
|
+
if (platform === 'darwin') {
|
|
224
|
+
try {
|
|
225
|
+
const { stdout } = await executor('launchctl', ['list']);
|
|
226
|
+
return stdout.includes(SERVICE_ID_DARWIN);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const { stdout } = await executor('systemctl', ['--user', 'is-active', SERVICE_NAME_LINUX]);
|
|
234
|
+
return stdout.trim() === 'active';
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const cloudflared_1 = require("./cloudflared");
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const tmpHome = fs_extra_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'cloudflared-'));
|
|
11
|
+
describe('cloudflared', () => {
|
|
12
|
+
describe('assetName', () => {
|
|
13
|
+
it('maps platforms to release asset names', () => {
|
|
14
|
+
expect((0, cloudflared_1.assetName)({ platform: 'darwin', arch: 'x64' })).toBe('cloudflared-darwin-amd64');
|
|
15
|
+
expect((0, cloudflared_1.assetName)({ platform: 'darwin', arch: 'arm64' })).toBe('cloudflared-darwin-arm64');
|
|
16
|
+
expect((0, cloudflared_1.assetName)({ platform: 'linux', arch: 'x64' })).toBe('cloudflared-linux-amd64');
|
|
17
|
+
expect((0, cloudflared_1.assetName)({ platform: 'linux', arch: 'arm64' })).toBe('cloudflared-linux-arm64');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('selectChecksum', () => {
|
|
21
|
+
it('finds the sha256 for an asset in checksums.txt', () => {
|
|
22
|
+
const txt = [
|
|
23
|
+
`${'a'.repeat(64)} cloudflared-linux-amd64`,
|
|
24
|
+
`${'b'.repeat(64)} cloudflared-darwin-arm64`,
|
|
25
|
+
].join('\n');
|
|
26
|
+
expect((0, cloudflared_1.selectChecksum)(txt, 'cloudflared-darwin-arm64')).toBe('b'.repeat(64));
|
|
27
|
+
});
|
|
28
|
+
it('returns null for unknown assets', () => {
|
|
29
|
+
expect((0, cloudflared_1.selectChecksum)('aaa111 other-binary', 'cloudflared-linux-amd64')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('service file content', () => {
|
|
33
|
+
it('builds a systemd unit running the tunnel with the token and metrics flag', () => {
|
|
34
|
+
const content = (0, cloudflared_1.systemdUnitContent)('/bin/cloudflared', 'TOKEN123');
|
|
35
|
+
// --metrics is a TUNNEL-level flag — it must come BEFORE the run
|
|
36
|
+
// subcommand (verified live: `tunnel run --metrics` is rejected).
|
|
37
|
+
expect(content).toContain('ExecStart=/bin/cloudflared tunnel --metrics 127.0.0.1:20241 run --token TOKEN123');
|
|
38
|
+
expect(content).toContain('WantedBy=default.target');
|
|
39
|
+
});
|
|
40
|
+
it('builds a launchd plist running the tunnel with the token and metrics flag', () => {
|
|
41
|
+
const content = (0, cloudflared_1.launchdPlistContent)('/bin/cloudflared', 'TOKEN123');
|
|
42
|
+
const args = content.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('<string>'));
|
|
43
|
+
// Metrics flag must precede the `run` subcommand.
|
|
44
|
+
expect(args.indexOf('<string>tunnel</string>')).toBeLessThan(args.indexOf('<string>run</string>'));
|
|
45
|
+
expect(content).toContain('<string>--metrics</string>');
|
|
46
|
+
expect(content).toContain('<string>127.0.0.1:20241</string>');
|
|
47
|
+
expect(content).toContain('<string>--token</string>');
|
|
48
|
+
expect(content).toContain('<string>TOKEN123</string>');
|
|
49
|
+
expect(content).toContain('<key>KeepAlive</key><true/>');
|
|
50
|
+
});
|
|
51
|
+
it('exposes the metrics URL on loopback', () => {
|
|
52
|
+
expect((0, cloudflared_1.cloudflaredMetricsUrl)()).toBe('http://127.0.0.1:20241/metrics');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('downloadCloudflared', () => {
|
|
56
|
+
const linux = { platform: 'linux', arch: 'x64' };
|
|
57
|
+
const makeFetch = (sha) => jest.fn(async (url) => {
|
|
58
|
+
const href = String(url);
|
|
59
|
+
if (href.endsWith('cloudflared_checksums.txt')) {
|
|
60
|
+
return { ok: true, text: async () => `${sha} cloudflared-linux-amd64`, status: 200 };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]), status: 200 };
|
|
63
|
+
});
|
|
64
|
+
it('downloads, verifies the checksum, and writes the binary', async () => {
|
|
65
|
+
const sha = require('crypto').createHash('sha256').update(Buffer.from([1, 2, 3, 4])).digest('hex');
|
|
66
|
+
const binaryPath = await (0, cloudflared_1.downloadCloudflared)({ home: tmpHome, platform: linux, fetchImpl: makeFetch(sha) });
|
|
67
|
+
expect(binaryPath).toBe((0, cloudflared_1.cloudflaredBinaryPath)(tmpHome));
|
|
68
|
+
expect(fs_extra_1.default.existsSync(binaryPath)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('rejects a binary whose checksum does not match', async () => {
|
|
71
|
+
await expect((0, cloudflared_1.downloadCloudflared)({ home: tmpHome, platform: linux, fetchImpl: makeFetch('0'.repeat(64)) })).rejects.toThrow(/Checksum mismatch/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('system service', () => {
|
|
75
|
+
const calls = [];
|
|
76
|
+
const executor = jest.fn(async (cmd, args) => {
|
|
77
|
+
calls.push([cmd, ...args]);
|
|
78
|
+
return { stdout: 'active\n' };
|
|
79
|
+
});
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
calls.length = 0;
|
|
82
|
+
executor.mockClear();
|
|
83
|
+
});
|
|
84
|
+
it('writes a systemd unit and enables it on linux', async () => {
|
|
85
|
+
const unitPath = await (0, cloudflared_1.installService)('TOKEN', {
|
|
86
|
+
home: tmpHome,
|
|
87
|
+
platform: 'linux',
|
|
88
|
+
executor: executor,
|
|
89
|
+
});
|
|
90
|
+
expect(unitPath).toBe((0, cloudflared_1.systemdUnitPath)(tmpHome));
|
|
91
|
+
expect(fs_extra_1.default.existsSync(unitPath)).toBe(true);
|
|
92
|
+
expect(calls[0]).toEqual(['systemctl', '--user', 'daemon-reload']);
|
|
93
|
+
expect(calls[1]).toEqual(['systemctl', '--user', 'enable', '--now', 'repoburg-cloudflared']);
|
|
94
|
+
});
|
|
95
|
+
it('writes a launchd plist and loads it on darwin', async () => {
|
|
96
|
+
const plistPath = await (0, cloudflared_1.installService)('TOKEN', {
|
|
97
|
+
home: tmpHome,
|
|
98
|
+
platform: 'darwin',
|
|
99
|
+
executor: executor,
|
|
100
|
+
});
|
|
101
|
+
expect(plistPath).toBe((0, cloudflared_1.launchdPlistPath)(tmpHome));
|
|
102
|
+
expect(fs_extra_1.default.existsSync(plistPath)).toBe(true);
|
|
103
|
+
expect(calls.at(-1)).toEqual(['launchctl', 'load', plistPath]);
|
|
104
|
+
});
|
|
105
|
+
it('reports the systemd service as running when active', async () => {
|
|
106
|
+
executor.mockResolvedValue({ stdout: 'active\n' });
|
|
107
|
+
await expect((0, cloudflared_1.isServiceRunning)({ platform: 'linux', executor: executor })).resolves.toBe(true);
|
|
108
|
+
executor.mockResolvedValue({ stdout: 'inactive\n' });
|
|
109
|
+
await expect((0, cloudflared_1.isServiceRunning)({ platform: 'linux', executor: executor })).resolves.toBe(false);
|
|
110
|
+
});
|
|
111
|
+
it('removes the systemd unit on linux', async () => {
|
|
112
|
+
await (0, cloudflared_1.installService)('TOKEN', { home: tmpHome, platform: 'linux', executor: executor });
|
|
113
|
+
await (0, cloudflared_1.removeService)({ home: tmpHome, platform: 'linux', executor: executor });
|
|
114
|
+
expect(fs_extra_1.default.existsSync((0, cloudflared_1.systemdUnitPath)(tmpHome))).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getEnableJob = getEnableJob;
|
|
7
|
+
exports.startEnableJob = startEnableJob;
|
|
8
|
+
exports.runEnable = runEnable;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic remote-setup orchestration.
|
|
11
|
+
*
|
|
12
|
+
* The daemon itself runs the enable state machine (detect → provision →
|
|
13
|
+
* install → verify) in the background; the modal only polls GET /remote/enable
|
|
14
|
+
* and renders the progress. Idempotent by design:
|
|
15
|
+
* - tunnel already provisioned → provision is skipped,
|
|
16
|
+
* - cloudflared binary already on disk → download is skipped,
|
|
17
|
+
* - service already running with live edge connections → everything is
|
|
18
|
+
* skipped (the job goes straight to ready).
|
|
19
|
+
*
|
|
20
|
+
* When a deterministic step fails, the orchestrator opens a troubleshooting
|
|
21
|
+
* SESSION on the embedded pane (last used provider/model/cwd combo) with the
|
|
22
|
+
* diagnostics inlined — the agent then works the problem in the background
|
|
23
|
+
* while the modal shows "Troubleshooting". No SKILL.md is involved; the
|
|
24
|
+
* troubleshooting playbook lives in this file.
|
|
25
|
+
*/
|
|
26
|
+
const os_1 = __importDefault(require("os"));
|
|
27
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
28
|
+
const provisioner_1 = require("./provisioner");
|
|
29
|
+
const cloudflared_1 = require("./cloudflared");
|
|
30
|
+
const probes_1 = require("./probes");
|
|
31
|
+
const DEFAULT_PANE_BASE_URL = 'http://127.0.0.1:9998';
|
|
32
|
+
const DEFAULT_VERIFY_TIMEOUT_MS = 90000;
|
|
33
|
+
const DEFAULT_VERIFY_INTERVAL_MS = 5000;
|
|
34
|
+
/** The single active (or last finished) job — one enable at a time. */
|
|
35
|
+
let currentJob = null;
|
|
36
|
+
function getEnableJob() {
|
|
37
|
+
return currentJob;
|
|
38
|
+
}
|
|
39
|
+
function log(job, step, message) {
|
|
40
|
+
job.step = step;
|
|
41
|
+
job.logs.push({ at: new Date().toISOString(), step, message });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Kick off the enable job in the background. Throws when a job is already
|
|
45
|
+
* running. Returns the job snapshot the caller can poll.
|
|
46
|
+
*/
|
|
47
|
+
function startEnableJob(mahlas, deps = {}) {
|
|
48
|
+
if (currentJob?.state === 'running') {
|
|
49
|
+
throw Object.assign(new Error('An enable job is already running.'), { statusCode: 409 });
|
|
50
|
+
}
|
|
51
|
+
const job = {
|
|
52
|
+
id: `enable-${Date.now()}`,
|
|
53
|
+
mahlas,
|
|
54
|
+
hostname: null,
|
|
55
|
+
state: 'running',
|
|
56
|
+
step: 'detect',
|
|
57
|
+
error: null,
|
|
58
|
+
troubleshootingSessionId: null,
|
|
59
|
+
logs: [],
|
|
60
|
+
startedAt: new Date().toISOString(),
|
|
61
|
+
finishedAt: null,
|
|
62
|
+
};
|
|
63
|
+
currentJob = job;
|
|
64
|
+
void runEnable(job, deps).catch((error) => {
|
|
65
|
+
// Catastrophic path — runEnable should never reject, but never leave
|
|
66
|
+
// the job stuck in 'running'.
|
|
67
|
+
finishFailed(job, error?.message ?? 'Unknown error');
|
|
68
|
+
});
|
|
69
|
+
return job;
|
|
70
|
+
}
|
|
71
|
+
function finishFailed(job, error) {
|
|
72
|
+
job.state = 'failed';
|
|
73
|
+
job.step = 'failed';
|
|
74
|
+
job.error = error;
|
|
75
|
+
job.finishedAt = new Date().toISOString();
|
|
76
|
+
}
|
|
77
|
+
/** The deterministic state machine. */
|
|
78
|
+
async function runEnable(job, depOverrides = {}) {
|
|
79
|
+
const d = {
|
|
80
|
+
loadTunnelState: tunnel_state_1.loadTunnelState,
|
|
81
|
+
provisionTunnel: provisioner_1.provisionTunnel,
|
|
82
|
+
isInstalled: cloudflared_1.isInstalled,
|
|
83
|
+
downloadCloudflared: cloudflared_1.downloadCloudflared,
|
|
84
|
+
installService: cloudflared_1.installService,
|
|
85
|
+
isServiceRunning: cloudflared_1.isServiceRunning,
|
|
86
|
+
isConnected: probes_1.probeTunnelConnected,
|
|
87
|
+
paneBaseUrl: DEFAULT_PANE_BASE_URL,
|
|
88
|
+
fetchImpl: fetch,
|
|
89
|
+
verifyTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
|
|
90
|
+
verifyIntervalMs: DEFAULT_VERIFY_INTERVAL_MS,
|
|
91
|
+
waitImpl: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
92
|
+
...depOverrides,
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
// ---- detect: idempotent no-op check ----
|
|
96
|
+
log(job, 'detect', 'Checking the current machine state.');
|
|
97
|
+
const existing = d.loadTunnelState();
|
|
98
|
+
const running = await d.isServiceRunning().catch(() => false);
|
|
99
|
+
const connected = await d.isConnected();
|
|
100
|
+
if (existing && running && connected) {
|
|
101
|
+
job.hostname = existing.hostname;
|
|
102
|
+
log(job, 'ready', `Already remote and connected as ${existing.hostname}. Nothing to do.`);
|
|
103
|
+
job.state = 'ready';
|
|
104
|
+
job.finishedAt = new Date().toISOString();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// ---- provision (only when no local state exists) ----
|
|
108
|
+
let state = existing;
|
|
109
|
+
if (!state) {
|
|
110
|
+
log(job, 'provision', `Provisioning the tunnel as "${job.mahlas ?? ''}".`);
|
|
111
|
+
state = await d.provisionTunnel(job.mahlas ?? '');
|
|
112
|
+
job.hostname = state.hostname;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
job.hostname = state.hostname;
|
|
116
|
+
log(job, 'provision', `Tunnel already provisioned (${state.hostname}) — skipping provisioning.`);
|
|
117
|
+
}
|
|
118
|
+
// ---- install (only when the service is not running) ----
|
|
119
|
+
if (!running) {
|
|
120
|
+
log(job, 'install', 'Installing the cloudflared service.');
|
|
121
|
+
if (d.isInstalled()) {
|
|
122
|
+
log(job, 'install', 'cloudflared binary already present — skipping the download.');
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
log(job, 'install', 'Downloading cloudflared (sha256-verified).');
|
|
126
|
+
await d.downloadCloudflared();
|
|
127
|
+
}
|
|
128
|
+
await d.installService(state.tunnelToken);
|
|
129
|
+
log(job, 'install', 'Service installed and started.');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
log(job, 'install', 'Service already running — skipping install.');
|
|
133
|
+
}
|
|
134
|
+
// ---- verify: live edge connections with a bounded wait ----
|
|
135
|
+
log(job, 'verify', `Waiting for live edge connections (up to ${Math.round(d.verifyTimeoutMs / 1000)}s).`);
|
|
136
|
+
const deadline = Date.now() + d.verifyTimeoutMs;
|
|
137
|
+
for (;;) {
|
|
138
|
+
const ok = (await d.isConnected()) && (await d.isServiceRunning().catch(() => false));
|
|
139
|
+
if (ok) {
|
|
140
|
+
log(job, 'ready', `Machine is reachable at https://${state.hostname}`);
|
|
141
|
+
job.state = 'ready';
|
|
142
|
+
job.finishedAt = new Date().toISOString();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (Date.now() >= deadline) {
|
|
146
|
+
throw new Error('Tunnel did not connect in time — see the cloudflared service logs.');
|
|
147
|
+
}
|
|
148
|
+
await d.waitImpl(d.verifyIntervalMs);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// Deterministic path failed → background troubleshooting via the pane.
|
|
153
|
+
const message = error?.message ?? 'Unknown error';
|
|
154
|
+
job.error = message;
|
|
155
|
+
log(job, 'troubleshooting', `Deterministic setup failed: ${message}`);
|
|
156
|
+
try {
|
|
157
|
+
await startTroubleshootingSession(job, d);
|
|
158
|
+
}
|
|
159
|
+
catch (troubleshootError) {
|
|
160
|
+
finishFailed(job, `${message} | troubleshooting failed: ${troubleshootError.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Open a troubleshooting session with the LAST USED provider/model/cwd combo
|
|
166
|
+
* and submit the diagnostics prompt. The agent runs autonomously in the
|
|
167
|
+
* background; the modal surfaces the session.
|
|
168
|
+
*/
|
|
169
|
+
async function startTroubleshootingSession(job, d) {
|
|
170
|
+
const base = d.paneBaseUrl;
|
|
171
|
+
const http = d.fetchImpl;
|
|
172
|
+
// The last used agent: newest session wins (GET /api/sessions is newest-first).
|
|
173
|
+
const listRes = await http(`${base}/api/sessions`);
|
|
174
|
+
if (!listRes.ok)
|
|
175
|
+
throw new Error(`pane sessions list failed: ${listRes.status}`);
|
|
176
|
+
const sessions = (await listRes.json());
|
|
177
|
+
const last = sessions[0];
|
|
178
|
+
if (!last) {
|
|
179
|
+
throw new Error('no agent sessions on this machine — nothing to troubleshoot with');
|
|
180
|
+
}
|
|
181
|
+
const createRes = await http(`${base}/api/sessions`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
title: 'Remote Setup Troubleshooting',
|
|
186
|
+
runtime_type: last.runtime_type ?? 'codex',
|
|
187
|
+
model: last.model ?? null,
|
|
188
|
+
model_reasoning_effort: last.model_reasoning_effort ?? null,
|
|
189
|
+
cwd: last.cwd ?? null,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
if (!createRes.ok)
|
|
193
|
+
throw new Error(`pane session create failed: ${createRes.status}`);
|
|
194
|
+
const session = (await createRes.json());
|
|
195
|
+
const turnRes = await http(`${base}/api/sessions/${session.id}/turns`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({ user_prompt: buildTroubleshootingPrompt(job) }),
|
|
199
|
+
});
|
|
200
|
+
if (!turnRes.ok)
|
|
201
|
+
throw new Error(`pane turn submit failed: ${turnRes.status}`);
|
|
202
|
+
job.troubleshootingSessionId = session.id;
|
|
203
|
+
job.state = 'troubleshooting';
|
|
204
|
+
job.finishedAt = new Date().toISOString();
|
|
205
|
+
log(job, 'troubleshooting', `Troubleshooting session opened (${session.id}) — the agent is working.`);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Inline diagnostics + playbook for the troubleshooting agent.
|
|
209
|
+
* SECURITY: never include the tunnel token or any other credential here.
|
|
210
|
+
*/
|
|
211
|
+
function buildTroubleshootingPrompt(job) {
|
|
212
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
213
|
+
const tail = job.logs
|
|
214
|
+
.slice(-12)
|
|
215
|
+
.map((entry) => `[${entry.step}] ${entry.message}`)
|
|
216
|
+
.join('\n');
|
|
217
|
+
const platform = os_1.default.platform();
|
|
218
|
+
const isMac = platform === 'darwin';
|
|
219
|
+
return [
|
|
220
|
+
'The deterministic remote-setup orchestration on this machine FAILED. Fix it autonomously.',
|
|
221
|
+
'',
|
|
222
|
+
`Error: ${job.error ?? 'unknown'}`,
|
|
223
|
+
state
|
|
224
|
+
? `Machine: mahlas=${state.mahlas}, hostname=${state.hostname}`
|
|
225
|
+
: 'Machine: no tunnel state on disk (provisioning may have failed).',
|
|
226
|
+
`Platform: ${platform}`,
|
|
227
|
+
'',
|
|
228
|
+
'Setup log:',
|
|
229
|
+
tail,
|
|
230
|
+
'',
|
|
231
|
+
'Playbook:',
|
|
232
|
+
'1. Snapshot: curl http://localhost:9998/remote/status',
|
|
233
|
+
isMac
|
|
234
|
+
? '2. Service: launchctl list | grep repoburg ; log: ~/.repoburg/bin/cloudflared.log ; plist: ~/Library/LaunchAgents/com.repoburg.cloudflared.plist'
|
|
235
|
+
: '2. Service: systemctl --user status repoburg-cloudflared ; journalctl --user -u repoburg-cloudflared',
|
|
236
|
+
'3. Fix the root cause yourself:',
|
|
237
|
+
' - reinstall the service: curl -X POST http://localhost:9998/remote/install',
|
|
238
|
+
' - broken tunnel: curl -X DELETE http://localhost:9998/remote/uninstall then curl -X POST -H "Content-Type: application/json" -d \'{"mahlas":"<mahlas>"}\' http://localhost:9998/remote/provision',
|
|
239
|
+
' - the mahlas may be taken (409) — pick a close variant and tell the user.',
|
|
240
|
+
'4. Done when /remote/status reports connection.ok=true and https://<hostname>/health answers.',
|
|
241
|
+
'5. Stop after 3 failed attempts (~2 minutes) and report findings. Do not loop forever.',
|
|
242
|
+
'',
|
|
243
|
+
'Safety: /remote/* works ONLY from localhost curl. Never edit ~/.repoburg/remote.json by hand and never run cloudflared manually. Never print the tunnel token.',
|
|
244
|
+
].join('\n');
|
|
245
|
+
}
|