@rubytech/create-maxy 1.0.685 → 1.0.687

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.
Files changed (35) hide show
  1. package/dist/index.js +23 -215
  2. package/dist/pinned-binaries.js +10 -41
  3. package/dist/uninstall.js +23 -23
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +35 -9
  6. package/payload/platform/plugins/docs/PLUGIN.md +2 -0
  7. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  8. package/payload/platform/plugins/docs/references/deployment.md +13 -10
  9. package/payload/platform/plugins/docs/references/graph.md +38 -0
  10. package/payload/platform/plugins/docs/references/platform.md +10 -7
  11. package/payload/platform/plugins/docs/references/troubleshooting.md +23 -13
  12. package/payload/platform/scripts/vnc.sh +7 -7
  13. package/payload/platform/templates/systemd/edge.service.template +5 -4
  14. package/payload/server/maxy-edge.js +5 -367
  15. package/payload/server/public/assets/admin-BqLtaMVu.js +352 -0
  16. package/payload/server/public/assets/{data-DUSyrydY.js → data-BZ7v-zug.js} +1 -1
  17. package/payload/server/public/assets/{file-CDJ6dUV3.js → file-CScYkZq5.js} +1 -1
  18. package/payload/server/public/assets/graph-tjXdtwk-.js +50 -0
  19. package/payload/server/public/assets/{house-CNP_bwvT.js → house-CdFRNujU.js} +1 -1
  20. package/payload/server/public/assets/{jsx-runtime-BFFQvkdQ.css → jsx-runtime-Og0q7dXg.css} +1 -1
  21. package/payload/server/public/assets/{public-sHoAccvb.js → public-CrkQJek6.js} +2 -2
  22. package/payload/server/public/assets/{share-2-DBcb9j6E.js → share-2-Ev-D4Lm9.js} +1 -1
  23. package/payload/server/public/assets/{useVoiceRecorder-CtSgpc95.js → useVoiceRecorder-DyDXH7EA.js} +2 -2
  24. package/payload/server/public/assets/{x-CTVJaC_u.js → x-D5W7ddgP.js} +1 -1
  25. package/payload/server/public/data.html +6 -6
  26. package/payload/server/public/graph.html +6 -6
  27. package/payload/server/public/index.html +7 -8
  28. package/payload/server/public/public.html +4 -4
  29. package/payload/server/server.js +830 -258
  30. package/payload/platform/templates/dotfiles/.tmux.conf +0 -1
  31. package/payload/platform/templates/systemd/ttyd.service.template +0 -30
  32. package/payload/server/public/assets/admin-BFmYXz1V.js +0 -362
  33. package/payload/server/public/assets/admin-kHJ-D0s7.css +0 -1
  34. package/payload/server/public/assets/graph-CWcYp5bE.js +0 -50
  35. /package/payload/server/public/assets/{jsx-runtime-BVKWELH6.js → jsx-runtime-CHqDsKlc.js} +0 -0
@@ -0,0 +1,38 @@
1
+ # Graph View
2
+
3
+ The **Graph** admin page (`/graph`) renders a force-directed view of your
4
+ account's Neo4j subgraph. Labels on the canvas follow the zoom level, so you
5
+ see the most useful identity at every scale.
6
+
7
+ ## Conversation label tiers
8
+
9
+ Conversation nodes carry the most operator-meaningful identity in the
10
+ subgraph (the conversation name or summary, the date it started, the message
11
+ count). They render in one of three tiers, switched by canvas scale:
12
+
13
+ | Zoom | Label shape | Example |
14
+ |------|-------------|---------|
15
+ | Zoomed out (< 0.7×) | Compact — one line, capped at 24 characters. Preserves the no-overlap contract that matters when nodes are tightly packed. | `Maxyfi branding conflict…` |
16
+ | Mid zoom (0.7× to 1.3×) | Wrapped — up to two lines of 24 characters each, soft-ellipsis on overflow. Full name is visible without hover. | `Maxyfi branding conflict` / `with Rubytech` |
17
+ | Zoomed in (≥ 1.3×) | Detailed — wrapped name plus a metadata line reading `YYYY-MM-DD · N msgs`. | `Maxyfi branding conflict` / `with Rubytech` / `2026-04-23 · 7 msgs` |
18
+
19
+ Non-Conversation nodes (People, Messages, Tasks, WorkflowRuns, etc.) keep
20
+ their concise single-line labels at every zoom level — the canvas stays
21
+ readable when you zoom out to see a large subgraph.
22
+
23
+ Tier transitions are debounced so spinning the scroll wheel does not cause
24
+ label flicker; labels only rewrite once zoom settles on a new tier.
25
+
26
+ ## Tooltips and side panel
27
+
28
+ Hovering a node still shows the full 5-line tooltip (display name, labels,
29
+ id, created at, updated at). Clicking a Conversation opens the side panel
30
+ with the full property table — zoom-tier changes never alter these paths.
31
+
32
+ ## Trashed conversations
33
+
34
+ Trashed Conversation nodes are hidden by default. Toggle **Show trashed** in
35
+ the filter popover to surface them; they render with a faded fill and dashed
36
+ border, with their zoom-tier labels intact. The `N msgs` count excludes
37
+ trashed Messages, so the detailed-tier label reflects only live turns in the
38
+ conversation.
@@ -62,19 +62,22 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
62
62
 
