@rubytech/create-maxy 1.0.685 → 1.0.686
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/dist/index.js +23 -215
- package/dist/pinned-binaries.js +10 -41
- package/dist/uninstall.js +23 -23
- package/package.json +1 -1
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +35 -9
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +13 -10
- package/payload/platform/plugins/docs/references/graph.md +38 -0
- package/payload/platform/plugins/docs/references/platform.md +10 -7
- package/payload/platform/plugins/docs/references/troubleshooting.md +23 -13
- package/payload/platform/scripts/vnc.sh +7 -7
- package/payload/platform/templates/systemd/edge.service.template +5 -4
- package/payload/server/maxy-edge.js +5 -367
- package/payload/server/public/assets/admin-BqLtaMVu.js +352 -0
- package/payload/server/public/assets/{data-DUSyrydY.js → data-BZ7v-zug.js} +1 -1
- package/payload/server/public/assets/{file-CDJ6dUV3.js → file-CScYkZq5.js} +1 -1
- package/payload/server/public/assets/graph-tjXdtwk-.js +50 -0
- package/payload/server/public/assets/{house-CNP_bwvT.js → house-CdFRNujU.js} +1 -1
- package/payload/server/public/assets/{jsx-runtime-BFFQvkdQ.css → jsx-runtime-Og0q7dXg.css} +1 -1
- package/payload/server/public/assets/{public-sHoAccvb.js → public-CrkQJek6.js} +2 -2
- package/payload/server/public/assets/{share-2-DBcb9j6E.js → share-2-Ev-D4Lm9.js} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-CtSgpc95.js → useVoiceRecorder-DyDXH7EA.js} +2 -2
- package/payload/server/public/assets/{x-CTVJaC_u.js → x-D5W7ddgP.js} +1 -1
- package/payload/server/public/data.html +6 -6
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +7 -8
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +830 -258
- package/payload/platform/templates/dotfiles/.tmux.conf +0 -1
- package/payload/platform/templates/systemd/ttyd.service.template +0 -30
- package/payload/server/public/assets/admin-BFmYXz1V.js +0 -362
- package/payload/server/public/assets/admin-kHJ-D0s7.css +0 -1
- package/payload/server/public/assets/graph-CWcYp5bE.js +0 -50
- /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
|
-
##
|
|
65
|
+
## Software Update and Cloudflare Setup
|
|
66
66
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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 directly — per-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
|
-
**
|
|
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
|
-
##
|
|
96
|
+
## Action runner — upgrade or Cloudflare setup appears stuck
|
|
97
97
|
|
|
98
|
-
|
|
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
|
-
**
|
|
100
|
+
**Heartbeat stalled** (log panel header shows rising `silent Ns` amber badge).
|
|
101
101
|
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
-
|
|
110
|
-
-
|
|
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 URL → cloudflared output-format drift; file a task with the last 20 lines of the action log.
|
|
112
122
|
|
|
113
|
-
**
|
|
123
|
+
**Log file missing (action stream returns 404).**
|
|
114
124
|
|
|
115
|
-
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
189
|
-
# TerminalOverlay
|
|
190
|
-
#
|
|
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
|
|
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
|
|
12
|
-
#
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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
|
});
|