@pi-chamber/web 0.3.0 → 0.3.2
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/bin/lib/cli-startup.js +53 -10
- package/bin/lib/cli-startup.test.js +42 -0
- package/dist/assets/{CommitSection-BpbMC0et.js → CommitSection-DLIvjYbM.js} +1 -1
- package/dist/assets/{DiagramEditor-dFTMKHuO.js → DiagramEditor-Tw3ZWHML.js} +1 -1
- package/dist/assets/{DiagramView-DVw_8Px1.js → DiagramView-CK6R7taL.js} +1 -1
- package/dist/assets/{DiffView-CkoY1oAM.js → DiffView-vrxi5oWp.js} +2 -2
- package/dist/assets/{FilesView-C0hyg2pa.js → FilesView-BD_h3EVr.js} +6 -6
- package/dist/assets/{GitView-D36hpDsb.js → GitView-BwNKqK0K.js} +4 -4
- package/dist/assets/{JsonTreeView-BF8S-bPN.js → JsonTreeView-CmY7QgEX.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BVNChdig.js → MarkdownRendererImpl-CpI-25Xk.js} +1 -1
- package/dist/assets/{PatchDiff-B--OaNB1.js → PatchDiff-CrIWbYj7.js} +1 -1
- package/dist/assets/{PierreDiffViewer-0Qkp9ICt.js → PierreDiffViewer-B410mdbq.js} +2 -2
- package/dist/assets/{SessionSwitcherDropdown-cFWgy1td.js → SessionSwitcherDropdown-DROjbyIu.js} +100 -100
- package/dist/assets/SettingsView-CsJItYZh.js +4 -0
- package/dist/assets/{SettingsWindow-BuJCvYoR.js → SettingsWindow-C9s4AX72.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Bi9BoEe7.js → ToolOutputDialog-b_R4tAZu.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-DGX7N4zr.js → ToolPartDiffPreview-CfnliaE9.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-CxraYoDZ.js → VirtualizedFileDiff-CQz2fSZI.js} +4 -4
- package/dist/assets/{collapsible-d4-HChej.js → collapsible-CR4U9tjw.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BuD14qFL.js → computeVirtualFileMetrics-C7vXPUYT.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-DirV-hCs.js → contextFileOpenGuard-DtTHRnq1.js} +1 -1
- package/dist/assets/{index-DEYblXCW.js → index-BAqpUCjs.js} +5 -5
- package/dist/assets/{index-BWmmf5yJ.js → index-DZG82DE1.js} +1 -1
- package/dist/assets/{index-DLKBsLKY.css → index-riABQmQ_.css} +1 -1
- package/dist/assets/{isDiffPlainText-LoMmNzmX.js → isDiffPlainText-zTq-4A04.js} +1 -1
- package/dist/assets/{isFilePlainText-3Hj9i02t.js → isFilePlainText-CLgIdzE5.js} +1 -1
- package/dist/assets/{main-BsszlAdQ.js → main-BVWtJYSk.js} +2 -2
- package/dist/assets/main-DXYHsqzA.js +133 -0
- package/dist/assets/{markdownCore-sGxk8jrc.js → markdownCore-CN0pApQE.js} +2 -2
- package/dist/assets/{miniChat-C6SDQL4G.js → miniChat-BrbRq5NQ.js} +2 -2
- package/dist/assets/{mobile-DBW3XDmZ.js → mobile-DTPtCn5N.js} +2 -2
- package/dist/assets/{renderElectronMiniChatApp-B1_fTdN-.js → renderElectronMiniChatApp-CM8qBaye.js} +1 -1
- package/dist/assets/{renderMobileApp-CFxZc_1a.js → renderMobileApp-KS_pt-qc.js} +4 -4
- package/dist/assets/{runtimeSurface-9aJdtQbh.js → runtimeSurface-CkHYYg0E.js} +1 -1
- package/dist/assets/{useInlineCommentController-Cl--s6vE.js → useInlineCommentController-CwNNjgc7.js} +2 -2
- package/dist/assets/usePanelSlide-DkorgQJ5.js +56 -0
- package/dist/assets/{useWindowTitle-C-AZD713.js → useWindowTitle-B9wt_3ZQ.js} +3 -3
- package/dist/assets/{vendor-codemirror-language-data-HC9JT4I3.js → vendor-codemirror-language-data-x89A7Wy0.js} +2 -2
- package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
- package/dist/assets/{web-components-skJ_VpnT.js → web-components-BEPfE4kU.js} +2 -2
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/index.js +33 -0
- package/server/lib/pi/routes.js +24 -2
- package/server/lib/pi/routes.test.js +14 -6
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
- package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
- package/server/lib/pi/session-daemon/session-daemon.js +176 -46
- package/server/lib/pi/session-daemon/session-daemon.test.js +364 -6
- package/server/lib/pi/session-daemon/supervisor.js +20 -1
- package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
- package/server/lib/pi/settings-store.js +17 -0
- package/server/lib/pi/settings-store.test.js +33 -0
- package/server/lib/server/core-routes.js +36 -4
- package/server/lib/server/tunnel-service.DOCUMENTATION.md +74 -0
- package/server/lib/server/tunnel-service.js +228 -0
- package/server/lib/server/tunnel-service.test.js +61 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +2 -0
- package/server/lib/ui-auth/ui-auth.js +5 -1
- package/server/lib/ui-auth/ui-auth.test.js +17 -0
- package/dist/assets/SettingsView-D3ZZyjU3.js +0 -4
- package/dist/assets/main-CUvZIN16.js +0 -133
- package/dist/assets/usePanelSlide-D5ffPTGM.js +0 -56
|
@@ -479,6 +479,38 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
479
479
|
}
|
|
480
480
|
};
|
|
481
481
|
|
|
482
|
+
const runWithPairingCreateAuth = async (req, res, next, handler) => {
|
|
483
|
+
try {
|
|
484
|
+
if (typeof uiAuthController.resolveAuthContext === 'function') {
|
|
485
|
+
const context = await uiAuthController.resolveAuthContext(req, res, {
|
|
486
|
+
allowClientAuth: true,
|
|
487
|
+
allowUrlToken: false,
|
|
488
|
+
});
|
|
489
|
+
if (context?.type === 'session') {
|
|
490
|
+
await handler(context);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (context?.type === 'client') {
|
|
494
|
+
const client = await clientRecordFromAuthContext(context);
|
|
495
|
+
// Trusted desktop clients (local or remote) may create short-lived
|
|
496
|
+
// pairing tickets scoped to this server/user. Mobile or other
|
|
497
|
+
// client kinds remain prohibited from creating pairing sessions.
|
|
498
|
+
if (client?.clientKind === 'desktop-local' || client?.clientKind === 'desktop') {
|
|
499
|
+
await handler({ ...context, client });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
return res.status(403).json({ error: 'Client tokens cannot create pairing sessions' });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await runWithUiAuth(req, res, next, async () => {
|
|
507
|
+
await handler({ type: 'session' });
|
|
508
|
+
}, { sessionOnly: true });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
next(error);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
482
514
|
const clientIdFromAuthContext = (context) => {
|
|
483
515
|
const raw = context?.client?.id || context?.clientId;
|
|
484
516
|
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
@@ -811,7 +843,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
811
843
|
});
|
|
812
844
|
|
|
813
845
|
app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
|
|
814
|
-
await
|
|
846
|
+
await runWithPairingCreateAuth(req, res, next, async (authContext) => {
|
|
815
847
|
const candidates = await pairingServerCandidates(req, {
|
|
816
848
|
preferredServerUrl: req.body?.serverUrl,
|
|
817
849
|
includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
|
|
@@ -877,7 +909,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
877
909
|
|
|
878
910
|
// Direct transports the server can be reached on (for the create-device dialog).
|
|
879
911
|
app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
|
|
880
|
-
await
|
|
912
|
+
await runWithPairingCreateAuth(req, res, next, async () => {
|
|
881
913
|
res.setHeader('Cache-Control', 'no-store');
|
|
882
914
|
res.json(getPairingTransports(req));
|
|
883
915
|
});
|
|
@@ -886,7 +918,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
886
918
|
// Pending pairing sessions (link created, device not yet connected) for the
|
|
887
919
|
// "pending devices" list. Secrets are never included.
|
|
888
920
|
app.get('/api/client-auth/pairing/sessions', async (req, res, next) => {
|
|
889
|
-
await
|
|
921
|
+
await runWithPairingCreateAuth(req, res, next, async () => {
|
|
890
922
|
const pending = await clientPairingRuntime.listPendingSessions();
|
|
891
923
|
res.setHeader('Cache-Control', 'no-store');
|
|
892
924
|
res.json({ pending });
|
|
@@ -894,7 +926,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|
|
894
926
|
});
|
|
895
927
|
|
|
896
928
|
app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => {
|
|
897
|
-
await
|
|
929
|
+
await runWithPairingCreateAuth(req, res, next, async () => {
|
|
898
930
|
const result = await clientPairingRuntime.cancelPairingSession(req.params?.id);
|
|
899
931
|
if (!result.cancelled) {
|
|
900
932
|
return res.status(404).json({ cancelled: false, error: 'Pairing session not found' });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Cloudflare Tunnel Service
|
|
2
|
+
|
|
3
|
+
PiChamber exposes a PiChamber-hosted Cloudflare Tunnel that lets a browser or mobile client reach a private PiChamber server without opening inbound ports. The server dials outbound to Cloudflare with `cloudflared`; the public hostname then proxies to the local PiChamber HTTP endpoint.
|
|
4
|
+
|
|
5
|
+
## Modes
|
|
6
|
+
|
|
7
|
+
- **Quick** (`quick`): ephemeral `*.trycloudflare.com` URL, no account required, best-effort. Uses `cloudflared tunnel --url http://127.0.0.1:<port>`. Good for demos, not for production (no SLA, no SSE guarantees).
|
|
8
|
+
- **Managed Remote** (`managed-remote`): persistent hostname backed by a Cloudflare Tunnel token. Requires `token` (from `cloudflared tunnel create` / dashboard) and `hostname` (e.g. `pichamber.example.com`). Uses `cloudflared tunnel run --token-file ...` and stores the token on disk with `0600`.
|
|
9
|
+
- **Managed Local** (`managed-local`): uses a local `config.yml` (`~/.cloudflared/config.yml` by default) whose `ingress` hostname is proxied. Uses `cloudflared tunnel --config <path> run`.
|
|
10
|
+
|
|
11
|
+
Quick is the default when no hostname/token is supplied.
|
|
12
|
+
|
|
13
|
+
## Server component
|
|
14
|
+
|
|
15
|
+
`createTunnelService({ dataDir, getPort, tunnelAuthController })` owns the tunnel child process and its public URL. It checks `cloudflared` availability via `checkCloudflaredAvailable` (plus `getTunnelDependencyInstallInfo` for the install command), validates mode inputs before touching the binary, and redacts tokens in every response.
|
|
16
|
+
|
|
17
|
+
- `GET /api/pichamber/tunnel/check` – availability + install help
|
|
18
|
+
- `GET /api/pichamber/tunnel/status` – active, url, mode, bootstrap token presence, managed-remote token presence (boolean only), hostname, sessions, localPort
|
|
19
|
+
- `POST /api/pichamber/tunnel/start` – body `{ mode, token, hostname, configPath }`
|
|
20
|
+
- `POST /api/pichamber/tunnel/stop` – stops the child and clears `tunnelAuthController`
|
|
21
|
+
- `PUT /api/pichamber/tunnel/managed-remote-token` – stores token/hostname for later starts
|
|
22
|
+
- `GET /api/pichamber/tunnel/doctor` – status + check for diagnostics
|
|
23
|
+
|
|
24
|
+
All routes are behind `uiAuthController.requireAuth`, so a bearer client or UI session is required; anonymous callers get `401`.
|
|
25
|
+
|
|
26
|
+
The service sets `tunnelAuthController.setActiveTunnel` and issues a one-time bootstrap token (`/connect?t=...`) when the tunnel becomes active. Stopping clears it. The bootstrap token is `HttpOnly`, short-lived (30 min), and revoked when the tunnel is replaced or stopped.
|
|
27
|
+
|
|
28
|
+
Remote traffic is still authenticated by `tunnelAuthController` and `uiAuthController`; the tunnel only provides reachability, not authorization. `isSecureRequest` (X-Forwarded-Proto) and `isRequestOriginAllowed` continue to enforce origin and WebSocket upgrade checks even when traffic arrives via the tunnel.
|
|
29
|
+
|
|
30
|
+
## Secret handling
|
|
31
|
+
|
|
32
|
+
- Tokens are written to `<dataDir>/cloudflare-tunnel-token.json` with `0600`; the hostname is stored alongside so `status` can report it without exposing the token.
|
|
33
|
+
- `status` never returns the raw token, only `hasManagedRemoteTunnelToken: boolean` and the hostname.
|
|
34
|
+
- Logs and error messages use `redactToken` (`abcd***wxyz`).
|
|
35
|
+
- `cloudflared` stdout/stderr is drained but not logged with token material.
|
|
36
|
+
|
|
37
|
+
## Systemd on Linux remote servers
|
|
38
|
+
|
|
39
|
+
The tunnel child is normally supervised by the PiChamber server process (it dies when the server stops, and `stop` kills `SIGINT`). On a Linux remote server the same `cloudflared` can be run as a managed systemd service if the operator prefers:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
# /etc/systemd/system/pichamber-cloudflared.service
|
|
43
|
+
[Unit]
|
|
44
|
+
Description=PiChamber Cloudflare Tunnel
|
|
45
|
+
After=network-online.target
|
|
46
|
+
|
|
47
|
+
[Service]
|
|
48
|
+
Type=simple
|
|
49
|
+
ExecStart=/usr/bin/cloudflared tunnel run --token-file /var/lib/pichamber/cloudflare-tunnel-token
|
|
50
|
+
Restart=always
|
|
51
|
+
|
|
52
|
+
[Install]
|
|
53
|
+
WantedBy=multi-user.target
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The PiChamber `pichamber.service` itself already supports both user (`~/.config/systemd/user/pichamber.service`, `systemctl --user`) and system (`/etc/systemd/system/pichamber.service`, `systemctl`) scopes; the tunnel service follows the same scope so a root VPS gets a system unit that works immediately after install without `loginctl enable-linger`.
|
|
57
|
+
|
|
58
|
+
## Upgrade considerations
|
|
59
|
+
|
|
60
|
+
`pichamber update` detects the owning package manager, upgrades the global package, and restarts the relevant systemd service (`systemctl --user restart pichamber.service` or `systemctl restart pichamber.service` when running as root). The tunnel token file and PiChamber `pi/settings.json` sidecar are preserved across upgrades because they live under `PICHAMBER_DATA_DIR` (or `~/.pichamber`), not in the package directory. A Docker deployment is not upgraded in-place; it must be redeployed via `docker pull`.
|
|
61
|
+
|
|
62
|
+
## Testing
|
|
63
|
+
|
|
64
|
+
- `tunnel-service.test.js` covers validation (missing token/hostname, unsupported mode), status shape, secret redaction, and that `missing_dependency` is reported when `cloudflared` is absent.
|
|
65
|
+
- Integration smoke against a real `cloudflared` binary is manual: install `cloudflared`, run `pichamber serve`, `curl /api/pichamber/tunnel/check`, start a quick tunnel, verify `status.url` appears and `curl -H "Host: $url-host"` reaches the server through the tunnel with `Authorization` still required.
|
|
66
|
+
|
|
67
|
+
## Cloudflare permissions for API-token flow
|
|
68
|
+
|
|
69
|
+
If the operator wants PiChamber to create a tunnel automatically (rather than pasting a token), the Cloudflare API token needs:
|
|
70
|
+
|
|
71
|
+
- `Zone:Read`, `DNS:Edit` on the zone that will host the hostname
|
|
72
|
+
- `Account:Cloudflare Tunnel:Edit` (to create/list tunnels)
|
|
73
|
+
|
|
74
|
+
PiChamber never asks for the Cloudflare account password. The manual token flow is the default; the API-token flow is an optional convenience that calls `https://api.cloudflare.com/client/v4/accounts/{accountId}/cfd_tunnel` with the scoped token. Secrets are still stored with `0600` and never returned in API responses.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
checkCloudflaredAvailable,
|
|
6
|
+
startCloudflareQuickTunnel,
|
|
7
|
+
startCloudflareManagedRemoteTunnel,
|
|
8
|
+
startCloudflareManagedLocalTunnel,
|
|
9
|
+
} from '../cloudflare-tunnel.js';
|
|
10
|
+
import { getTunnelDependencyInstallInfo } from '../tunnels/install-help.js';
|
|
11
|
+
import { TUNNEL_PROVIDER_CLOUDFLARE } from '../tunnels/types.js';
|
|
12
|
+
|
|
13
|
+
const TUNNEL_STATE_FILE = 'cloudflare-tunnel-state.json';
|
|
14
|
+
const TUNNEL_TOKEN_FILE = 'cloudflare-tunnel-token.json';
|
|
15
|
+
|
|
16
|
+
const sanitizeToken = (value) => {
|
|
17
|
+
if (typeof value !== 'string') return '';
|
|
18
|
+
return value.trim();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const redactToken = (value) => {
|
|
22
|
+
if (!value || typeof value !== 'string') return '';
|
|
23
|
+
if (value.length <= 8) return '***';
|
|
24
|
+
return `${value.slice(0, 4)}***${value.slice(-4)}`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const createTunnelService = ({
|
|
28
|
+
dataDir,
|
|
29
|
+
getPort,
|
|
30
|
+
tunnelAuthController,
|
|
31
|
+
getServerLabel = () => 'PiChamber',
|
|
32
|
+
} = {}) => {
|
|
33
|
+
const statePath = path.join(dataDir, TUNNEL_STATE_FILE);
|
|
34
|
+
const tokenPath = path.join(dataDir, TUNNEL_TOKEN_FILE);
|
|
35
|
+
let activeController = null;
|
|
36
|
+
let activePublicUrl = null;
|
|
37
|
+
let activeMode = null;
|
|
38
|
+
let bootstrapToken = null;
|
|
39
|
+
let bootstrapExpiresAt = null;
|
|
40
|
+
|
|
41
|
+
const readTokenStore = () => {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(tokenPath, 'utf8');
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed && typeof parsed.token === 'string' && typeof parsed.hostname === 'string') {
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const writeTokenStore = (token, hostname) => {
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
|
|
55
|
+
fs.writeFileSync(tokenPath, JSON.stringify({ token, hostname }), { mode: 0o600 });
|
|
56
|
+
} catch {}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const clearTokenStore = () => {
|
|
60
|
+
try { fs.unlinkSync(tokenPath); } catch {}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getStatus = async () => {
|
|
64
|
+
const hasManagedRemoteTunnelToken = Boolean(readTokenStore()?.token);
|
|
65
|
+
const active = Boolean(activeController && activePublicUrl);
|
|
66
|
+
const providerMetadata = active ? { mode: activeMode } : null;
|
|
67
|
+
const hasBootstrapToken = Boolean(bootstrapToken && bootstrapExpiresAt && bootstrapExpiresAt > Date.now());
|
|
68
|
+
return {
|
|
69
|
+
active,
|
|
70
|
+
url: activePublicUrl,
|
|
71
|
+
mode: activeMode,
|
|
72
|
+
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
|
73
|
+
providerMetadata,
|
|
74
|
+
hasManagedRemoteTunnelToken,
|
|
75
|
+
managedRemoteTunnelHostname: readTokenStore()?.hostname ?? null,
|
|
76
|
+
hasBootstrapToken,
|
|
77
|
+
bootstrapExpiresAt: hasBootstrapToken ? bootstrapExpiresAt : null,
|
|
78
|
+
activeTunnelMode: activeMode,
|
|
79
|
+
activeSessions: tunnelAuthController?.listTunnelSessions?.() ?? [],
|
|
80
|
+
localPort: typeof getPort === 'function' ? getPort() : null,
|
|
81
|
+
policy: 'tunnel-gated',
|
|
82
|
+
ttlConfig: { bootstrapTtlMs: 30 * 60 * 1000, sessionTtlMs: 8 * 60 * 60 * 1000 },
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const check = async () => {
|
|
87
|
+
const result = await checkCloudflaredAvailable();
|
|
88
|
+
if (result.available) {
|
|
89
|
+
return {
|
|
90
|
+
available: true,
|
|
91
|
+
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
|
92
|
+
version: result.version,
|
|
93
|
+
dependency: 'cloudflared',
|
|
94
|
+
installCommand: null,
|
|
95
|
+
platform: process.platform,
|
|
96
|
+
message: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, process.platform);
|
|
100
|
+
return {
|
|
101
|
+
available: false,
|
|
102
|
+
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
|
103
|
+
version: null,
|
|
104
|
+
dependency: installInfo.dependency ?? 'cloudflared',
|
|
105
|
+
installCommand: installInfo.installCommand ?? installInfo.message,
|
|
106
|
+
platform: process.platform,
|
|
107
|
+
message: installInfo.message,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const start = async (options = {}) => {
|
|
112
|
+
const mode = typeof options.mode === 'string' ? options.mode : 'quick';
|
|
113
|
+
const hostname = sanitizeToken(options.hostname);
|
|
114
|
+
const token = sanitizeToken(options.token);
|
|
115
|
+
|
|
116
|
+
// Validate mode-specific inputs before checking binary availability so the
|
|
117
|
+
// user gets a actionable validation error even when cloudflared is missing.
|
|
118
|
+
if (mode === 'managed-remote') {
|
|
119
|
+
if (!token) {
|
|
120
|
+
const error = new Error('Managed remote tunnel token is required');
|
|
121
|
+
error.code = 'validation_error';
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (!hostname) {
|
|
125
|
+
const error = new Error('Managed remote tunnel hostname is required');
|
|
126
|
+
error.code = 'validation_error';
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
} else if (mode !== 'quick' && mode !== 'managed-local') {
|
|
130
|
+
const error = new Error(`Unsupported tunnel mode: ${mode}`);
|
|
131
|
+
error.code = 'mode_unsupported';
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (activeController) {
|
|
136
|
+
await stop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const availability = await checkCloudflaredAvailable();
|
|
140
|
+
if (!availability.available) {
|
|
141
|
+
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, process.platform);
|
|
142
|
+
const error = new Error(installInfo.message);
|
|
143
|
+
error.code = 'missing_dependency';
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const port = typeof getPort === 'function' ? getPort() : null;
|
|
148
|
+
const originUrl = port ? `http://127.0.0.1:${port}` : undefined;
|
|
149
|
+
|
|
150
|
+
let controller;
|
|
151
|
+
if (mode === 'quick') {
|
|
152
|
+
controller = await startCloudflareQuickTunnel({ originUrl });
|
|
153
|
+
} else if (mode === 'managed-remote') {
|
|
154
|
+
controller = await startCloudflareManagedRemoteTunnel({ token, hostname });
|
|
155
|
+
writeTokenStore(token, hostname);
|
|
156
|
+
} else if (mode === 'managed-local') {
|
|
157
|
+
const configPath = typeof options.configPath === 'string' ? options.configPath : undefined;
|
|
158
|
+
controller = await startCloudflareManagedLocalTunnel({ configPath, hostname });
|
|
159
|
+
} else {
|
|
160
|
+
const error = new Error(`Unsupported tunnel mode: ${mode}`);
|
|
161
|
+
error.code = 'mode_unsupported';
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
activeController = controller;
|
|
166
|
+
activePublicUrl = controller.getPublicUrl?.() ?? (mode !== 'quick' ? `https://${hostname}` : null);
|
|
167
|
+
activeMode = mode;
|
|
168
|
+
|
|
169
|
+
if (tunnelAuthController && activePublicUrl) {
|
|
170
|
+
const tunnelId = crypto.randomUUID();
|
|
171
|
+
tunnelAuthController.setActiveTunnel({ tunnelId, publicUrl: activePublicUrl, mode });
|
|
172
|
+
try {
|
|
173
|
+
const issued = tunnelAuthController.issueBootstrapToken({ ttlMs: 30 * 60 * 1000 });
|
|
174
|
+
bootstrapToken = issued.token;
|
|
175
|
+
bootstrapExpiresAt = issued.expiresAt;
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
url: activePublicUrl,
|
|
182
|
+
mode: activeMode,
|
|
183
|
+
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
|
184
|
+
providerMetadata: controller.getResolvedHostname ? { resolvedHostname: controller.getResolvedHostname() } : null,
|
|
185
|
+
connectUrl: bootstrapToken && activePublicUrl ? `${activePublicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken)}` : null,
|
|
186
|
+
bootstrapExpiresAt,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const stop = async () => {
|
|
191
|
+
if (activeController?.stop) {
|
|
192
|
+
try { activeController.stop(); } catch {}
|
|
193
|
+
}
|
|
194
|
+
if (activeController?.process?.kill) {
|
|
195
|
+
try { activeController.process.kill('SIGINT'); } catch {}
|
|
196
|
+
}
|
|
197
|
+
activeController = null;
|
|
198
|
+
activePublicUrl = null;
|
|
199
|
+
activeMode = null;
|
|
200
|
+
bootstrapToken = null;
|
|
201
|
+
bootstrapExpiresAt = null;
|
|
202
|
+
if (tunnelAuthController) {
|
|
203
|
+
try { tunnelAuthController.clearActiveTunnel(); } catch {}
|
|
204
|
+
}
|
|
205
|
+
return { ok: true };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const saveManagedRemoteToken = async ({ token, hostname, presetName }) => {
|
|
209
|
+
const cleanToken = sanitizeToken(token);
|
|
210
|
+
const cleanHostname = sanitizeToken(hostname);
|
|
211
|
+
if (!cleanToken || !cleanHostname) {
|
|
212
|
+
const error = new Error('Token and hostname are required');
|
|
213
|
+
error.code = 'validation_error';
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
writeTokenStore(cleanToken, cleanHostname);
|
|
217
|
+
return { ok: true };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
getStatus,
|
|
222
|
+
check,
|
|
223
|
+
start,
|
|
224
|
+
stop,
|
|
225
|
+
saveManagedRemoteToken,
|
|
226
|
+
_internals: { getActiveController: () => activeController, redactToken },
|
|
227
|
+
};
|
|
228
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
|
|
7
|
+
import { createTunnelService } from './tunnel-service.js';
|
|
8
|
+
|
|
9
|
+
const makeDataDir = async () => {
|
|
10
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'pichamber-tunnel-test-'));
|
|
11
|
+
return dir;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('tunnel service', () => {
|
|
15
|
+
let dataDir;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dataDir = await makeDataDir();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await rm(dataDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('validates managed-remote mode requires token and hostname', async () => {
|
|
26
|
+
const service = createTunnelService({ dataDir, getPort: () => 3000, tunnelAuthController: { setActiveTunnel: () => {}, clearActiveTunnel: () => {}, issueBootstrapToken: () => ({ token: 't', expiresAt: Date.now() + 1000 }), listTunnelSessions: () => [] } });
|
|
27
|
+
await expect(service.start({ mode: 'managed-remote', token: '', hostname: '' })).rejects.toMatchObject({ code: 'validation_error' });
|
|
28
|
+
await expect(service.start({ mode: 'managed-remote', token: 'tok', hostname: '' })).rejects.toMatchObject({ code: 'validation_error' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns status with required fields and handles missing cloudflared gracefully', async () => {
|
|
32
|
+
const service = createTunnelService({ dataDir, getPort: () => 3000, tunnelAuthController: { listTunnelSessions: () => [] } });
|
|
33
|
+
const status = await service.getStatus();
|
|
34
|
+
expect(status).toHaveProperty('active');
|
|
35
|
+
expect(status).toHaveProperty('url');
|
|
36
|
+
expect(status).toHaveProperty('mode');
|
|
37
|
+
expect(typeof status.active).toBe('boolean');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not expose secrets in status or logs', async () => {
|
|
41
|
+
const service = createTunnelService({ dataDir, getPort: () => 3000, tunnelAuthController: { listTunnelSessions: () => [] } });
|
|
42
|
+
await service.saveManagedRemoteToken({ token: 'secret-token-12345', hostname: 'example.trycloudflare.com' });
|
|
43
|
+
const status = await service.getStatus();
|
|
44
|
+
const serialized = JSON.stringify(status);
|
|
45
|
+
expect(serialized).not.toContain('secret-token-12345');
|
|
46
|
+
expect(status.hasManagedRemoteTunnelToken).toBe(true);
|
|
47
|
+
expect(status.managedRemoteTunnelHostname).toBe('example.trycloudflare.com');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('redacts tokens when formatting for display', async () => {
|
|
51
|
+
const service = createTunnelService({ dataDir, getPort: () => 3000, tunnelAuthController: { listTunnelSessions: () => [] } });
|
|
52
|
+
const redacted = service._internals.redactToken('my-super-secret-token-value');
|
|
53
|
+
expect(redacted).not.toContain('super-secret');
|
|
54
|
+
expect(redacted.length).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles unsupported mode with clear error', async () => {
|
|
58
|
+
const service = createTunnelService({ dataDir, getPort: () => 3000, tunnelAuthController: { setActiveTunnel: () => {}, clearActiveTunnel: () => {}, issueBootstrapToken: () => ({ token: 't', expiresAt: Date.now() + 1000 }), listTunnelSessions: () => [] } });
|
|
59
|
+
await expect(service.start({ mode: 'unknown-mode' })).rejects.toThrow('Unsupported tunnel mode');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -7,6 +7,8 @@ Trusted-device access has one durable credential model: a remote client bearer t
|
|
|
7
7
|
|
|
8
8
|
Pairing v2 is implemented by `packages/web/server/lib/client-auth/pairing.js`. It stores short-lived one-time pairing sessions with hashed secrets, exposes create/cancel/redeem routes under `/api/client-auth/pairing/*`, and redeems a valid pairing secret into the same remote client token used by password/passkey trusted-device flows.
|
|
9
9
|
|
|
10
|
+
Browser WebSocket clients mint a 60-second `oc_url_token` through the authenticated `/auth/url-token` route because the WebSocket constructor cannot attach an Authorization header. The token is accepted only for the explicit WebSocket allowlist, currently `/api/terminal/ws`; ordinary API routes and unknown upgrade paths reject it. The token resolves to the UI session or remote-client identity that minted it.
|
|
11
|
+
|
|
10
12
|
## Entrypoints and structure
|
|
11
13
|
- `packages/web/server/lib/ui-auth/ui-auth.js`: UI auth controller runtime, cookie/session issuance, rate limiting, and auth route handlers.
|
|
12
14
|
- `packages/web/server/lib/ui-auth/ui-passkeys.js`: passkey store and WebAuthn registration/authentication verification helpers.
|
|
@@ -293,7 +293,11 @@ const isWebSocketUpgrade = (req) => {
|
|
|
293
293
|
|
|
294
294
|
const isUrlAuthReadableHttpPath = (pathname) => pathname === '/api/pi/events';
|
|
295
295
|
|
|
296
|
-
const
|
|
296
|
+
const URL_AUTH_WEBSOCKET_PATHS = new Set([
|
|
297
|
+
'/api/terminal/ws',
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
const isUrlAuthWebSocketPath = (pathname) => URL_AUTH_WEBSOCKET_PATHS.has(pathname);
|
|
297
301
|
|
|
298
302
|
const canUseUrlAuthTokenForRequest = (req) => {
|
|
299
303
|
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
@@ -182,6 +182,23 @@ describe('ui auth client credential seam', () => {
|
|
|
182
182
|
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
|
183
183
|
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
|
184
184
|
|
|
185
|
+
const terminalWsReq = {
|
|
186
|
+
method: 'GET',
|
|
187
|
+
path: '/api/terminal/ws',
|
|
188
|
+
url: `/api/terminal/ws?oc_url_token=${encodeURIComponent(urlToken)}`,
|
|
189
|
+
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
|
190
|
+
};
|
|
191
|
+
const terminalWsRes = createResponse();
|
|
192
|
+
expect(await auth.ensureSessionToken(terminalWsReq, terminalWsRes)).toBe('client:device-1');
|
|
193
|
+
|
|
194
|
+
const arbitraryWsReq = {
|
|
195
|
+
method: 'GET',
|
|
196
|
+
path: '/api/not-allowed/ws',
|
|
197
|
+
url: `/api/not-allowed/ws?oc_url_token=${encodeURIComponent(urlToken)}`,
|
|
198
|
+
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
|
199
|
+
};
|
|
200
|
+
expect(await auth.ensureSessionToken(arbitraryWsReq, createResponse())).toBe(null);
|
|
201
|
+
|
|
185
202
|
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
|
186
203
|
const arbitraryGetRes = createResponse();
|
|
187
204
|
let arbitraryGetCalled = false;
|