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,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrator spec — drives runEnable with mock deps; startEnableJob is
|
|
5
|
+
* covered by the routes spec (mahlas validation + busy 409).
|
|
6
|
+
*/
|
|
7
|
+
const orchestrator_1 = require("./orchestrator");
|
|
8
|
+
jest.mock('./tunnel-state');
|
|
9
|
+
jest.mock('./provisioner');
|
|
10
|
+
jest.mock('./cloudflared');
|
|
11
|
+
jest.mock('./probes');
|
|
12
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
13
|
+
const cloudflared_1 = require("./cloudflared");
|
|
14
|
+
const probes_1 = require("./probes");
|
|
15
|
+
const mockedLoad = tunnel_state_1.loadTunnelState;
|
|
16
|
+
const mockedIsInstalled = cloudflared_1.isInstalled;
|
|
17
|
+
const mockedConnected = probes_1.probeTunnelConnected;
|
|
18
|
+
const STATE = {
|
|
19
|
+
mahlas: 'zz',
|
|
20
|
+
hostname: 'zz.repoburg.com',
|
|
21
|
+
accountTunnelId: 'acc-1',
|
|
22
|
+
tunnelToken: 'TUNNELSECRET',
|
|
23
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
24
|
+
accessAud: 'aud',
|
|
25
|
+
ownerEmail: 'o@x.com',
|
|
26
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
27
|
+
};
|
|
28
|
+
/** Wait for a settled microtask chain (the job loop is async). */
|
|
29
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
30
|
+
function makeJob(mahlas = 'newmach') {
|
|
31
|
+
return {
|
|
32
|
+
id: 'job-1',
|
|
33
|
+
mahlas,
|
|
34
|
+
hostname: null,
|
|
35
|
+
state: 'running',
|
|
36
|
+
step: 'detect',
|
|
37
|
+
error: null,
|
|
38
|
+
troubleshootingSessionId: null,
|
|
39
|
+
logs: [],
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
finishedAt: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function makeDeps(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
loadTunnelState: mockedLoad,
|
|
47
|
+
provisionTunnel: jest.fn().mockResolvedValue(STATE),
|
|
48
|
+
isInstalled: mockedIsInstalled,
|
|
49
|
+
downloadCloudflared: jest.fn().mockResolvedValue('/bin/cloudflared'),
|
|
50
|
+
installService: jest.fn().mockResolvedValue('/service'),
|
|
51
|
+
isServiceRunning: jest.fn().mockResolvedValue(true),
|
|
52
|
+
isConnected: mockedConnected,
|
|
53
|
+
paneBaseUrl: 'http://127.0.0.1:9998',
|
|
54
|
+
fetchImpl: jest.fn(),
|
|
55
|
+
verifyTimeoutMs: 60,
|
|
56
|
+
verifyIntervalMs: 10,
|
|
57
|
+
waitImpl: jest.fn(async () => undefined),
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
describe('orchestrator: deterministic happy path', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
});
|
|
65
|
+
it('no-ops when already provisioned, running, and connected', async () => {
|
|
66
|
+
mockedLoad.mockReturnValue(STATE);
|
|
67
|
+
const deps = makeDeps();
|
|
68
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
69
|
+
mockedConnected.mockResolvedValue(true);
|
|
70
|
+
const job = makeJob();
|
|
71
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
72
|
+
expect(job.state).toBe('ready');
|
|
73
|
+
expect(deps.provisionTunnel).not.toHaveBeenCalled();
|
|
74
|
+
expect(deps.installService).not.toHaveBeenCalled();
|
|
75
|
+
expect(job.logs.some((l) => l.message.includes('Nothing to do'))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('provisions, installs, and verifies a fresh machine', async () => {
|
|
78
|
+
mockedLoad.mockReturnValue(null);
|
|
79
|
+
mockedIsInstalled.mockReturnValue(false);
|
|
80
|
+
mockedConnected.mockResolvedValue(true);
|
|
81
|
+
// Detect: service down (fresh machine). Verify: service up after install.
|
|
82
|
+
const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
83
|
+
const job = makeJob('fresh');
|
|
84
|
+
const deps = makeDeps({ isServiceRunning: serviceProbe });
|
|
85
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
86
|
+
expect(deps.provisionTunnel).toHaveBeenCalledWith('fresh');
|
|
87
|
+
expect(deps.downloadCloudflared).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
|
|
89
|
+
expect(job.state).toBe('ready');
|
|
90
|
+
expect(job.logs.some((l) => l.message.includes('reachable at https://zz.repoburg.com'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('skips provision when the tunnel state exists and skips download when the binary is present', async () => {
|
|
93
|
+
mockedLoad.mockReturnValue(STATE);
|
|
94
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
95
|
+
mockedConnected.mockResolvedValue(true);
|
|
96
|
+
// Detect: service down → install runs. Verify: service up.
|
|
97
|
+
const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
98
|
+
const job = makeJob('ignored');
|
|
99
|
+
const deps = makeDeps({ isServiceRunning: serviceProbe });
|
|
100
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
101
|
+
expect(deps.provisionTunnel).not.toHaveBeenCalled();
|
|
102
|
+
expect(deps.downloadCloudflared).not.toHaveBeenCalled();
|
|
103
|
+
expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
|
|
104
|
+
expect(job.state).toBe('ready');
|
|
105
|
+
});
|
|
106
|
+
it('skips install when the service is already running and waits for connections', async () => {
|
|
107
|
+
mockedLoad.mockReturnValue(STATE);
|
|
108
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
109
|
+
// First probe: not connected (service warming up); second: connected.
|
|
110
|
+
mockedConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
111
|
+
const job = makeJob();
|
|
112
|
+
const deps = makeDeps();
|
|
113
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
114
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
115
|
+
expect(deps.installService).not.toHaveBeenCalled();
|
|
116
|
+
expect(job.state).toBe('ready');
|
|
117
|
+
});
|
|
118
|
+
it('fails after the verify deadline', async () => {
|
|
119
|
+
mockedLoad.mockReturnValue(STATE);
|
|
120
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
121
|
+
mockedConnected.mockResolvedValue(false);
|
|
122
|
+
const job = makeJob();
|
|
123
|
+
const deps = makeDeps();
|
|
124
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
125
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
126
|
+
expect(job.state).toBe('failed'); // no pane sessions available in the mock
|
|
127
|
+
expect(job.error).toContain('did not connect');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('orchestrator: troubleshooting fallback', () => {
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
jest.clearAllMocks();
|
|
133
|
+
mockedLoad.mockReturnValue(null);
|
|
134
|
+
mockedIsInstalled.mockReturnValue(false);
|
|
135
|
+
mockedConnected.mockResolvedValue(false);
|
|
136
|
+
});
|
|
137
|
+
function paneFetch(jestFn) {
|
|
138
|
+
// /api/sessions list (newest first), session create, turn submit.
|
|
139
|
+
jestFn.mockImplementation(async (input, init) => {
|
|
140
|
+
const url = String(input);
|
|
141
|
+
if (url.endsWith('/api/sessions') && (!init || init.method === 'GET')) {
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
json: async () => [
|
|
145
|
+
{ id: 's1', runtime_type: 'repoburg', model: 'glm', model_reasoning_effort: null, cwd: '/repo' },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
|
|
150
|
+
return { ok: true, json: async () => ({ id: 'ts-1' }) };
|
|
151
|
+
}
|
|
152
|
+
if (url.includes('/api/sessions/ts-1/turns')) {
|
|
153
|
+
return { ok: true, json: async () => ({ id: 'turn-1' }) };
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`unexpected ${url}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
it('opens a troubleshooting session with the last used agent on failure', async () => {
|
|
159
|
+
const fetchImpl = jest.fn();
|
|
160
|
+
paneFetch(fetchImpl);
|
|
161
|
+
const deps = makeDeps({ fetchImpl: fetchImpl });
|
|
162
|
+
const job = makeJob();
|
|
163
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
164
|
+
expect(job.state).toBe('troubleshooting');
|
|
165
|
+
expect(job.troubleshootingSessionId).toBe('ts-1');
|
|
166
|
+
// Session created with the LAST USED combo.
|
|
167
|
+
const createCall = fetchImpl.mock.calls.find((c) => c[1]?.method === 'POST' && String(c[0]).endsWith('/api/sessions'));
|
|
168
|
+
expect(JSON.parse(createCall[1].body)).toMatchObject({
|
|
169
|
+
title: 'Remote Setup Troubleshooting',
|
|
170
|
+
runtime_type: 'repoburg',
|
|
171
|
+
model: 'glm',
|
|
172
|
+
cwd: '/repo',
|
|
173
|
+
});
|
|
174
|
+
// The prompt carries the playbook but never the tunnel token.
|
|
175
|
+
const turnCall = fetchImpl.mock.calls.find((c) => String(c[0]).includes('/api/sessions/ts-1/turns'));
|
|
176
|
+
const prompt = JSON.parse(turnCall[1].body).user_prompt;
|
|
177
|
+
expect(prompt).toContain('localhost:9998/remote/status');
|
|
178
|
+
expect(prompt).not.toContain('TUNNELSECRET');
|
|
179
|
+
});
|
|
180
|
+
it('fails (no agent) when the pane has no sessions', async () => {
|
|
181
|
+
const fetchImpl = jest.fn(async () => ({ ok: true, json: async () => [] }));
|
|
182
|
+
const deps = makeDeps({ fetchImpl: fetchImpl });
|
|
183
|
+
const job = makeJob();
|
|
184
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
185
|
+
expect(job.state).toBe('failed');
|
|
186
|
+
expect(job.troubleshootingSessionId).toBeNull();
|
|
187
|
+
expect(job.error).toContain('no agent sessions');
|
|
188
|
+
});
|
|
189
|
+
it('getEnableJob returns null before any job', () => {
|
|
190
|
+
expect((0, orchestrator_1.getEnableJob)()).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.probePublicHealth = probePublicHealth;
|
|
4
|
+
exports.probeTunnelConnected = probeTunnelConnected;
|
|
5
|
+
/**
|
|
6
|
+
* Connectivity probes shared by the /remote routes and the enable
|
|
7
|
+
* orchestrator. Both signals are needed: the public health probe only proves
|
|
8
|
+
* the edge knows the hostname (CF Access answers 302 before the tunnel is
|
|
9
|
+
* consulted), while the cloudflared metrics gauge proves live edge
|
|
10
|
+
* connections through the tunnel.
|
|
11
|
+
*/
|
|
12
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
13
|
+
const cloudflared_1 = require("./cloudflared");
|
|
14
|
+
const HEALTH_CHECK_TIMEOUT_MS = 10000;
|
|
15
|
+
function publicHealthUrl() {
|
|
16
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
17
|
+
return state ? `https://${state.hostname}/health` : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Edge reachability probe. Behind CF Access an anonymous request gets a 302
|
|
21
|
+
* to the login page BEFORE the tunnel is ever consulted — so this can only
|
|
22
|
+
* prove the edge knows the hostname, never that the tunnel is connected.
|
|
23
|
+
* `redirect: 'manual'` prevents the old false positive where the login
|
|
24
|
+
* page's 200 was counted as a healthy daemon.
|
|
25
|
+
*/
|
|
26
|
+
async function probePublicHealth(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
27
|
+
const url = publicHealthUrl();
|
|
28
|
+
if (!url)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
32
|
+
return response.status >= 200 && response.status < 400;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Tunnel connectivity from cloudflared's LOCAL metrics endpoint. This is the
|
|
40
|
+
* primary "is the tunnel actually serving" signal: `ha_connections` counts
|
|
41
|
+
* live edge connections (0 or unreachable metrics = not connected).
|
|
42
|
+
*/
|
|
43
|
+
async function probeTunnelConnected(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch((0, cloudflared_1.cloudflaredMetricsUrl)(), {
|
|
46
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
return false;
|
|
50
|
+
const body = await response.text();
|
|
51
|
+
// The gauge may appear label-less (`… 4`) or labeled (`…{region="us"} 4`);
|
|
52
|
+
// ^… anchors past the HELP/TYPE lines.
|
|
53
|
+
const match = body.match(/^cloudflared_tunnel_ha_connections(?:\{[^}]*\})?\s+([0-9.]+)/m);
|
|
54
|
+
return Boolean(match && Number(match[1]) > 0);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProvisioningError = void 0;
|
|
4
|
+
exports.accountServerUrl = accountServerUrl;
|
|
5
|
+
exports.provisionTunnel = provisionTunnel;
|
|
6
|
+
exports.listTunnels = listTunnels;
|
|
7
|
+
exports.uninstallTunnel = uninstallTunnel;
|
|
8
|
+
/**
|
|
9
|
+
* Account-server client for tunnel lifecycle. The daemon authenticates with
|
|
10
|
+
* the device JWT from `repoburg login` (~/.repoburg/token.jwt).
|
|
11
|
+
*/
|
|
12
|
+
const authManager_1 = require("../auth/authManager");
|
|
13
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
14
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
15
|
+
const DEFAULT_ACCOUNT_SERVER_URL = isDevelopment
|
|
16
|
+
? 'http://localhost:2999'
|
|
17
|
+
: 'https://repoburg.com/api/account';
|
|
18
|
+
function accountServerUrl() {
|
|
19
|
+
return process.env.ACCOUNT_SERVER_URL ?? DEFAULT_ACCOUNT_SERVER_URL;
|
|
20
|
+
}
|
|
21
|
+
class ProvisioningError extends Error {
|
|
22
|
+
constructor(message, statusCode) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.statusCode = statusCode;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.ProvisioningError = ProvisioningError;
|
|
28
|
+
async function callAccountServer(method, path, token, body) {
|
|
29
|
+
const response = await fetch(`${accountServerUrl()}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${token}`,
|
|
33
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
34
|
+
},
|
|
35
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
const data = (await response.json().catch(() => ({})));
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const message = data.message ?? data.error ?? response.statusText;
|
|
40
|
+
throw new ProvisioningError(`Account server: ${message}`, response.status);
|
|
41
|
+
}
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Provision a new tunnel for this machine and persist the state locally.
|
|
46
|
+
* The response's tunnel_token is the only credential written to disk.
|
|
47
|
+
*/
|
|
48
|
+
async function provisionTunnel(mahlas) {
|
|
49
|
+
const token = await authManager_1.authManager.readToken();
|
|
50
|
+
if (!token) {
|
|
51
|
+
throw new ProvisioningError('Not logged in. Run `repoburg login` first.', 401);
|
|
52
|
+
}
|
|
53
|
+
const result = await callAccountServer('POST', '/device/tunnels', token, { mahlas });
|
|
54
|
+
// Access config + owner email arrive in the same response: the tunnel is
|
|
55
|
+
// email-gated from birth, and checkRemoteAuth can pin the owner's email.
|
|
56
|
+
const state = {
|
|
57
|
+
mahlas: result.mahlas,
|
|
58
|
+
hostname: result.hostname,
|
|
59
|
+
accountTunnelId: result.id,
|
|
60
|
+
tunnelToken: result.tunnel_token,
|
|
61
|
+
accessTeamDomain: result.accessTeamDomain,
|
|
62
|
+
accessAud: result.accessAud,
|
|
63
|
+
ownerEmail: result.ownerEmail,
|
|
64
|
+
installedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
(0, tunnel_state_1.saveTunnelState)(state);
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
/** List this user's machines (used by the pane machine list when local). */
|
|
70
|
+
async function listTunnels() {
|
|
71
|
+
const token = await authManager_1.authManager.readToken();
|
|
72
|
+
if (!token)
|
|
73
|
+
return [];
|
|
74
|
+
return callAccountServer('GET', '/device/tunnels', token);
|
|
75
|
+
}
|
|
76
|
+
/** Remove the tunnel on the account server and wipe the local state. */
|
|
77
|
+
async function uninstallTunnel() {
|
|
78
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
79
|
+
if (!state)
|
|
80
|
+
return;
|
|
81
|
+
const token = await authManager_1.authManager.readToken();
|
|
82
|
+
if (token) {
|
|
83
|
+
await callAccountServer('DELETE', `/device/tunnels/${state.accountTunnelId}`, token);
|
|
84
|
+
}
|
|
85
|
+
(0, tunnel_state_1.clearTunnelState)();
|
|
86
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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 provisioner_1 = require("./provisioner");
|
|
7
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
8
|
+
const authManager_1 = require("../auth/authManager");
|
|
9
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const tmpHome = fs_extra_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'provisioner-'));
|
|
13
|
+
process.env.ACCOUNT_SERVER_URL = 'http://account-server.test';
|
|
14
|
+
jest.mock('../auth/authManager', () => ({
|
|
15
|
+
authManager: { readToken: jest.fn() },
|
|
16
|
+
}));
|
|
17
|
+
const mockedReadToken = authManager_1.authManager.readToken;
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
(0, tunnel_state_1.clearTunnelState)(tmpHome);
|
|
20
|
+
mockedReadToken.mockReset();
|
|
21
|
+
});
|
|
22
|
+
describe('provisioner', () => {
|
|
23
|
+
it('provisions a tunnel and persists only the machine-local state', async () => {
|
|
24
|
+
mockedReadToken.mockResolvedValue('device-jwt');
|
|
25
|
+
const fetchMock = jest.fn(async () => ({
|
|
26
|
+
ok: true,
|
|
27
|
+
status: 200,
|
|
28
|
+
json: async () => ({
|
|
29
|
+
id: 'tunnel-uuid',
|
|
30
|
+
mahlas: 'workstation',
|
|
31
|
+
hostname: 'workstation.repoburg.com',
|
|
32
|
+
tunnel_token: 'connector-token',
|
|
33
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
34
|
+
accessAud: 'aud-tag',
|
|
35
|
+
ownerEmail: 'owner@repoburg.com',
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
global.fetch = fetchMock;
|
|
39
|
+
const origHomedir = os_1.default.homedir;
|
|
40
|
+
os_1.default.homedir = () => tmpHome;
|
|
41
|
+
try {
|
|
42
|
+
const state = await (0, provisioner_1.provisionTunnel)('workstation');
|
|
43
|
+
expect(state).toMatchObject({
|
|
44
|
+
mahlas: 'workstation',
|
|
45
|
+
hostname: 'workstation.repoburg.com',
|
|
46
|
+
accountTunnelId: 'tunnel-uuid',
|
|
47
|
+
tunnelToken: 'connector-token',
|
|
48
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
49
|
+
accessAud: 'aud-tag',
|
|
50
|
+
ownerEmail: 'owner@repoburg.com',
|
|
51
|
+
});
|
|
52
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toMatchObject({
|
|
53
|
+
accountTunnelId: 'tunnel-uuid',
|
|
54
|
+
// The Access config arrives with the tunnel — email-gated from birth.
|
|
55
|
+
accessAud: 'aud-tag',
|
|
56
|
+
ownerEmail: 'owner@repoburg.com',
|
|
57
|
+
});
|
|
58
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
59
|
+
expect(url).toBe('http://account-server.test/device/tunnels');
|
|
60
|
+
expect(init.headers.Authorization).toBe('Bearer device-jwt');
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
os_1.default.homedir = origHomedir;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it('refuses to provision when not logged in', async () => {
|
|
67
|
+
mockedReadToken.mockResolvedValue(null);
|
|
68
|
+
await expect((0, provisioner_1.provisionTunnel)('workstation')).rejects.toBeInstanceOf(provisioner_1.ProvisioningError);
|
|
69
|
+
});
|
|
70
|
+
it('surfaces account-server errors', async () => {
|
|
71
|
+
mockedReadToken.mockResolvedValue('device-jwt');
|
|
72
|
+
global.fetch = jest.fn(async () => ({
|
|
73
|
+
ok: false,
|
|
74
|
+
status: 409,
|
|
75
|
+
json: async () => ({ message: 'Mahlas already taken' }),
|
|
76
|
+
}));
|
|
77
|
+
await expect((0, provisioner_1.provisionTunnel)('workstation')).rejects.toThrow(/already taken/);
|
|
78
|
+
});
|
|
79
|
+
it('uninstalls the tunnel on the server and clears local state', async () => {
|
|
80
|
+
(0, tunnel_state_1.saveTunnelState)({
|
|
81
|
+
mahlas: 'workstation',
|
|
82
|
+
hostname: 'workstation.repoburg.com',
|
|
83
|
+
accountTunnelId: 'tunnel-uuid',
|
|
84
|
+
tunnelToken: 'connector-token',
|
|
85
|
+
}, tmpHome);
|
|
86
|
+
mockedReadToken.mockResolvedValue('device-jwt');
|
|
87
|
+
const fetchMock = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
|
|
88
|
+
global.fetch = fetchMock;
|
|
89
|
+
const origHomedir = os_1.default.homedir;
|
|
90
|
+
os_1.default.homedir = () => tmpHome;
|
|
91
|
+
try {
|
|
92
|
+
await (0, provisioner_1.uninstallTunnel)();
|
|
93
|
+
expect(fetchMock.mock.calls[0][0]).toBe('http://account-server.test/device/tunnels/tunnel-uuid');
|
|
94
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toBeNull();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
os_1.default.homedir = origHomedir;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('lists tunnels only when logged in', async () => {
|
|
101
|
+
mockedReadToken.mockResolvedValue(null);
|
|
102
|
+
await expect((0, provisioner_1.listTunnels)()).resolves.toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
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.createRemoteRouter = createRemoteRouter;
|
|
7
|
+
const express_1 = require("express");
|
|
8
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
9
|
+
const provisioner_1 = require("./provisioner");
|
|
10
|
+
const probes_1 = require("./probes");
|
|
11
|
+
const orchestrator_1 = require("./orchestrator");
|
|
12
|
+
const cloudflared_1 = require("./cloudflared");
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
/**
|
|
15
|
+
* Local-only tunnel lifecycle API.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: this router must NEVER be reachable through the tunnel. The
|
|
18
|
+
* account-server blocks `^/remote(/.*)?$` at the CF ingress edge (404), and
|
|
19
|
+
* these endpoints manage the machine itself.
|
|
20
|
+
*/
|
|
21
|
+
function errorResponse(res, error) {
|
|
22
|
+
// Account-server errors carry a meaningful HTTP status (ProvisioningError).
|
|
23
|
+
const statusCode = error.statusCode;
|
|
24
|
+
const message = error.message ?? 'Unknown error';
|
|
25
|
+
if (typeof statusCode === 'number' && statusCode >= 400 && statusCode < 600) {
|
|
26
|
+
res.status(statusCode).json({ error: message });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
res.status(500).json({ error: message });
|
|
30
|
+
}
|
|
31
|
+
function createRemoteRouter() {
|
|
32
|
+
const router = (0, express_1.Router)();
|
|
33
|
+
// Full status snapshot for the skill's first diagnostic step.
|
|
34
|
+
router.get('/status', async (_req, res) => {
|
|
35
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
36
|
+
const platformSupported = ['linux', 'darwin'].includes(os_1.default.platform());
|
|
37
|
+
res.json({
|
|
38
|
+
daemonRunning: true,
|
|
39
|
+
platform: os_1.default.platform(),
|
|
40
|
+
platformSupported,
|
|
41
|
+
tunnel: state
|
|
42
|
+
? { mahlas: state.mahlas, hostname: state.hostname, provisioned: true }
|
|
43
|
+
: { provisioned: false },
|
|
44
|
+
cloudflared: { installed: (0, cloudflared_1.isInstalled)() },
|
|
45
|
+
service: { running: await (0, cloudflared_1.isServiceRunning)().catch(() => false) },
|
|
46
|
+
connection: { ok: await (0, probes_1.probeTunnelConnected)() },
|
|
47
|
+
access: {
|
|
48
|
+
configured: Boolean(state?.accessTeamDomain && state?.accessAud),
|
|
49
|
+
ownerEmail: state?.ownerEmail ?? null,
|
|
50
|
+
teamDomain: state?.accessTeamDomain ?? null,
|
|
51
|
+
},
|
|
52
|
+
publicHealth: { ok: await (0, probes_1.probePublicHealth)() },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
// Create the tunnel on the account server; state saved, nothing installed yet.
|
|
56
|
+
router.post('/provision', async (req, res) => {
|
|
57
|
+
const mahlas = req.body?.mahlas;
|
|
58
|
+
if (typeof mahlas !== 'string' || mahlas.length === 0) {
|
|
59
|
+
res.status(400).json({ error: 'Body must include { "mahlas": "..." }' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const state = await (0, provisioner_1.provisionTunnel)(mahlas);
|
|
64
|
+
res.json({
|
|
65
|
+
mahlas: state.mahlas,
|
|
66
|
+
hostname: state.hostname,
|
|
67
|
+
message: 'Tunnel provisioned. Next: POST /remote/install.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
errorResponse(res, error);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// Download cloudflared + install + start the system service.
|
|
75
|
+
router.post('/install', async (_req, res) => {
|
|
76
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
77
|
+
if (!state) {
|
|
78
|
+
res.status(409).json({ error: 'No tunnel provisioned. Call POST /remote/provision first.' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (!(0, cloudflared_1.isInstalled)()) {
|
|
83
|
+
await (0, cloudflared_1.downloadCloudflared)();
|
|
84
|
+
}
|
|
85
|
+
const servicePath = await (0, cloudflared_1.installService)(state.tunnelToken);
|
|
86
|
+
const running = await (0, cloudflared_1.isServiceRunning)();
|
|
87
|
+
// Primary verification: live edge connections from cloudflared metrics.
|
|
88
|
+
// The edge probe alone is meaningless behind CF Access (always 302).
|
|
89
|
+
const connected = await (0, probes_1.probeTunnelConnected)();
|
|
90
|
+
res.json({
|
|
91
|
+
installed: true,
|
|
92
|
+
servicePath,
|
|
93
|
+
running,
|
|
94
|
+
connection: { ok: connected },
|
|
95
|
+
message: connected
|
|
96
|
+
? `Machine is reachable at https://${state.hostname}`
|
|
97
|
+
: 'Service started but the tunnel has no live connections — check service logs.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
errorResponse(res, error);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Stop the tunnel service but keep the provisioned tunnel.
|
|
105
|
+
router.post('/disable', async (_req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
await (0, cloudflared_1.removeService)();
|
|
108
|
+
res.json({ disabled: true });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
errorResponse(res, error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Remove service + tunnel (account server) + local state.
|
|
115
|
+
router.delete('/uninstall', async (_req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
await (0, cloudflared_1.removeService)();
|
|
118
|
+
await (0, provisioner_1.uninstallTunnel)();
|
|
119
|
+
(0, tunnel_state_1.clearTunnelState)();
|
|
120
|
+
res.json({ uninstalled: true });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
errorResponse(res, error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// Deterministic enable orchestration — the daemon runs provision/install/
|
|
127
|
+
// verify itself; the UI only polls GET /remote/enable.
|
|
128
|
+
const MAHLAS_RE = /^[a-z][a-z0-9-]{1,30}$/;
|
|
129
|
+
router.post('/enable', (req, res) => {
|
|
130
|
+
const mahlas = req.body?.mahlas;
|
|
131
|
+
if (typeof mahlas !== 'string' || !MAHLAS_RE.test(mahlas)) {
|
|
132
|
+
res.status(400).json({ error: 'Body must include { "mahlas": "..." } (a-z, 0-9, "-").' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
res.json((0, orchestrator_1.startEnableJob)(mahlas));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
errorResponse(res, error);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
router.get('/enable', (_req, res) => {
|
|
143
|
+
res.json((0, orchestrator_1.getEnableJob)());
|
|
144
|
+
});
|
|
145
|
+
// Machine list from the account server (for the pane machine switcher).
|
|
146
|
+
router.get('/machines', async (_req, res) => {
|
|
147
|
+
try {
|
|
148
|
+
res.json(await (0, provisioner_1.listTunnels)());
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
errorResponse(res, error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return router;
|
|
155
|
+
}
|