63
63
  The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
64
64
 
65
- ## Admin Terminal
65
+ ## Software Update and Cloudflare Setup
66
66
 
67
- The admin UI has a single terminal surface one pipeline (a real GUI terminal emulator running on the Pi's VNC display `:99`, rendered inside an `iframe` of `/vnc-viewer.html`) with two entry points: the burger menu's **Terminal** button (opens a plain operator shell) and the Software Update modal's **Upgrade** button (opens the same iframe with `npx -y @rubytech/create-maxy@latest` already running in the spawned shell).
67
+ There is no free-form terminal surface in the admin UI ad-hoc shell access stays on SSH. The two flows that used to need a terminal (upgrade, Cloudflare tunnel setup) run as **detached actions** instead.
68
68
 
69
- The binary is chosen by target display. For the VNC virtual display `:99`, **xterm** is preferred because it honours `DISPLAY` directly with no IPC layer. For the native loopback display, **gnome-terminal** is preferred because the login session's `gnome-terminal-server` owns that display. (gnome-terminal cannot be used for the VNC display — it's a D-Bus launcher that delegates window creation to the session's server, and the window would appear on the physical screen instead of inside the admin iframe.) Post-spawn, the launch pipeline asserts the window actually landed on the target display via `xdotool search --onlyvisible --class '.'` if anything went wrong (gnome-terminal D-Bus delegation, missing `xdotool`, broken X server), the 502 response body carries the exact diagnostic string inline in the UI instead of showing a black rectangle.
69
+ **Software Update flow.** Click Upgrade in the modal the modal asks for your sudo password once the admin server validates it and launches a transient `systemd-run --user` unit running `npx -y @rubytech/create-maxy@latest`. The action unit is a peer of the brand service (not a child), so the installer's mid-run `systemctl --user restart maxy` does not kill the upgrade itself it finishes running, then the web UI reloads on the new version. The modal shows a live log panel with three event types:
70
+ - every stdout/stderr line the installer emits, timestamped server-side;
71
+ - a heartbeat every 5 seconds carrying `state=active` + `last_phase` so you can see the installer is alive even during silent phases (tarball download, dep install);
72
+ - a final banner on exit with the return code and elapsed time.
70
73
 
71
- **Software Update flow.** Click Upgrade in the modal the admin server kills any pre-existing terminal on `:99` (the upgrade must run in a fresh shell) and spawns the chosen binary with a command dispatcher: `xterm -e bash -c "npx …; exec bash"` or `gnome-terminal -- bash -c "npx …; exec bash"`. The trailing `exec bash` replaces the shell process after `npx` exits so the window stays open — you can scroll through the full installer output and read the final "Open in your browser:" line at your own pace. The modal mounts the fullscreen VNC terminal overlay (the same surface the burger menu's Terminal button uses) and the operator watches the installer run at viewport height. A background poll of `GET /api/admin/version` every 5 seconds detects completion: the moment `installed === latest`, the modal flips to success and the page reloads two seconds later. Password-protected `sudo` prompts appear natively inside the terminal — the password you type never leaves the Pi.
74
+ If the browser drops the SSE connection mid-upgrade (typical during the maxy restart window), the panel reconnects within two seconds and replays any lines you missed from the persisted log.
72
75
 
73
- **Operator Terminal flow.** The burger menu's Terminal button uses the exact same pipeline, minus the pre-loaded command. Click Terminal the admin server spawns the same binary (no `-e`/`--` dispatcher, no `bash -c` wrapper) the iframe shows an empty shell. Closing the overlay with Esc or × kills the spawned PID on the Pi via `POST /api/admin/terminal/close`; reopening launches a fresh shell.
76
+ **Cloudflare setup flow.** Same pattern POST to `/api/admin/cloudflare/setup` launches a `cloudflare-setup` action that runs `~/setup-tunnel.sh <brand> <port> <hostname...>`. When the script emits the OAuth consent URL on stdout, the log panel surfaces an **"Authorise in Cloudflare"** button; clicking it opens the consent page in a new tab. After you approve, the script's callback receives `cert.pem` and the setup continues through `tunnel create`/`route`/`run`. On devices where a VNC Chromium is also running, the script can drive the click via CDP automatically (same button remains a harmless safety net).
74
77
 
75
- **If you click Upgrade while an operator Terminal is open** (or vice versa), the pre-existing shell is killed and the iframe refreshes with the new shell. The upgrade is disruptive by design you cannot graft an installer onto a shell that already has your env, cwd, and aliases.
78
+ **Sudo password** is prompted once per upgrade. The admin server pipes it to `sudo -S -v` to validate + cache, then forwards it to the action unit via `systemd-run --setenv=SUDO_PASSWORD` so the installer's in-unit `sudo -S` reads it directlyper-TTY sudoers configurations where the user-level cache does not cover a fresh systemd-run unit still work. The password is never written to any log, SSE frame, or persisted file.
76
79
 
77
- **Error surface.** If the terminal emulator fails to spawn on `:99` (missing xterm, gnome-terminal broken, X server down), the UpdateModal renders the verbatim server-side diagnostic — e.g. `[terminal-launch] failed err="window absent from target display after spawn" pid=... display=:99 observed_windows=0 transport=vnc reason=upgrade` — and the Upgrade button re-labels to "Try again". The same string lands in `~/.maxy/logs/vnc-boot.log` under `action=launch-upgrade-failed err="..."`, so what you see in the modal and what your operator-visible diagnostic grep finds are identical.
80
+ **Log files.** Each action writes its full output to `~/.maxy/logs/actions/<actionId>.log` for seven days. `journalctl --user --identifier=maxy-action-<actionId>` gives the systemd-level view.
78
81
 
79
82
  **Authorisation** is inherited from the same `canAccessAdmin()` gate that wraps every `/api/admin/*` route.
80
83
 
@@ -93,26 +93,36 @@ If the initial Cloudflare login fails during setup, Maxy will fall back to askin
93
93
 
94
94
  ---
95
95
 
96
- ## Admin terminal renders leading `0`, `1`, or `2` characters (wire-format decoder not running)
96
+ ## Action runner upgrade or Cloudflare setup appears stuck
97
97
 
98
- **Symptom:** The header-menu Terminal or Software Update modal connects, but the xterm.js buffer shows literal digits where the tmux prompt should be — e.g. `1tmux new-session -A -s maxy-pty -x 200 -y 50 (neo)2{"..."}`. Typing keystrokes does nothing (the terminal looks alive but nothing echoes).
98
+ Task 664 replaced the ttyd/xterm admin terminal with a detached action runner. Upgrades and Cloudflare setup now run under transient `systemd-run --user` units whose stdout+stderr land in a persisted per-action log, streamed to the browser via SSE.
99
99
 
100
- **What it means:** ttyd 1.7.7 frames every WebSocket message with a 1-byte command prefix (`'0'` for output, `'1'` for window title, `'2'` for preferences). The React terminal needs the [ttyd-protocol.ts](../../../../platform/ui/app/lib/ttyd-protocol.ts) decoder to split the prefix and route accordingly. If the decoder isn't wired up — typically because an older bundle was installed or a hotfix reverted the wiring — xterm.js receives the raw bytes and renders the prefix as a literal digit. Outbound keystrokes have the mirror problem: ttyd discards any frame that doesn't start with `'0'` INPUT.
100
+ **Heartbeat stalled** (log panel header shows rising `silent Ns` amber badge).
101
101
 
102
- **Check (browser DevTools console on admin.maxy.bot):**
102
+ - Open the log panel header: `state: <systemd_state>` tells you the unit's current state.
103
+ - `systemd_state: active` + silent >30s → the child is running but emitting nothing. Expected for `npx` while it downloads the tarball, or `cloudflared tunnel login` waiting for an operator click.
104
+ - `systemd_state: inactive` + no `exit` event → the exit event was missed; the server-side heartbeat timer will emit it on the next 5 s tick.
105
+ - `systemd_state: failed` → see the next symptom.
103
106
 
104
- ```
105
- [terminal] connected
106
- [ttyd-client] protocol negotiated cmd=0 payloadBytes=<n> ← expected within 3s of connect
107
- ```
107
+ **`ActiveState=failed`** (log panel's exit banner shows a non-zero code).
108
+
109
+ - Read the persisted log directly: `~/.maxy/logs/actions/<actionId>.log` (or `.realagent/...`) has every stdout+stderr line the unit emitted.
110
+ - `journalctl --user --identifier=maxy-action-<actionId>` shows systemd's own record including ExecStartPre/ExecStopPost if any.
111
+ - Common cases:
112
+ - Wrong sudo password → `sudo: 1 incorrect password attempt` near the top of the log; re-open the upgrade modal, enter the correct password.
113
+ - Network failure during `npx` → `npm ERR! network` lines; re-open the modal and retry when network is restored.
114
+ - `cloudflared tunnel login` timed out waiting for OAuth → action exits non-zero with `Timed out after Ns waiting for cert.pem`; re-trigger from the Cloudflare setup form.
115
+
116
+ **"Authorise in Cloudflare" button never appears** (cloudflare-setup action).
117
+
118
+ The setup script emits either the raw `https://dash.cloudflare.com/argotunnel?...` URL in cloudflared's own stderr OR an explicit `OAUTH_URL: <url>` stdout line once URL extraction succeeds. The log panel's regex matches either. If neither appears within ~15 s of launch:
108
119
 
109
- - `[terminal] connected` present but `[ttyd-client] protocol negotiated` absent → the decoder is not running. Re-install: `npx -y @rubytech/create-maxy@latest` (or Software Update from the admin header when reachable).
110
- - `[ttyd-client] unknown cmd prefix=<hex> bytes=<n>` cluster ttyd version drift. The 1.7.7 pin is expected; a newer ttyd may have added command classes. Safe-ignore on 1.7.7.
111
- - Neither line appears → the WebSocket itself isn't connecting; see the "Admin terminal blank / flashing cursor" section below (pre-Task-657 sections are historical, not applicable).
120
+ - The action log file (`~/.maxy/logs/actions/<actionId>.log`) should show `[script:setup-tunnel:cloudflared]` lines. No such lines cloudflared isn't spawning (check whether the binary is on PATH in the transient unit; `systemctl --user show maxy-action-<actionId>` reveals the environment).
121
+ - Lines present but no URLcloudflared output-format drift; file a task with the last 20 lines of the action log.
112
122
 
113
- **Correlate with server side.** `grep corrId=<id> ~/.maxy/logs/edge-boot.log` reveals whether bytes are flowing end-to-end — `ttyd-proxy-chunk dir=upstream→client` lines prove ttyd is sending output. If those lines exist but the browser shows leading digits, the decode listener isn't wired to the live socket; if those lines are absent, the failure is upstream of the client (maxy-ttyd service, tmux attach).
123
+ **Log file missing (action stream returns 404).**
114
124
 
115
- **Regression boundary:** `platform/ui/app/RemoteTerminal.tsx` must import `decodeFrame`, `encodeInput`, and `encodeResize` from `./lib/ttyd-protocol`. `@xterm/addon-attach` is not a dependency it pipes WS bytes to `term.write` raw and cannot satisfy the ttyd framing contract.
125
+ The transient unit was auto-collected by systemd before the client subscribed. Race condition: action finished in <1 s. The per-action log file is retained for 7 days; look for it by name under `~/.maxy/logs/actions/`. If it isn't there, the unit failed before any output (check `journalctl --user -u maxy-action-<id>`).
116
126
 
117
127
  ---
118
128
 
@@ -10,9 +10,10 @@
10
10
  # Chromium :9222 — headed browser with CDP enabled
11
11
  # (Playwright MCP connects via --cdp-endpoint)
12
12
  #
13
- # Task 657: the admin Terminal overlay and upgrade modal now use the
14
- # byte-stream xterm.js surface over /ttyd on maxy-edge, not VNC. This
15
- # script no longer spawns GUI terminal emulators.
13
+ # Task 664 retired the admin-UI terminal surface entirely. This script
14
+ # no longer spawns GUI terminal emulators of any kind; upgrades and
15
+ # cloudflare-setup actions run via the detached action runner
16
+ # (/api/admin/actions/*) rather than an in-browser terminal.
16
17
  #
17
18
  # Display modes (DISPLAY_MODE env var, set by installer --display flag):
18
19
  # virtual (default) — Chromium runs on :99 (VNC virtual display)
@@ -185,10 +186,9 @@ start_chrome() {
185
186
  }
186
187
 
187
188
  # ---------------------------------------------------------------------------
188
- # Task 657 retired the VNC-as-terminal path (Task 627/643): the header
189
- # TerminalOverlay and UpdateModal now mount xterm.js against /ttyd on
190
- # maxy-edge. The GUI-terminal spawn pipeline that lived here is gone.
191
- # Only the Chromium surface remains for BrowserOverlay.
189
+ # Task 664 retired the admin-UI terminal surface entirely: the header
190
+ # TerminalOverlay + UpdateModal embedded terminal stack is gone. Only
191
+ # the Chromium surface remains for BrowserOverlay.
192
192
  # ---------------------------------------------------------------------------
193
193
 
194
194
  start_chrome_native() {
@@ -1,5 +1,5 @@
1
1
  [Unit]
2
- Description=Edge service (public port + VNC + ttyd transport — independent of the main brand service)
2
+ Description=Edge service (public port + VNC transport — independent of the main brand service)
3
3
  # No ordering dependency on the main brand service: the edge is the long-running
4
4
  # front door. The main brand unit declares Wants/After this unit so it only
5
5
  # starts once the edge is listening, but this unit has no such reciprocal
@@ -8,8 +8,10 @@ Description=Edge service (public port + VNC + ttyd transport — independent of
8
8
  [Service]
9
9
  Type=simple
10
10
  # ExecStartPre owns the VNC stack so an `npx -y @rubytech/create-maxy@latest`
11
- # run from the admin terminal can restart the main brand service without
12
- # taking Xtigervnc, websockify, or the upgrade shell down with it. Task 647.
11
+ # run from the admin UI can restart the main brand service without taking
12
+ # Xtigervnc or websockify down with it. Task 647. Task 664 removed the ttyd
13
+ # transport — upgrades now run via systemd-run --user transient units that
14
+ # are peers of maxy-ui and survive its restart structurally.
13
15
  ExecStartPre=/bin/bash __INSTALL_DIR__/platform/scripts/vnc.sh start
14
16
  ExecStart=/usr/bin/node __INSTALL_DIR__/server/maxy-edge.js
15
17
  ExecStopPost=/bin/bash __INSTALL_DIR__/platform/scripts/vnc.sh stop
@@ -24,7 +26,6 @@ Environment=MAXY_UI_HOST=127.0.0.1
24
26
  Environment=MAXY_UI_PORT=__MAXY_UI_PORT__
25
27
  Environment=WEBSOCKIFY_HOST=127.0.0.1
26
28
  Environment=WEBSOCKIFY_PORT=6080
27
- Environment=TTYD_PORT=__TTYD_PORT__
28
29
  Environment=DISPLAY=:99
29
30
  Environment=MAXY_PLATFORM_ROOT=__INSTALL_DIR__/platform
30
31
  Environment=PATH=%h/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -1,5 +1,4 @@
1
1
  import {
2
- LOG_DIR,
3
2
  canAccessAdmin,
4
3
  describeRemoteSession,
5
4
  newCorrId,
@@ -10,7 +9,7 @@ import {
10
9
 
11
10
  // server/edge.ts
12
11
  import { createServer, request as httpRequest } from "http";
13
- import { createConnection as createConnection3 } from "net";
12
+ import { createConnection as createConnection2 } from "net";
14
13
  import { readFileSync, existsSync, watchFile } from "fs";
15
14
  import { homedir } from "os";
16
15
  import { join } from "path";
@@ -276,358 +275,6 @@ Content-Length: 0\r
276
275
  socket.destroy();
277
276
  }
278
277
 
279
- // server/ws-proxy-ttyd.ts
280
- import { createConnection as createConnection2 } from "net";
281
-
282
- // app/lib/ttyd-logger.ts
283
- import { appendFileSync, mkdirSync } from "fs";
284
- import { resolve } from "path";
285
- var EDGE_LOG_FILE = resolve(LOG_DIR, "edge-boot.log");
286
- try {
287
- mkdirSync(LOG_DIR, { recursive: true });
288
- } catch (err) {
289
- console.error(`[ttyd-log-fail] mkdir ${LOG_DIR} failed: ${err.message}`);
290
- }
291
- function ttydLog(phase, fields = {}) {
292
- const ts = (/* @__PURE__ */ new Date()).toISOString();
293
- const kv = Object.entries(fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ");
294
- const line = kv.length > 0 ? `[${ts}] [${phase}] ${kv}
295
- ` : `[${ts}] [${phase}]
296
- `;
297
- try {
298
- appendFileSync(EDGE_LOG_FILE, line);
299
- } catch (err) {
300
- console.error(`[ttyd-log-fail] ${err.message} \u2014 dropped: ${line.slice(0, 300).trim()}`);
301
- }
302
- }
303
-
304
- // server/ws-proxy-ttyd.ts
305
- var WS_PATH2 = "/ttyd";
306
- var UPSTREAM_WS_PATH = "/ws";
307
- var UPSTREAM_TIMEOUT_MS2 = 5e3;
308
- var FLOW_ACTIVE_INTERVAL_MS = 5e3;
309
- var FLOW_IDLE_INTERVAL_MS = 3e4;
310
- var CHUNK_THROTTLE_MS = 1e3;
311
- var CHUNK_UNTHROTTLED_COUNT = 5;
312
- var HOP_BY_HOP2 = /* @__PURE__ */ new Set([
313
- "connection",
314
- "keep-alive",
315
- "proxy-authenticate",
316
- "proxy-authorization",
317
- "te",
318
- "trailer",
319
- "transfer-encoding",
320
- "upgrade"
321
- ]);
322
- function attachTtydWsProxy(server2, opts) {
323
- const upstreamHost = opts.upstreamHost ?? "127.0.0.1";
324
- const upstreamPort = opts.upstreamPort ?? 7681;
325
- const now = opts.now ?? (() => Date.now());
326
- server2.on("upgrade", (req, clientSocket, head) => {
327
- try {
328
- handleUpgrade2(req, clientSocket, head, {
329
- isPublicHost: opts.isPublicHost,
330
- upstreamHost,
331
- upstreamPort,
332
- now
333
- });
334
- } catch (err) {
335
- ttydLog("ttyd-ws-upgrade", {
336
- decision: "rejected",
337
- reason: "handler-exception",
338
- err: err.message
339
- });
340
- clientSocket.destroy();
341
- }
342
- });
343
- }
344
- function handleUpgrade2(req, clientSocket, head, opts) {
345
- const url = req.url ?? "";
346
- const qsIndex = url.indexOf("?");
347
- const pathname = qsIndex === -1 ? url : url.slice(0, qsIndex);
348
- if (pathname !== WS_PATH2) return;
349
- const corrId = newCorrId();
350
- const query = qsIndex === -1 ? "" : url.slice(qsIndex + 1);
351
- const clientCorrId = sanitizeClientCorrId(parseQueryParam2(query, "corrId"));
352
- const hostHeader = (req.headers.host ?? "").split(":")[0];
353
- const originHeader = headerString2(req.headers.origin);
354
- const remote = req.socket.remoteAddress;
355
- const xff = headerString2(req.headers["x-forwarded-for"]);
356
- const cookieHeader = headerString2(req.headers.cookie);
357
- const decision = canAccessAdmin({
358
- host: hostHeader,
359
- remoteAddress: remote,
360
- xForwardedFor: xff,
361
- cookieHeader,
362
- isPublicHost: opts.isPublicHost
363
- });
364
- if (!decision.allow) {
365
- const status = decision.reason === "public-host" ? 404 : 401;
366
- const rawToken = parseCookieValue(cookieHeader, "__remote_session");
367
- const tokenInfo = describeRemoteSession(rawToken);
368
- ttydLog("ttyd-ws-upgrade", {
369
- corrId,
370
- clientCorrId: clientCorrId ?? null,
371
- decision: "rejected",
372
- reason: decision.reason,
373
- ip: remote,
374
- xff: xff ?? null,
375
- origin: originHeader ?? null,
376
- host: hostHeader,
377
- cookieHeaderPresent: cookieHeader != null && cookieHeader.length > 0,
378
- tokenPresent: tokenInfo.present,
379
- tokenExpired: tokenInfo.expired
380
- });
381
- writeStatusAndDestroy2(clientSocket, status, decision.reason === "public-host" ? "Not Found" : "Unauthorized");
382
- return;
383
- }
384
- const originHost = parseOriginHost2(originHeader);
385
- if (!originHost) {
386
- ttydLog("ttyd-ws-upgrade", {
387
- corrId,
388
- clientCorrId: clientCorrId ?? null,
389
- decision: "rejected",
390
- reason: "origin-missing-or-invalid",
391
- origin: originHeader ?? null,
392
- host: hostHeader,
393
- ip: remote
394
- });
395
- writeStatusAndDestroy2(clientSocket, 403, "Forbidden");
396
- return;
397
- }
398
- if (originHost !== hostHeader) {
399
- ttydLog("ttyd-ws-upgrade", {
400
- corrId,
401
- clientCorrId: clientCorrId ?? null,
402
- decision: "rejected",
403
- reason: "origin-mismatch",
404
- origin_host: originHost,
405
- host: hostHeader,
406
- ip: remote
407
- });
408
- writeStatusAndDestroy2(clientSocket, 403, "Forbidden");
409
- return;
410
- }
411
- if (opts.isPublicHost(originHost)) {
412
- ttydLog("ttyd-ws-upgrade", {
413
- corrId,
414
- clientCorrId: clientCorrId ?? null,
415
- decision: "rejected",
416
- reason: "origin-public-host",
417
- origin_host: originHost,
418
- host: hostHeader,
419
- ip: remote
420
- });
421
- writeStatusAndDestroy2(clientSocket, 403, "Forbidden");
422
- return;
423
- }
424
- ttydLog("ttyd-ws-upgrade", {
425
- corrId,
426
- clientCorrId: clientCorrId ?? null,
427
- decision: "accepted",
428
- ip: remote,
429
- xff: xff ?? null,
430
- origin: originHeader ?? null,
431
- host: hostHeader,
432
- sec_ws_version: headerString2(req.headers["sec-websocket-version"]) ?? null,
433
- sec_ws_protocol: headerString2(req.headers["sec-websocket-protocol"]) ?? null
434
- });
435
- const connectStart = opts.now();
436
- const upstream = createConnection2({ host: opts.upstreamHost, port: opts.upstreamPort });
437
- upstream.setTimeout(UPSTREAM_TIMEOUT_MS2);
438
- let bytesClientToUpstream = 0;
439
- let bytesUpstreamToClient = 0;
440
- let lastFlowBytesClient = 0;
441
- let lastFlowBytesUpstream = 0;
442
- let lastActivityAt = opts.now();
443
- let lastChunkLogAtClient = 0;
444
- let lastChunkLogAtUpstream = 0;
445
- let chunkCountClient = 0;
446
- let chunkCountUpstream = 0;
447
- let closedBy = null;
448
- let proxyOpened = false;
449
- const sessionStart = opts.now();
450
- let flowHandle = null;
451
- let currentFlowInterval = FLOW_ACTIVE_INTERVAL_MS;
452
- const scheduleFlow = (intervalMs) => {
453
- if (flowHandle) clearInterval(flowHandle);
454
- currentFlowInterval = intervalMs;
455
- flowHandle = setInterval(emitFlow, intervalMs);
456
- };
457
- const emitFlow = () => {
458
- if (!proxyOpened || closedBy) return;
459
- const deltaClient = bytesClientToUpstream - lastFlowBytesClient;
460
- const deltaUpstream = bytesUpstreamToClient - lastFlowBytesUpstream;
461
- lastFlowBytesClient = bytesClientToUpstream;
462
- lastFlowBytesUpstream = bytesUpstreamToClient;
463
- const idleMs = opts.now() - lastActivityAt;
464
- ttydLog("ttyd-proxy-flow", {
465
- corrId,
466
- clientBytes: bytesClientToUpstream,
467
- upstreamBytes: bytesUpstreamToClient,
468
- clientBytesDelta: deltaClient,
469
- upstreamBytesDelta: deltaUpstream,
470
- idleMs
471
- });
472
- const active = deltaClient > 0 || deltaUpstream > 0;
473
- const desiredInterval = active ? FLOW_ACTIVE_INTERVAL_MS : FLOW_IDLE_INTERVAL_MS;
474
- if (desiredInterval !== currentFlowInterval) scheduleFlow(desiredInterval);
475
- };
476
- const finish = (side, reason) => {
477
- if (closedBy) return;
478
- closedBy = side;
479
- if (flowHandle) {
480
- clearInterval(flowHandle);
481
- flowHandle = null;
482
- }
483
- if (proxyOpened) {
484
- ttydLog("ttyd-proxy-close", {
485
- corrId,
486
- closedBy: side,
487
- reason,
488
- clientBytes: bytesClientToUpstream,
489
- upstreamBytes: bytesUpstreamToClient,
490
- durationMs: opts.now() - sessionStart
491
- });
492
- }
493
- clientSocket.destroy();
494
- upstream.destroy();
495
- };
496
- upstream.once("connect", () => {
497
- upstream.setTimeout(0);
498
- proxyOpened = true;
499
- ttydLog("ttyd-proxy-open", {
500
- corrId,
501
- upstream: `${opts.upstreamHost}:${opts.upstreamPort}`,
502
- connect_ms: opts.now() - connectStart
503
- });
504
- const lines = [];
505
- lines.push(`${req.method ?? "GET"} ${UPSTREAM_WS_PATH} HTTP/${req.httpVersion}`);
506
- lines.push(`host: ${opts.upstreamHost}:${opts.upstreamPort}`);
507
- for (const [name, value] of Object.entries(req.headers)) {
508
- if (name === "host") continue;
509
- if (HOP_BY_HOP2.has(name)) continue;
510
- if (value == null) continue;
511
- if (Array.isArray(value)) {
512
- for (const v of value) lines.push(`${name}: ${v}`);
513
- } else {
514
- lines.push(`${name}: ${value}`);
515
- }
516
- }
517
- const upgradeHeader = headerString2(req.headers.upgrade);
518
- const connectionHeader = headerString2(req.headers.connection);
519
- if (upgradeHeader) lines.push(`upgrade: ${upgradeHeader}`);
520
- if (connectionHeader) lines.push(`connection: ${connectionHeader}`);
521
- upstream.write(lines.join("\r\n") + "\r\n\r\n");
522
- if (head && head.length > 0) upstream.write(head);
523
- const logChunk = (dir, bytes) => {
524
- const now = opts.now();
525
- if (dir === "client\u2192upstream") {
526
- chunkCountClient += 1;
527
- const sinceLastMs = lastChunkLogAtClient === 0 ? 0 : now - lastChunkLogAtClient;
528
- if (chunkCountClient <= CHUNK_UNTHROTTLED_COUNT || sinceLastMs >= CHUNK_THROTTLE_MS) {
529
- ttydLog("ttyd-proxy-chunk", { corrId, dir, bytes, sinceLastMs });
530
- lastChunkLogAtClient = now;
531
- }
532
- } else {
533
- chunkCountUpstream += 1;
534
- const sinceLastMs = lastChunkLogAtUpstream === 0 ? 0 : now - lastChunkLogAtUpstream;
535
- if (chunkCountUpstream <= CHUNK_UNTHROTTLED_COUNT || sinceLastMs >= CHUNK_THROTTLE_MS) {
536
- ttydLog("ttyd-proxy-chunk", { corrId, dir, bytes, sinceLastMs });
537
- lastChunkLogAtUpstream = now;
538
- }
539
- }
540
- lastActivityAt = now;
541
- };
542
- clientSocket.on("data", (chunk) => {
543
- bytesClientToUpstream += chunk.length;
544
- logChunk("client\u2192upstream", chunk.length);
545
- });
546
- upstream.on("data", (chunk) => {
547
- bytesUpstreamToClient += chunk.length;
548
- logChunk("upstream\u2192client", chunk.length);
549
- });
550
- clientSocket.pipe(upstream);
551
- upstream.pipe(clientSocket);
552
- scheduleFlow(FLOW_ACTIVE_INTERVAL_MS);
553
- clientSocket.once("close", (hadError) => {
554
- finish("client", hadError ? "error" : "normal");
555
- });
556
- upstream.once("close", (hadError) => {
557
- finish("upstream", hadError ? "error" : "normal");
558
- });
559
- clientSocket.once("error", (err) => {
560
- ttydLog("ttyd-proxy-error", { corrId, side: "client", err: err.message });
561
- finish("client", "error");
562
- });
563
- upstream.once("error", (err) => {
564
- ttydLog("ttyd-proxy-error", { corrId, side: "upstream", err: err.message });
565
- finish("upstream", "error");
566
- });
567
- });
568
- upstream.once("timeout", () => {
569
- if (proxyOpened) return;
570
- ttydLog("ttyd-proxy-error", {
571
- corrId,
572
- side: "upstream-connect",
573
- err: "timeout",
574
- timeout_ms: UPSTREAM_TIMEOUT_MS2
575
- });
576
- writeStatusAndDestroy2(clientSocket, 504, "Gateway Timeout");
577
- upstream.destroy();
578
- });
579
- upstream.once("error", (err) => {
580
- if (proxyOpened) return;
581
- ttydLog("ttyd-proxy-error", {
582
- corrId,
583
- side: "upstream-connect",
584
- err: err.message
585
- });
586
- writeStatusAndDestroy2(clientSocket, 502, "Bad Gateway");
587
- upstream.destroy();
588
- });
589
- }
590
- function parseQueryParam2(query, key) {
591
- if (!query) return null;
592
- for (const pair of query.split("&")) {
593
- const eq = pair.indexOf("=");
594
- const k = eq === -1 ? pair : pair.slice(0, eq);
595
- if (k !== key) continue;
596
- const v = eq === -1 ? "" : pair.slice(eq + 1);
597
- try {
598
- return decodeURIComponent(v);
599
- } catch {
600
- return null;
601
- }
602
- }
603
- return null;
604
- }
605
- function headerString2(value) {
606
- if (value == null) return void 0;
607
- return Array.isArray(value) ? value[0] : value;
608
- }
609
- function parseOriginHost2(origin) {
610
- if (!origin) return null;
611
- try {
612
- return new URL(origin).hostname;
613
- } catch {
614
- return null;
615
- }
616
- }
617
- function writeStatusAndDestroy2(socket, status, statusText) {
618
- try {
619
- socket.write(
620
- `HTTP/1.1 ${status} ${statusText}\r
621
- Connection: close\r
622
- Content-Length: 0\r
623
- \r
624
- `
625
- );
626
- } catch {
627
- }
628
- socket.destroy();
629
- }
630
-
631
278
  // server/edge.ts
632
279
  var PLATFORM_ROOT = process.env.MAXY_PLATFORM_ROOT || "";
633
280
  var BRAND_JSON_PATH = PLATFORM_ROOT ? join(PLATFORM_ROOT, "config", "brand.json") : "";
@@ -665,9 +312,7 @@ var UPSTREAM_HOST = process.env.MAXY_UI_HOST ?? "127.0.0.1";
665
312
  var UPSTREAM_PORT = parseInt(process.env.MAXY_UI_PORT ?? "19199", 10);
666
313
  var WEBSOCKIFY_HOST = process.env.WEBSOCKIFY_HOST ?? "127.0.0.1";
667
314
  var WEBSOCKIFY_PORT = parseInt(process.env.WEBSOCKIFY_PORT ?? "6080", 10);
668
- var TTYD_HOST = process.env.TTYD_HOST ?? "127.0.0.1";
669
- var TTYD_PORT = parseInt(process.env.TTYD_PORT ?? "7681", 10);
670
- var HOP_BY_HOP3 = /* @__PURE__ */ new Set([
315
+ var HOP_BY_HOP2 = /* @__PURE__ */ new Set([
671
316
  "connection",
672
317
  "keep-alive",
673
318
  "proxy-authenticate",
@@ -681,7 +326,7 @@ function forwardHttp(clientReq, clientRes) {
681
326
  const headers = {};
682
327
  for (const [name, value] of Object.entries(clientReq.headers)) {
683
328
  if (value == null) continue;
684
- if (HOP_BY_HOP3.has(name)) continue;
329
+ if (HOP_BY_HOP2.has(name)) continue;
685
330
  headers[name] = value;
686
331
  }
687
332
  const existingXff = headers["x-forwarded-for"];
@@ -711,7 +356,7 @@ function forwardHttp(clientReq, clientRes) {
711
356
  clientReq.pipe(upstream);
712
357
  }
713
358
  function forwardUpgrade(req, clientSocket, head) {
714
- const upstream = createConnection3({ host: UPSTREAM_HOST, port: UPSTREAM_PORT });
359
+ const upstream = createConnection2({ host: UPSTREAM_HOST, port: UPSTREAM_PORT });
715
360
  upstream.setTimeout(5e3);
716
361
  upstream.once("connect", () => {
717
362
  upstream.setTimeout(0);
@@ -720,7 +365,7 @@ function forwardUpgrade(req, clientSocket, head) {
720
365
  lines.push(`host: ${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
721
366
  for (const [name, value] of Object.entries(req.headers)) {
722
367
  if (name === "host") continue;
723
- if (HOP_BY_HOP3.has(name)) continue;
368
+ if (HOP_BY_HOP2.has(name)) continue;
724
369
  if (value == null) continue;
725
370
  if (Array.isArray(value)) {
726
371
  for (const v of value) lines.push(`${name}: ${v}`);
@@ -771,22 +416,15 @@ attachVncWsProxy(server, {
771
416
  upstreamHost: WEBSOCKIFY_HOST,
772
417
  upstreamPort: WEBSOCKIFY_PORT
773
418
  });
774
- attachTtydWsProxy(server, {
775
- isPublicHost,
776
- upstreamHost: TTYD_HOST,
777
- upstreamPort: TTYD_PORT
778
- });
779
419
  server.on("upgrade", (req, socket, head) => {
780
420
  const url = req.url ?? "";
781
421
  const qsIndex = url.indexOf("?");
782
422
  const pathname = qsIndex === -1 ? url : url.slice(0, qsIndex);
783
423
  if (pathname === "/websockify") return;
784
- if (pathname === "/ttyd") return;
785
424
  forwardUpgrade(req, socket, head);
786
425
  });
787
426
  server.listen(EDGE_PORT, EDGE_HOSTNAME, () => {
788
427
  console.log(`[edge] listening on http://${EDGE_HOSTNAME}:${EDGE_PORT}`);
789
428
  console.log(`[edge] /websockify \u2192 ${WEBSOCKIFY_HOST}:${WEBSOCKIFY_PORT}`);
790
- console.log(`[edge] /ttyd \u2192 ${TTYD_HOST}:${TTYD_PORT}`);
791
429
  console.log(`[edge] everything else \u2192 ${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
792
430
  });