repoburg 1.3.217 → 1.3.219
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/agent-pane/frontend/dist/assets/index-BUkOj2Cb.js +392 -0
- package/agent-pane/frontend/dist/assets/index-gAFaOmjN.css +1 -0
- package/agent-pane/frontend/dist/index.html +19 -0
- 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 +2 -2
|
@@ -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
|
+
}
|
|
@@ -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
|
+
const http_1 = __importDefault(require("http"));
|
|
7
|
+
const express_1 = __importDefault(require("express"));
|
|
8
|
+
const routes_1 = require("./routes");
|
|
9
|
+
jest.mock('./tunnel-state');
|
|
10
|
+
jest.mock('./provisioner');
|
|
11
|
+
jest.mock('./cloudflared');
|
|
12
|
+
jest.mock('./orchestrator');
|
|
13
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
14
|
+
const provisioner_1 = require("./provisioner");
|
|
15
|
+
const orchestrator_1 = require("./orchestrator");
|
|
16
|
+
const cloudflared_1 = require("./cloudflared");
|
|
17
|
+
const mocked = {
|
|
18
|
+
loadTunnelState: tunnel_state_1.loadTunnelState,
|
|
19
|
+
updateTunnelState: tunnel_state_1.updateTunnelState,
|
|
20
|
+
clearTunnelState: tunnel_state_1.clearTunnelState,
|
|
21
|
+
provisionTunnel: provisioner_1.provisionTunnel,
|
|
22
|
+
uninstallTunnel: provisioner_1.uninstallTunnel,
|
|
23
|
+
listTunnels: provisioner_1.listTunnels,
|
|
24
|
+
isInstalled: cloudflared_1.isInstalled,
|
|
25
|
+
downloadCloudflared: cloudflared_1.downloadCloudflared,
|
|
26
|
+
installService: cloudflared_1.installService,
|
|
27
|
+
removeService: cloudflared_1.removeService,
|
|
28
|
+
isServiceRunning: cloudflared_1.isServiceRunning,
|
|
29
|
+
cloudflaredMetricsUrl: cloudflared_1.cloudflaredMetricsUrl,
|
|
30
|
+
};
|
|
31
|
+
const METRICS_URL = 'http://127.0.0.1:20241/metrics';
|
|
32
|
+
/** Real cloudflared exports the gauge label-less — HELP/TYPE lines included. */
|
|
33
|
+
const METRICS_BODY_CONNECTED = '# HELP cloudflared_tunnel_ha_connections Number of active ha connections\n' +
|
|
34
|
+
'# TYPE cloudflared_tunnel_ha_connections gauge\n' +
|
|
35
|
+
'cloudflared_tunnel_ha_connections 4\n';
|
|
36
|
+
/** fetch mock: metrics endpoint answers connected by default, health 200. */
|
|
37
|
+
function makeFetch(opts = {}) {
|
|
38
|
+
return jest.fn(async (url) => {
|
|
39
|
+
const href = String(url);
|
|
40
|
+
if (href === METRICS_URL) {
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
44
|
+
text: async () => opts.metricsBody ?? METRICS_BODY_CONNECTED,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
ok: opts.healthOk ?? true,
|
|
49
|
+
status: (opts.healthOk ?? true) ? 200 : 530,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const STATE = {
|
|
54
|
+
mahlas: 'workstation',
|
|
55
|
+
hostname: 'workstation.repoburg.com',
|
|
56
|
+
accountTunnelId: 'tunnel-uuid',
|
|
57
|
+
tunnelToken: 'connector-token',
|
|
58
|
+
};
|
|
59
|
+
function request(server, method, path, body) {
|
|
60
|
+
const { port } = server.address();
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
63
|
+
const req = http_1.default.request({
|
|
64
|
+
host: '127.0.0.1',
|
|
65
|
+
port,
|
|
66
|
+
method,
|
|
67
|
+
path,
|
|
68
|
+
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : undefined,
|
|
69
|
+
}, (res) => {
|
|
70
|
+
let data = '';
|
|
71
|
+
res.on('data', (chunk) => (data += chunk));
|
|
72
|
+
// Non-JSON replies (e.g. express 404 HTML) are status-only checks.
|
|
73
|
+
res.on('end', () => {
|
|
74
|
+
let json = null;
|
|
75
|
+
try {
|
|
76
|
+
json = JSON.parse(data || '{}');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
json = null;
|
|
80
|
+
}
|
|
81
|
+
resolve({ status: res.statusCode ?? 0, json });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
req.on('error', reject);
|
|
85
|
+
if (payload)
|
|
86
|
+
req.write(payload);
|
|
87
|
+
req.end();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
describe('remote routes', () => {
|
|
91
|
+
let server;
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
for (const mock of Object.values(mocked))
|
|
94
|
+
mock.mockReset();
|
|
95
|
+
mocked.cloudflaredMetricsUrl.mockReturnValue(METRICS_URL);
|
|
96
|
+
const app = (0, express_1.default)();
|
|
97
|
+
app.use(express_1.default.json());
|
|
98
|
+
app.use('/remote', (0, routes_1.createRemoteRouter)());
|
|
99
|
+
server = (await new Promise((resolve) => {
|
|
100
|
+
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
afterEach(() => new Promise((resolve) => server.close(() => resolve())));
|
|
104
|
+
it('GET /status aggregates tunnel, cloudflared, service, connection, and access', async () => {
|
|
105
|
+
mocked.loadTunnelState.mockReturnValue({
|
|
106
|
+
...STATE,
|
|
107
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
108
|
+
accessAud: 'aud-tag',
|
|
109
|
+
ownerEmail: 'owner@repoburg.com',
|
|
110
|
+
});
|
|
111
|
+
mocked.isInstalled.mockReturnValue(true);
|
|
112
|
+
mocked.isServiceRunning.mockResolvedValue(true);
|
|
113
|
+
global.fetch = makeFetch();
|
|
114
|
+
const res = await request(server, 'GET', '/remote/status');
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
expect(res.json.tunnel).toEqual({ mahlas: 'workstation', hostname: 'workstation.repoburg.com', provisioned: true });
|
|
117
|
+
expect(res.json.cloudflared).toEqual({ installed: true });
|
|
118
|
+
expect(res.json.service).toEqual({ running: true });
|
|
119
|
+
expect(res.json.connection).toEqual({ ok: true });
|
|
120
|
+
expect(res.json.access).toEqual({
|
|
121
|
+
configured: true,
|
|
122
|
+
ownerEmail: 'owner@repoburg.com',
|
|
123
|
+
teamDomain: 'team.cloudflareaccess.com',
|
|
124
|
+
});
|
|
125
|
+
expect(res.json.publicHealth).toEqual({ ok: true });
|
|
126
|
+
});
|
|
127
|
+
it('GET /status reports connection false when cloudflared metrics show 0 ha connections', async () => {
|
|
128
|
+
mocked.loadTunnelState.mockReturnValue(STATE);
|
|
129
|
+
mocked.isServiceRunning.mockResolvedValue(false);
|
|
130
|
+
global.fetch = makeFetch({ metricsBody: 'cloudflared_tunnel_ha_connections 0\n' });
|
|
131
|
+
const res = await request(server, 'GET', '/remote/status');
|
|
132
|
+
expect(res.json.connection).toEqual({ ok: false });
|
|
133
|
+
});
|
|
134
|
+
it('GET /status accepts labeled metric lines too (other cloudflared versions)', async () => {
|
|
135
|
+
mocked.loadTunnelState.mockReturnValue(STATE);
|
|
136
|
+
mocked.isServiceRunning.mockResolvedValue(true);
|
|
137
|
+
global.fetch = makeFetch({ metricsBody: 'cloudflared_tunnel_ha_connections{region="us"} 2\n' });
|
|
138
|
+
const res = await request(server, 'GET', '/remote/status');
|
|
139
|
+
expect(res.json.connection).toEqual({ ok: true });
|
|
140
|
+
});
|
|
141
|
+
it('GET /status reports connection false when metrics are unreachable', async () => {
|
|
142
|
+
mocked.loadTunnelState.mockReturnValue(STATE);
|
|
143
|
+
mocked.isServiceRunning.mockResolvedValue(false);
|
|
144
|
+
global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
145
|
+
const res = await request(server, 'GET', '/remote/status');
|
|
146
|
+
expect(res.json.connection).toEqual({ ok: false });
|
|
147
|
+
expect(res.json.publicHealth).toEqual({ ok: false });
|
|
148
|
+
});
|
|
149
|
+
it('GET /status reports false health when no tunnel exists', async () => {
|
|
150
|
+
mocked.loadTunnelState.mockReturnValue(null);
|
|
151
|
+
mocked.isInstalled.mockReturnValue(false);
|
|
152
|
+
mocked.isServiceRunning.mockResolvedValue(false);
|
|
153
|
+
const res = await request(server, 'GET', '/remote/status');
|
|
154
|
+
expect(res.json.tunnel).toEqual({ provisioned: false });
|
|
155
|
+
expect(res.json.publicHealth).toEqual({ ok: false });
|
|
156
|
+
expect(res.json.connection).toEqual({ ok: false });
|
|
157
|
+
});
|
|
158
|
+
it('POST /provision validates the mahlas body', async () => {
|
|
159
|
+
const res = await request(server, 'POST', '/remote/provision', {});
|
|
160
|
+
expect(res.status).toBe(400);
|
|
161
|
+
expect(mocked.provisionTunnel).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
it('POST /provision provisions and reports the hostname', async () => {
|
|
164
|
+
mocked.provisionTunnel.mockResolvedValue(STATE);
|
|
165
|
+
const res = await request(server, 'POST', '/remote/provision', { mahlas: 'workstation' });
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
expect(res.json.hostname).toBe('workstation.repoburg.com');
|
|
168
|
+
expect(mocked.provisionTunnel).toHaveBeenCalledWith('workstation');
|
|
169
|
+
});
|
|
170
|
+
it('POST /provision maps ProvisioningError status codes', async () => {
|
|
171
|
+
const err = new Error('taken');
|
|
172
|
+
err.statusCode = 409;
|
|
173
|
+
mocked.provisionTunnel.mockRejectedValue(err);
|
|
174
|
+
const res = await request(server, 'POST', '/remote/provision', { mahlas: 'x' });
|
|
175
|
+
expect(res.status).toBe(409);
|
|
176
|
+
});
|
|
177
|
+
it('POST /install requires a provisioned tunnel', async () => {
|
|
178
|
+
mocked.loadTunnelState.mockReturnValue(null);
|
|
179
|
+
const res = await request(server, 'POST', '/remote/install');
|
|
180
|
+
expect(res.status).toBe(409);
|
|
181
|
+
});
|
|
182
|
+
it('POST /install downloads (when missing), writes the service, and probes metrics connectivity', async () => {
|
|
183
|
+
mocked.loadTunnelState.mockReturnValue(STATE);
|
|
184
|
+
mocked.isInstalled.mockReturnValue(false);
|
|
185
|
+
mocked.downloadCloudflared.mockResolvedValue('/bin/cloudflared');
|
|
186
|
+
mocked.installService.mockResolvedValue('/unit/path');
|
|
187
|
+
mocked.isServiceRunning.mockResolvedValue(true);
|
|
188
|
+
global.fetch = makeFetch();
|
|
189
|
+
const res = await request(server, 'POST', '/remote/install');
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(mocked.downloadCloudflared).toHaveBeenCalled();
|
|
192
|
+
expect(mocked.installService).toHaveBeenCalledWith('connector-token');
|
|
193
|
+
expect(res.json.connection).toEqual({ ok: true });
|
|
194
|
+
expect(res.json.message).toContain('https://workstation.repoburg.com');
|
|
195
|
+
});
|
|
196
|
+
it('POST /install skips the download when cloudflared is present', async () => {
|
|
197
|
+
mocked.loadTunnelState.mockReturnValue(STATE);
|
|
198
|
+
mocked.isInstalled.mockReturnValue(true);
|
|
199
|
+
mocked.installService.mockResolvedValue('/unit/path');
|
|
200
|
+
mocked.isServiceRunning.mockResolvedValue(true);
|
|
201
|
+
global.fetch = makeFetch({ metricsBody: 'cloudflared_tunnel_ha_connections{region="us"} 0\n' });
|
|
202
|
+
const res = await request(server, 'POST', '/remote/install');
|
|
203
|
+
expect(mocked.downloadCloudflared).not.toHaveBeenCalled();
|
|
204
|
+
expect(res.json.connection).toEqual({ ok: false });
|
|
205
|
+
expect(res.json.message).toContain('no live connections');
|
|
206
|
+
});
|
|
207
|
+
it('POST /access-config no longer exists (Access is provisioned automatically)', async () => {
|
|
208
|
+
const res = await request(server, 'POST', '/remote/access-config', { teamDomain: 't', aud: 'a' });
|
|
209
|
+
expect(res.status).toBe(404);
|
|
210
|
+
expect(mocked.updateTunnelState).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
it('DELETE /uninstall removes service, server tunnel, and local state', async () => {
|
|
213
|
+
mocked.removeService.mockResolvedValue(undefined);
|
|
214
|
+
mocked.uninstallTunnel.mockResolvedValue(undefined);
|
|
215
|
+
const res = await request(server, 'DELETE', '/remote/uninstall');
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
expect(mocked.removeService).toHaveBeenCalled();
|
|
218
|
+
expect(mocked.uninstallTunnel).toHaveBeenCalled();
|
|
219
|
+
expect(mocked.clearTunnelState).toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
it('POST /enable validates the mahlas and starts the orchestration job', async () => {
|
|
222
|
+
orchestrator_1.startEnableJob.mockReturnValue({ id: 'job-1', state: 'running' });
|
|
223
|
+
const res = await request(server, 'POST', '/remote/enable', { mahlas: 'my-box' });
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
expect(res.json.id).toBe('job-1');
|
|
226
|
+
expect(orchestrator_1.startEnableJob).toHaveBeenCalledWith('my-box');
|
|
227
|
+
});
|
|
228
|
+
it('POST /enable rejects an invalid mahlas', async () => {
|
|
229
|
+
orchestrator_1.startEnableJob.mockClear();
|
|
230
|
+
const res = await request(server, 'POST', '/remote/enable', { mahlas: 'BAD NAME' });
|
|
231
|
+
expect(res.status).toBe(400);
|
|
232
|
+
expect(orchestrator_1.startEnableJob).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
it('GET /enable returns the current (or last) job', async () => {
|
|
235
|
+
orchestrator_1.getEnableJob.mockReturnValue({ id: 'job-9', state: 'ready' });
|
|
236
|
+
const res = await request(server, 'GET', '/remote/enable');
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(res.json.id).toBe('job-9');
|
|
239
|
+
});
|
|
240
|
+
it('GET /machines lists the account-server machines', async () => {
|
|
241
|
+
mocked.listTunnels.mockResolvedValue([{ id: '1', mahlas: 'workstation', hostname: 'workstation.repoburg.com', status: 'active' }]);
|
|
242
|
+
const res = await request(server, 'GET', '/remote/machines');
|
|
243
|
+
expect(res.json).toHaveLength(1);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
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.remoteStatePath = remoteStatePath;
|
|
7
|
+
exports.loadTunnelState = loadTunnelState;
|
|
8
|
+
exports.saveTunnelState = saveTunnelState;
|
|
9
|
+
exports.updateTunnelState = updateTunnelState;
|
|
10
|
+
exports.clearTunnelState = clearTunnelState;
|
|
11
|
+
/**
|
|
12
|
+
* Tunnel state persisted on the machine (~/.repoburg/remote.json).
|
|
13
|
+
*
|
|
14
|
+
* The tunnel token stored here is BY DESIGN machine-local: it is the
|
|
15
|
+
* single-purpose Cloudflare connector token that can only run this one
|
|
16
|
+
* tunnel — it cannot create, read, or delete tunnels, DNS, or anything else.
|
|
17
|
+
*/
|
|
18
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const os_1 = __importDefault(require("os"));
|
|
21
|
+
function remoteStatePath(home = os_1.default.homedir()) {
|
|
22
|
+
return path_1.default.join(home, '.repoburg', 'remote.json');
|
|
23
|
+
}
|
|
24
|
+
function loadTunnelState(home = os_1.default.homedir()) {
|
|
25
|
+
const statePath = remoteStatePath(home);
|
|
26
|
+
if (!fs_extra_1.default.existsSync(statePath))
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
const state = fs_extra_1.default.readJsonSync(statePath);
|
|
30
|
+
if (!state.hostname || !state.tunnelToken || !state.accountTunnelId)
|
|
31
|
+
return null;
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function saveTunnelState(state, home = os_1.default.homedir()) {
|
|
39
|
+
const statePath = remoteStatePath(home);
|
|
40
|
+
fs_extra_1.default.ensureDirSync(path_1.default.dirname(statePath));
|
|
41
|
+
fs_extra_1.default.writeJsonSync(statePath, state, { spaces: 2 });
|
|
42
|
+
}
|
|
43
|
+
function updateTunnelState(patch, home = os_1.default.homedir()) {
|
|
44
|
+
const current = loadTunnelState(home);
|
|
45
|
+
if (!current)
|
|
46
|
+
return null;
|
|
47
|
+
const next = { ...current, ...patch };
|
|
48
|
+
saveTunnelState(next, home);
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
function clearTunnelState(home = os_1.default.homedir()) {
|
|
52
|
+
const statePath = remoteStatePath(home);
|
|
53
|
+
fs_extra_1.default.removeSync(statePath);
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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 tunnel_state_1 = require("./tunnel-state");
|
|
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(), 'remote-state-'));
|
|
11
|
+
afterEach(() => (0, tunnel_state_1.clearTunnelState)(tmpHome));
|
|
12
|
+
describe('tunnel state', () => {
|
|
13
|
+
const state = {
|
|
14
|
+
mahlas: 'workstation',
|
|
15
|
+
hostname: 'workstation.repoburg.com',
|
|
16
|
+
accountTunnelId: 'tunnel-uuid',
|
|
17
|
+
tunnelToken: 'connector-token',
|
|
18
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
19
|
+
accessAud: 'aud-tag',
|
|
20
|
+
ownerEmail: 'owner@repoburg.com',
|
|
21
|
+
};
|
|
22
|
+
it('round-trips the state to ~/.repoburg/remote.json', () => {
|
|
23
|
+
(0, tunnel_state_1.saveTunnelState)(state, tmpHome);
|
|
24
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toEqual(state);
|
|
25
|
+
expect(fs_extra_1.default.existsSync((0, tunnel_state_1.remoteStatePath)(tmpHome))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it('returns null when no state exists', () => {
|
|
28
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it('returns null for malformed or incomplete state files', () => {
|
|
31
|
+
(0, tunnel_state_1.saveTunnelState)(state, tmpHome);
|
|
32
|
+
fs_extra_1.default.writeJsonSync((0, tunnel_state_1.remoteStatePath)(tmpHome), { hostname: 'x.repoburg.com' }); // missing fields
|
|
33
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it('clears the state file', () => {
|
|
36
|
+
(0, tunnel_state_1.saveTunnelState)(state, tmpHome);
|
|
37
|
+
(0, tunnel_state_1.clearTunnelState)(tmpHome);
|
|
38
|
+
expect((0, tunnel_state_1.loadTunnelState)(tmpHome)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
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.resolvePaneFrontendDist = resolvePaneFrontendDist;
|
|
7
|
+
exports.createPaneFrontendRouter = createPaneFrontendRouter;
|
|
8
|
+
/**
|
|
9
|
+
* Static serving for the pane frontend SPA.
|
|
10
|
+
*
|
|
11
|
+
* Spec: a machine's daemon serves the pane at https://<mahlas>.repoburg.com
|
|
12
|
+
* from its own origin — SPA and API share one origin (no CORS, no
|
|
13
|
+
* mixed-content). The router is mounted LAST: every API path (/api, /ws,
|
|
14
|
+
* /services, /auth, /registry, /mcp, /remote) is matched by earlier
|
|
15
|
+
* routers and never reaches the fallback.
|
|
16
|
+
*
|
|
17
|
+
* Path resolution is ABSOLUTE (pm2 God-daemon-cwd bug — see agentPane.ts).
|
|
18
|
+
*/
|
|
19
|
+
const express_1 = __importDefault(require("express"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
22
|
+
/** Resolve the pane frontend dist from the daemon's module directory. */
|
|
23
|
+
function resolvePaneFrontendDist(fromDir) {
|
|
24
|
+
return path_1.default.resolve(fromDir, '../../agent-pane/frontend/dist');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the static router: assets from dist, SPA fallback to index.html for
|
|
28
|
+
* client-side routes. Returns null when the dist bundle is missing (dev
|
|
29
|
+
* installs serve the frontend via vite instead).
|
|
30
|
+
*/
|
|
31
|
+
function createPaneFrontendRouter(distPath) {
|
|
32
|
+
const indexHtml = path_1.default.join(distPath, 'index.html');
|
|
33
|
+
if (!fs_extra_1.default.existsSync(indexHtml)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const router = express_1.default.Router();
|
|
37
|
+
// Static assets with fallthrough so the SPA fallback handles routes.
|
|
38
|
+
router.use(express_1.default.static(distPath, { fallthrough: true }));
|
|
39
|
+
// SPA fallback: any GET that no earlier route handled becomes the app.
|
|
40
|
+
router.get('*', (_req, res) => {
|
|
41
|
+
res.sendFile(indexHtml);
|
|
42
|
+
});
|
|
43
|
+
return router;
|
|
44
|
+
}
|