@mason-data/cli 0.8.0 → 0.10.0
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/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason ui --replica <org>/<name>` — A HOSTED REPLICA'S WEB PAGE, IN THE BROWSER OF THE ORGANISATION'S OWNER
|
|
3
|
+
* (docs/HOSTED-API.md "A replica's page"). ONE request, `POST /v1/replicas/{org}/{name}/ui`, whose answer is a one-use,
|
|
4
|
+
* short-lived address of the replica's page (on the service's origin, or on https at a subdomain of its host), read-only
|
|
5
|
+
* (the service refuses every write through it: changes go by `mason user … --replica`, which it audits); the CLI opens it as `mason ui` opens a run's
|
|
6
|
+
* page and `mason login` its sign-in page (`openBrowser`), or prints it (`--no-open`, `--json`, a browser that would not
|
|
7
|
+
* start). Who may ask (the owner's login alone), and the audit row of each page opened, are the service's: the CLI cannot
|
|
8
|
+
* write that row and never says it did.
|
|
9
|
+
*
|
|
10
|
+
* Every value of the answer, and how it is held (src/cli/hosted.ts's header: a service's value is data, never structure):
|
|
11
|
+
*
|
|
12
|
+
* value where it is printed what holds it
|
|
13
|
+
* every string, keys too nowhere until held `carriesNoSecret`: an answer carrying a secret is refused whole
|
|
14
|
+
* url opened; printed where it was not `pageOrigin`, `pageUrl`: the service's origin or https at a
|
|
15
|
+
* subdomain of its host, its port; no user, at most 2048, carries
|
|
16
|
+
* opened, and in --json no secret as printed; then `OPENABLE`: letters, digits and
|
|
17
|
+
* `. : [ ] - _ ~ / ? = +` alone, so no shell reads it
|
|
18
|
+
* expiresAt a row, --json `isServiceTime`
|
|
19
|
+
* a failure's words stderr's failure, --json error `serviceFailure`: one line each, masked; the code by the status
|
|
20
|
+
* a failure's next nowhere: never read the step is `failureNext`'s, by the status
|
|
21
|
+
* Retry-After a next step's words `retryAfterOf`: 1 to 3600 seconds
|
|
22
|
+
*
|
|
23
|
+
* THE ADDRESS IS PRINTED ONLY WHERE THE PERSON NEEDS IT: opened in the browser, it is not printed (the browser used it,
|
|
24
|
+
* and it opens the page once); printed when it was not opened, and in `--json` for the agent that passes it on. It is
|
|
25
|
+
* never logged: this command writes no log. A 409 or a 503 (a replica not ready, its machine not connected) is said
|
|
26
|
+
* with the next step, never waited on: the person runs the command again.
|
|
27
|
+
*/
|
|
28
|
+
import { failed, jsonText, reportText, serviceStyle } from "./command.js";
|
|
29
|
+
import { environmentToken, TOKEN_ENV } from "./credentials.js";
|
|
30
|
+
import { call, carriesNoSecret, codeOfStatus, isFailure, isServiceTime, pageUrl, serviceFailure, serviceOf, unexpected } from "./hosted.js";
|
|
31
|
+
import { holdOutput } from "./output-gate.js";
|
|
32
|
+
import { replicaText, tokenFor } from "./replica.js";
|
|
33
|
+
import { err as styleErr, out as styleOut } from "./style.js";
|
|
34
|
+
import { OPENABLE, openBrowser, UI } from "./ui.js";
|
|
35
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
36
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
37
|
+
/** The request: the replica's page, asked of the service. Both parts are `REPLICA_PART`'s, safe in a path as they are. */
|
|
38
|
+
export const pagePath = (r) => `/v1/replicas/${r.org}/${r.name}/ui`;
|
|
39
|
+
const STEP = "a replica's page was asked for";
|
|
40
|
+
/**
|
|
41
|
+
* THE ORIGIN A REPLICA'S PAGE MAY BE ON: the service's own, or an `https:` origin whose host is a subdomain of the
|
|
42
|
+
* service's host, on the service's port (a page of its own origin keeps the service's session cookie and its routes
|
|
43
|
+
* apart from Mason's page: `r-abc.ui.app.example` under `app.example`). A subdomain by its labels, never a suffix of the
|
|
44
|
+
* text (`evilapp.example` is not under `app.example`), and none under an IP address. The origin the page is held to,
|
|
45
|
+
* or null when `v` is on none of them (or is no URL).
|
|
46
|
+
*/
|
|
47
|
+
export function pageOrigin(service, v) {
|
|
48
|
+
if (typeof v !== "string")
|
|
49
|
+
return null;
|
|
50
|
+
let u, base;
|
|
51
|
+
try {
|
|
52
|
+
u = new URL(v);
|
|
53
|
+
base = new URL(service.base);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (u.origin === service.base)
|
|
59
|
+
return service;
|
|
60
|
+
const ip = /^[\d.]+$/.test(base.hostname) || base.hostname.startsWith("[");
|
|
61
|
+
if (u.protocol !== "https:" || u.port !== base.port || ip || !u.hostname.endsWith(`.${base.hostname}`))
|
|
62
|
+
return null;
|
|
63
|
+
return { base: u.origin };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `POST …/ui`'s answer (200), held: every string of it, each key included, put to the exact test first
|
|
67
|
+
* (`carriesNoSecret`), then the address on an origin it may be on (`pageOrigin`; refused naming the origin when it is
|
|
68
|
+
* not), held there by `pageUrl` (no user or password, at most 2048, no secret as printed), of the characters a browser
|
|
69
|
+
* is handed (`OPENABLE`), and the time to its grammar. The page, or a Failure said by the status alone (or naming the
|
|
70
|
+
* other origin).
|
|
71
|
+
*/
|
|
72
|
+
export function heldPage(service, a, secrets) {
|
|
73
|
+
const j = a.json;
|
|
74
|
+
if (!obj(j) || !carriesNoSecret(j, secrets))
|
|
75
|
+
return unexpected(service, a, STEP);
|
|
76
|
+
const origin = pageOrigin(service, j.url);
|
|
77
|
+
// on an origin it may be on, `pageUrl` holds it as on the service's own; elsewhere, `pageUrl` says the origin safely
|
|
78
|
+
const page = pageUrl(origin ?? service, j.url, secrets);
|
|
79
|
+
if (page !== null && "elsewhere" in page) {
|
|
80
|
+
return { message: `${service.base} named a replica's page on another address: ${page.elsewhere}`, code: "refused",
|
|
81
|
+
why: `a replica's page is opened only on the service's own address, ${service.base}, or on https at a subdomain of its host: anything else would send you to an address nobody chose`,
|
|
82
|
+
fix: "check MASON_CLOUD_URL names the service you mean; if it does, the service is answering outside its documented API" };
|
|
83
|
+
}
|
|
84
|
+
if (page === null || !OPENABLE.test(page.href) || !isServiceTime(j.expiresAt))
|
|
85
|
+
return unexpected(service, a, STEP);
|
|
86
|
+
return { url: page.href, expiresAt: j.expiresAt };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* WHAT TO RUN AFTER THE SERVICE REFUSED: the CLI's own, by the status (never the service's `next`). A 401 or 403 is a
|
|
90
|
+
* login that may not (only the organisation's owner opens a replica's page; a CI token never does), a 404 a replica the
|
|
91
|
+
* login does not see, a 409 or 503 a replica not ready or not connected: its state, and the command again when the
|
|
92
|
+
* service's `Retry-After` says.
|
|
93
|
+
*/
|
|
94
|
+
export function failureNext(status, r, retryAfter, env = process.env) {
|
|
95
|
+
const named = replicaText(r);
|
|
96
|
+
if (status === 401 || status === 403) {
|
|
97
|
+
return environmentToken(env) !== null
|
|
98
|
+
? [[`env -u ${TOKEN_ENV} mason ui --replica ${named}`, `with your own login, not ${TOKEN_ENV}: a CI token never opens a page`]]
|
|
99
|
+
: [["mason login", "sign in as the organisation's owner: only an owner opens a replica's page"]];
|
|
100
|
+
}
|
|
101
|
+
if (status === 404)
|
|
102
|
+
return [[`mason replica list ${r.org}`, "the replicas this login sees"]];
|
|
103
|
+
if (status === 409 || status === 503) {
|
|
104
|
+
return [[`mason replica status ${named}`, "whether it is ready"],
|
|
105
|
+
[`mason ui --replica ${named}`, retryAfter !== null ? `again in ${retryAfter} s, as the service asks` : "again in a moment"]];
|
|
106
|
+
}
|
|
107
|
+
return [[`mason ui --replica ${named}`, "again in a moment"]];
|
|
108
|
+
}
|
|
109
|
+
/** A refusal said as the service worded it; a 409 or 503 it sent without its words is said in the CLI's. */
|
|
110
|
+
export function refusalOf(service, a, r, secrets, env = process.env) {
|
|
111
|
+
const next = failureNext(a.status, r, a.retryAfter, env);
|
|
112
|
+
const notReady = a.status === 409 || a.status === 503;
|
|
113
|
+
const again = a.retryAfter !== null ? `try again in ${a.retryAfter} s` : "try again in a moment";
|
|
114
|
+
const said = serviceFailure(a, secrets, () => next);
|
|
115
|
+
if (said !== null)
|
|
116
|
+
return notReady && said.fix === undefined ? { ...said, fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready` } : said;
|
|
117
|
+
if (notReady) {
|
|
118
|
+
return { message: `the replica ${replicaText(r)} is not ready to open its page (HTTP ${a.status})`, code: codeOfStatus(a.status),
|
|
119
|
+
why: a.status === 409 ? "it is not ready yet: being made, deleted, or failed" : "its machine is not connected to the service now",
|
|
120
|
+
fix: `${again}; mason replica status ${replicaText(r)} says whether it is ready`, next };
|
|
121
|
+
}
|
|
122
|
+
return unexpected(service, a, STEP);
|
|
123
|
+
}
|
|
124
|
+
/** `mason ui --replica <org>/<name>`: the page asked for, then opened, printed, or said as --json. */
|
|
125
|
+
export async function hostedUi(r, o) {
|
|
126
|
+
const fail = (f) => failed("ui", f, { json: o.json, style: serviceStyle(styleErr()) });
|
|
127
|
+
const service = serviceOf();
|
|
128
|
+
if (isFailure(service))
|
|
129
|
+
return fail(service);
|
|
130
|
+
const token = tokenFor(service, { asked: "a replica's page is asked for", again: `mason ui --replica ${replicaText(r)}` });
|
|
131
|
+
if (isFailure(token))
|
|
132
|
+
return fail(token);
|
|
133
|
+
// from here every byte written is put to the exact test against the token (src/cli/output-gate.ts)
|
|
134
|
+
holdOutput({ command: "ui", json: o.json, secrets: [token] });
|
|
135
|
+
const secrets = [token];
|
|
136
|
+
const a = await call(service, "POST", pagePath(r), { token });
|
|
137
|
+
if (isFailure(a))
|
|
138
|
+
return fail(a);
|
|
139
|
+
if (a.status !== 200)
|
|
140
|
+
return fail(refusalOf(service, a, r, secrets));
|
|
141
|
+
const page = heldPage(service, a, secrets);
|
|
142
|
+
if (isFailure(page))
|
|
143
|
+
return fail(page);
|
|
144
|
+
const named = replicaText(r);
|
|
145
|
+
const next = [[`mason replica status ${named}`, "the replica's state, in this terminal"], [`mason ui --replica ${named}`, "a new address: each opens the page once"]];
|
|
146
|
+
if (o.json) {
|
|
147
|
+
out(jsonText(UI, { url: page.url, replica: named, expiresAt: page.expiresAt }, next));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const couldNot = o.open ? await openBrowser(page.url) : null;
|
|
151
|
+
const opened = o.open && couldNot === null;
|
|
152
|
+
out(reportText({
|
|
153
|
+
headline: opened ? `The page of the replica ${named}, opened in your browser` : `The page of the replica ${named}`,
|
|
154
|
+
// opened, the address is not printed: the browser used it, and it opens the page once
|
|
155
|
+
details: [...(opened ? [] : [["url", page.url]]), ["replica", named], ["expires", page.expiresAt]],
|
|
156
|
+
tips: [
|
|
157
|
+
...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
|
|
158
|
+
opened ? "The address opens the page once: for another tab, run the command again." : "The URL opens the page once, for you alone, until it expires: paste it nowhere else.",
|
|
159
|
+
],
|
|
160
|
+
next,
|
|
161
|
+
}, serviceStyle(styleOut())));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason ui --ssh <user@server>`: A SERVER'S WEB PAGE IN THE DEVELOPER'S BROWSER, WITH NOTHING PUBLIC (docs/WEB-UI.md "On
|
|
3
|
+
* a server"). Two processes, one on each end of the machine's own `ssh` (never a library: `~/.ssh/config`, keys, SSO,
|
|
4
|
+
* hardware tokens and a cloud's private route are then the person's, and Mason knows none of them):
|
|
5
|
+
*
|
|
6
|
+
* browser ── 127.0.0.1:<free port> ── mason ui --ssh ══ ssh, its stdin and stdout ══ mason ui --bridge ── the run's page
|
|
7
|
+
* on the laptop on the server, or in its container
|
|
8
|
+
*
|
|
9
|
+
* THE BRIDGE RUNS WHERE THE RUN RUNS, AND THE TUNNEL IS ITS STDIO, NOT `ssh -L`. The page listens on loopback alone, by
|
|
10
|
+
* construction (src/cli/run-server.ts `listenOnLoopback`), and a container's loopback is not its host's: `-L` from the
|
|
11
|
+
* host reaches a port Docker publishes, and Docker publishes to the container's own network address, so `-L` would need
|
|
12
|
+
* the page off loopback. A process inside the container reaches it as it is, so ONE route serves a host and a container,
|
|
13
|
+
* and needs no `AllowTcpForwarding` on the server and no port published or even known.
|
|
14
|
+
*
|
|
15
|
+
* ONE SSH CONNECTION CARRIES EVERY BROWSER CONNECTION (`FrameLink`): a page opens six at once, and a connection per one
|
|
16
|
+
* would mean a touch of a hardware token per one. After the hello line the two ends speak frames: 1 byte type, 4 bytes
|
|
17
|
+
* connection, 4 bytes length, then that many bytes.
|
|
18
|
+
*
|
|
19
|
+
* THE TOKEN STAYS ON THE SERVER: the bridge reads it from the run's config, as every command does, opens the tunnel
|
|
20
|
+
* with it (`POST /api/ui/tunnel`, its connection IS the tunnel) and sends the laptop a ticket (src/cli/security.ts). The
|
|
21
|
+
* hello line is what crosses: nothing secret is on a command line, in the laptop's output or in a log.
|
|
22
|
+
*
|
|
23
|
+
* THE BRIDGE ENDS A BOUND AFTER ITS STDIN DOES, WHATEVER ITS MAIN THREAD IS DOING: stdin is read on a worker thread
|
|
24
|
+
* (src/cli/bridge-stdin.ts), whose own event loop hears the end and ends the process `BRIDGE_EXIT_MS` later if it is
|
|
25
|
+
* still there, so a blocked main loop cannot keep the run's tunnel open once the laptop's ssh is gone.
|
|
26
|
+
*
|
|
27
|
+
* WHAT `ssh` PRINTS IS NOT OURS: stdout is scanned for the hello line, as a server's shell may print before it; stderr
|
|
28
|
+
* is passed on as it comes, because a person may be asked for a passphrase or a touch there.
|
|
29
|
+
*/
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import * as net from "node:net";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { Worker } from "node:worker_threads";
|
|
34
|
+
import { runnable } from "../lib/runnable.js";
|
|
35
|
+
import { errorJson, failureCode, nextJson } from "./command.js";
|
|
36
|
+
import { holdRun, RunUnreachable } from "./control.js";
|
|
37
|
+
import { whereIs } from "./state.js";
|
|
38
|
+
import { lastLines } from "./tunnel.js";
|
|
39
|
+
import { version } from "./version.js";
|
|
40
|
+
/** What the laptop looks for on the bridge's stdout: this, a space, then one line of JSON. */
|
|
41
|
+
export const HELLO = "MASON-UI-BRIDGE";
|
|
42
|
+
/** The frames' version: two ends of another say so, whatever their Mason versions. */
|
|
43
|
+
export const BRIDGE_PROTOCOL = 1;
|
|
44
|
+
const OPEN = 1, DATA = 2, CLOSE = 3;
|
|
45
|
+
const HEADER = 9;
|
|
46
|
+
/** No end sends more than a socket's read at once: a length past this is not a frame, it is a broken stream. */
|
|
47
|
+
const MAX_FRAME = 1024 * 1024;
|
|
48
|
+
const STOP_GRACE_MS = 2_000;
|
|
49
|
+
/** How long after ssh exits the end is read: a Ctrl-C reaches ssh and this process together. */
|
|
50
|
+
const SETTLE_MS = 200;
|
|
51
|
+
/** Why a `--ssh` target cannot be given to ssh, or null: a target opening with `-` would be read as ssh's own option. */
|
|
52
|
+
export function targetProblem(target) {
|
|
53
|
+
return /^[A-Za-z0-9_][A-Za-z0-9._@%+:[\]-]*$/.test(target) ? null : `${JSON.stringify(target)} is not user@server or a Host of your ssh config`;
|
|
54
|
+
}
|
|
55
|
+
/** The server-side command: `mason`, as `--mason` says to run it there. */
|
|
56
|
+
export const bridgeCommand = (mason, localPort) => `${mason} ui --bridge --local-port ${localPort}`;
|
|
57
|
+
/**
|
|
58
|
+
* THE `ssh` LINE. No secret in it: `ps` shows it. `BatchMode` where nobody can answer a prompt, so ssh fails rather
|
|
59
|
+
* than waits. A shared connection (`ControlPath`) is left out: a master that persists would be an ssh process left
|
|
60
|
+
* behind by Ctrl-C. Forwards from the person's config are cleared: a tunnel of ours must not fail on theirs.
|
|
61
|
+
*/
|
|
62
|
+
export function sshArgs(target, remote, interactive) {
|
|
63
|
+
const options = ["ConnectTimeout=15", "ServerAliveInterval=15", "ServerAliveCountMax=3", "ControlMaster=no", "ControlPath=none", "ClearAllForwardings=yes", "ForwardAgent=no", "ForwardX11=no",
|
|
64
|
+
...(interactive ? [] : ["BatchMode=yes"])];
|
|
65
|
+
return ["-T", "-e", "none", ...options.flatMap((o) => ["-o", o]), "--", target, remote];
|
|
66
|
+
}
|
|
67
|
+
function frame(type, id, payload = Buffer.alloc(0)) {
|
|
68
|
+
const head = Buffer.alloc(HEADER);
|
|
69
|
+
head.writeUInt8(type, 0);
|
|
70
|
+
head.writeUInt32BE(id, 1);
|
|
71
|
+
head.writeUInt32BE(payload.length, 5);
|
|
72
|
+
return Buffer.concat([head, payload]);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* ONE END OF THE STREAM: many sockets over one pipe each way. `connect` is the bridge's, which dials the page when the
|
|
76
|
+
* laptop opens a connection; the laptop has none and calls `open`. Backpressure is the pipe's: a socket that cannot take
|
|
77
|
+
* more pauses the pipe, and a pipe that cannot take more pauses the sockets, so memory stays bounded and a browser's
|
|
78
|
+
* slow tab can hold the others back a moment, which a page of small JSON answers does not notice.
|
|
79
|
+
*/
|
|
80
|
+
export class FrameLink {
|
|
81
|
+
sockets = new Map();
|
|
82
|
+
input;
|
|
83
|
+
output;
|
|
84
|
+
connect;
|
|
85
|
+
broken;
|
|
86
|
+
kept = Buffer.alloc(0);
|
|
87
|
+
next = 1;
|
|
88
|
+
waiting = false;
|
|
89
|
+
constructor(o) {
|
|
90
|
+
this.input = o.input;
|
|
91
|
+
this.output = o.output;
|
|
92
|
+
this.connect = o.connect ?? null;
|
|
93
|
+
this.broken = o.broken;
|
|
94
|
+
}
|
|
95
|
+
/** A connection from this end: the laptop's, one per browser connection. */
|
|
96
|
+
open(socket) {
|
|
97
|
+
const id = this.next++;
|
|
98
|
+
this.send(frame(OPEN, id));
|
|
99
|
+
this.attach(id, socket);
|
|
100
|
+
}
|
|
101
|
+
/** Bytes from the pipe. */
|
|
102
|
+
read(chunk) {
|
|
103
|
+
this.kept = this.kept.length === 0 ? chunk : Buffer.concat([this.kept, chunk]);
|
|
104
|
+
while (this.kept.length >= HEADER) {
|
|
105
|
+
const type = this.kept.readUInt8(0), id = this.kept.readUInt32BE(1), length = this.kept.readUInt32BE(5);
|
|
106
|
+
if (length > MAX_FRAME || type < OPEN || type > CLOSE) {
|
|
107
|
+
this.broken(`a frame of type ${type} and ${length} bytes: this is not the bridge's stream`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.kept.length < HEADER + length)
|
|
111
|
+
return;
|
|
112
|
+
this.frameIn(type, id, this.kept.subarray(HEADER, HEADER + length));
|
|
113
|
+
this.kept = this.kept.subarray(HEADER + length);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Every connection dropped: the tunnel is gone. */
|
|
117
|
+
end() { for (const s of [...this.sockets.values()])
|
|
118
|
+
s.destroy(); this.sockets.clear(); }
|
|
119
|
+
frameIn(type, id, payload) {
|
|
120
|
+
if (type === OPEN) {
|
|
121
|
+
if (this.connect !== null)
|
|
122
|
+
this.attach(id, this.connect());
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const socket = this.sockets.get(id);
|
|
126
|
+
// the other end closed it while this frame crossed
|
|
127
|
+
if (socket === undefined)
|
|
128
|
+
return;
|
|
129
|
+
if (type === CLOSE) {
|
|
130
|
+
this.sockets.delete(id);
|
|
131
|
+
socket.destroySoon();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (socket.write(Buffer.from(payload)))
|
|
135
|
+
return;
|
|
136
|
+
this.input.pause();
|
|
137
|
+
const go = () => { socket.off("drain", go); socket.off("close", go); this.input.resume(); };
|
|
138
|
+
socket.once("drain", go);
|
|
139
|
+
socket.once("close", go);
|
|
140
|
+
}
|
|
141
|
+
attach(id, socket) {
|
|
142
|
+
this.sockets.set(id, socket);
|
|
143
|
+
socket.on("data", (b) => { this.send(frame(DATA, id, b)); });
|
|
144
|
+
// a browser or the page dropping a connection is an ordinary end, said to the other side by the close below
|
|
145
|
+
socket.on("error", () => undefined);
|
|
146
|
+
socket.on("close", () => { if (this.sockets.delete(id))
|
|
147
|
+
this.send(frame(CLOSE, id)); });
|
|
148
|
+
}
|
|
149
|
+
send(b) {
|
|
150
|
+
if (this.output.write(b) || this.waiting)
|
|
151
|
+
return;
|
|
152
|
+
this.waiting = true;
|
|
153
|
+
for (const s of this.sockets.values())
|
|
154
|
+
s.pause();
|
|
155
|
+
this.output.once("drain", () => { this.waiting = false; for (const s of this.sockets.values())
|
|
156
|
+
s.resume(); });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ------------------------------------------------------------------ the server's end: mason ui --bridge
|
|
160
|
+
/**
|
|
161
|
+
* How long the bridge may take to end by itself once its stdin has ended, before its watchdog ends it (SIGKILL), and
|
|
162
|
+
* how long its main thread may be silent with stdin's window full before it is taken for stuck. An ordinary end takes
|
|
163
|
+
* milliseconds: the tunnel's hold closed, its sockets destroyed, the output flushed or found gone.
|
|
164
|
+
*/
|
|
165
|
+
export const BRIDGE_EXIT_MS = 5_000;
|
|
166
|
+
/**
|
|
167
|
+
* What the stdin thread hands the main thread before it is acknowledged: the most of the laptop's bytes the bridge
|
|
168
|
+
* holds between the pipe and FrameLink, whatever its main thread does. Past it the pipe fills and the laptop waits.
|
|
169
|
+
*/
|
|
170
|
+
export const STDIN_WINDOW_BYTES = 1024 * 1024;
|
|
171
|
+
/** How often the main thread tells the stdin thread it is alive: well inside BRIDGE_EXIT_MS. */
|
|
172
|
+
const BEAT_MS = 1_000;
|
|
173
|
+
/**
|
|
174
|
+
* The bridge's stdin, as its thread forwards it (src/cli/bridge-stdin.ts), by credit: each chunk is acknowledged once
|
|
175
|
+
* FrameLink has read it and is not paused, so the thread's window is what has not reached a page's socket yet. Started
|
|
176
|
+
* first, so the watchdog bounds the whole bridge, the run's lookup included; what arrives before `attach` is kept for
|
|
177
|
+
* it, unacknowledged, so that too is inside the window.
|
|
178
|
+
*/
|
|
179
|
+
class StdinByWorker {
|
|
180
|
+
worker;
|
|
181
|
+
kept = [];
|
|
182
|
+
end = null;
|
|
183
|
+
read = null;
|
|
184
|
+
ended = null;
|
|
185
|
+
paused = false;
|
|
186
|
+
/** Bytes read while paused: acknowledged on resume. */
|
|
187
|
+
unacked = 0;
|
|
188
|
+
broken = false;
|
|
189
|
+
constructor() {
|
|
190
|
+
// execArgv [] as the other workers here: the main thread's flags (a test's --import) are not the reader's
|
|
191
|
+
this.worker = new Worker(runnable(fileURLToPath(new URL("./bridge-stdin.ts", import.meta.url))), { workerData: { exitMs: BRIDGE_EXIT_MS, windowBytes: STDIN_WINDOW_BYTES }, execArgv: [], argv: [] });
|
|
192
|
+
// the process ends when the bridge does: the thread never holds it, and dies with it
|
|
193
|
+
this.worker.unref();
|
|
194
|
+
this.worker.on("message", (m) => {
|
|
195
|
+
if ("data" in m) {
|
|
196
|
+
if (this.broken)
|
|
197
|
+
return;
|
|
198
|
+
const b = Buffer.from(m.data.buffer, m.data.byteOffset, m.data.byteLength);
|
|
199
|
+
if (this.read === null)
|
|
200
|
+
this.kept.push(b);
|
|
201
|
+
else
|
|
202
|
+
this.consume(b);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if ("dropped" in m) {
|
|
206
|
+
// the thread took this one for stuck and read on past what it handed over: the stream has a hole, never read on
|
|
207
|
+
this.broken = true;
|
|
208
|
+
this.over(1, `its main thread was stuck for ${BRIDGE_EXIT_MS / 1000} s with ${m.dropped} bytes unread, and what the laptop sent since was dropped: the tunnel is broken, run mason ui --ssh again`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.over(m.failed ? 1 : 0, m.failed ? m.end : null);
|
|
212
|
+
});
|
|
213
|
+
// a stdin nobody reads is a bridge that cannot work: said, and the bridge ends, never left without its end
|
|
214
|
+
this.worker.on("error", (e) => { this.over(1, `its stdin cannot be read: ${e.message}`); });
|
|
215
|
+
this.worker.on("exit", (code) => { this.over(1, `its stdin's reader stopped (exit ${code})`); });
|
|
216
|
+
// a main thread that is waiting, not stuck, says so; one that is stuck cannot
|
|
217
|
+
setInterval(() => { this.worker.postMessage({ beat: true }); }, BEAT_MS).unref();
|
|
218
|
+
}
|
|
219
|
+
/** From now on, bytes to `read` and the end to `ended`: what came before first. */
|
|
220
|
+
attach(read, ended) {
|
|
221
|
+
this.read = read;
|
|
222
|
+
for (const b of this.kept)
|
|
223
|
+
this.consume(b);
|
|
224
|
+
this.kept = [];
|
|
225
|
+
this.ended = ended;
|
|
226
|
+
if (this.end !== null)
|
|
227
|
+
this.say(this.end);
|
|
228
|
+
}
|
|
229
|
+
/** FrameLink's backpressure: a page's socket is full, so what is read now is acknowledged only when it drains. */
|
|
230
|
+
pause() { this.paused = true; }
|
|
231
|
+
resume() {
|
|
232
|
+
this.paused = false;
|
|
233
|
+
if (this.unacked > 0) {
|
|
234
|
+
this.worker.postMessage({ ack: this.unacked });
|
|
235
|
+
this.unacked = 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
consume(b) {
|
|
239
|
+
this.read?.(b);
|
|
240
|
+
if (this.paused)
|
|
241
|
+
this.unacked += b.length;
|
|
242
|
+
else
|
|
243
|
+
this.worker.postMessage({ ack: b.length });
|
|
244
|
+
}
|
|
245
|
+
over(code, why) {
|
|
246
|
+
if (this.end !== null)
|
|
247
|
+
return;
|
|
248
|
+
this.end = { code, why };
|
|
249
|
+
if (this.ended !== null)
|
|
250
|
+
this.say(this.end);
|
|
251
|
+
}
|
|
252
|
+
say(e) {
|
|
253
|
+
if (e.why !== null)
|
|
254
|
+
process.stderr.write(`mason ui --bridge: ${e.why}\n`);
|
|
255
|
+
this.ended?.(e.code);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* `mason ui --bridge`: the hello line, then the frames, until the laptop's end goes (stdin closes), or the run ends the
|
|
260
|
+
* tunnel. A failure is the hello, in the shape the laptop says; what it prints on stderr reaches the laptop through ssh.
|
|
261
|
+
*/
|
|
262
|
+
export async function bridge(localPort) {
|
|
263
|
+
const stdin = new StdinByWorker();
|
|
264
|
+
const hello = (h) => { process.stdout.write(`${HELLO} ${JSON.stringify(h)}\n`); };
|
|
265
|
+
const refuse = (f) => { hello({ protocol: BRIDGE_PROTOCOL, version: version(), error: errorJson(f), next: nextJson(f.next) }); return 1; };
|
|
266
|
+
const w = await whereIs();
|
|
267
|
+
if (w.state !== "up")
|
|
268
|
+
return refuse({ ...w.problem, message: w.problem.problem });
|
|
269
|
+
const { run } = w;
|
|
270
|
+
let held;
|
|
271
|
+
try {
|
|
272
|
+
held = await holdRun(run, "/api/ui/tunnel", { port: localPort });
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
if (e instanceof RunUnreachable)
|
|
276
|
+
return refuse({ message: e.message, code: "unreachable", next: [["mason logs", "the run's log"]] });
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
if (!("first" in held)) {
|
|
280
|
+
if (held.status === 404) {
|
|
281
|
+
return refuse({ message: `Mason for ${run.name} serves no web page through a tunnel`, code: "refused", why: "the run was started by a Mason from before mason ui --ssh",
|
|
282
|
+
fix: "restart it on this Mason's version: mason serve's page needs no other setting" });
|
|
283
|
+
}
|
|
284
|
+
const f = held.body.failure;
|
|
285
|
+
return refuse(f ?? { message: String(held.body.error ?? `HTTP ${held.status}`), code: "failed" });
|
|
286
|
+
}
|
|
287
|
+
const { first, ended, close } = held;
|
|
288
|
+
hello({ protocol: BRIDGE_PROTOCOL, version: version(), project: run.name, ticket: String(first.ticket) });
|
|
289
|
+
const page = new URL(run.control.url);
|
|
290
|
+
return await new Promise((resolve) => {
|
|
291
|
+
let over = false;
|
|
292
|
+
const done = (code) => { over = true; close(); link.end(); resolve(code); };
|
|
293
|
+
const link = new FrameLink({ input: stdin, output: process.stdout, connect: () => net.connect(Number(page.port), "127.0.0.1"),
|
|
294
|
+
broken: (why) => { process.stderr.write(`mason ui --bridge: ${why}\n`); done(1); } });
|
|
295
|
+
stdin.attach((b) => { link.read(b); }, (code) => { done(code); });
|
|
296
|
+
// the laptop's end gone with bytes still to write: the same end as stdin's, which says it
|
|
297
|
+
process.stdout.on("error", () => { done(0); });
|
|
298
|
+
// settles on this end's own close too, which is no news
|
|
299
|
+
void ended.then(() => { if (over)
|
|
300
|
+
return; process.stderr.write(`mason ui --bridge: Mason for ${run.name} ended the tunnel: it stopped or restarted\n`); done(1); });
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** What a Mason that lacks `--bridge` prints: its parser's refusal of the flag, or its usage. */
|
|
304
|
+
const OLDER_MASON = /unknown option\b.*--bridge|^usage: mason ui\b/im;
|
|
305
|
+
/** The report for an ssh that exited before the bridge said hello, by what its exit means. */
|
|
306
|
+
export function exitFailure(o) {
|
|
307
|
+
const { target, mason, code, signal, said, stderr } = o;
|
|
308
|
+
const how = signal !== null ? `ssh was ended (${signal})` : `ssh exited ${code}`;
|
|
309
|
+
const why = said === "" ? how : `${how}: ${said}`;
|
|
310
|
+
const here = [`ssh ${target} true`, "whether ssh reaches it on its own"];
|
|
311
|
+
// 127 is a shell's "command not found", and ssh hands on the remote command's exit
|
|
312
|
+
if (code === 127) {
|
|
313
|
+
return { message: `${JSON.stringify(mason)} is not a command on ${target}`, code: "not_found", why,
|
|
314
|
+
fix: `--mason says how to run Mason there: --mason "docker exec -i <container> mason" for a container (docker ps names it), or --mason "/path/to/mason" where a shell ssh opens lacks it on its PATH`, next: [here] };
|
|
315
|
+
}
|
|
316
|
+
// 2 alone proves nothing (a shell exits 2 when its cd fails): an older Mason says so in its own words, read from the
|
|
317
|
+
// whole of stderr, as what it prints after that line pushes it out of `said`
|
|
318
|
+
if (code === 2 && OLDER_MASON.test(stderr)) {
|
|
319
|
+
return { message: `the Mason on ${target} does not know mason ui --bridge`, code: "refused", why, fix: "it is older than mason ui --ssh: upgrade the server's Mason to this one's version", next: [["mason --version", "this machine's"]] };
|
|
320
|
+
}
|
|
321
|
+
if (code === 255 && /host key|host identification/i.test(stderr)) {
|
|
322
|
+
return { message: `ssh does not trust the host key of ${target}`, code: "failed", why,
|
|
323
|
+
fix: `run ssh ${target} on its own and check the key before you accept it: mason ui --ssh never answers that question for you`, next: [here] };
|
|
324
|
+
}
|
|
325
|
+
if (code === 255) {
|
|
326
|
+
return { message: `ssh could not reach ${target}`, code: "failed", why,
|
|
327
|
+
fix: `run ssh ${target} on its own: this uses your ~/.ssh/config, as that does. A firewall rule that names your old IP address is the common cause`, next: [here] };
|
|
328
|
+
}
|
|
329
|
+
return { message: `the bridge on ${target} ended before it answered`, code: "failed", why, fix: `run ${bridgeCommand(mason, 1)} there to see why`, next: [here] };
|
|
330
|
+
}
|
|
331
|
+
/** A hello's refusal, in the words of who reads it: on this machine, about that one. */
|
|
332
|
+
function refusal(h, target) {
|
|
333
|
+
const lost = h.error.code === "no_project", stopped = h.error.code === "not_running";
|
|
334
|
+
const fix = lost ? `ssh lands in a folder with no Mason project. --mason says where it is: --mason "cd <MASON_DATA> && mason", or --mason "docker exec -i <container> mason"`
|
|
335
|
+
: stopped ? `start mason serve on ${target}: its service or container` : h.error.fix;
|
|
336
|
+
// these two say mason init and mason start, words for a person at that machine: run here they would do the wrong thing
|
|
337
|
+
const next = lost || stopped ? [] : h.next.map((n) => n.what === undefined ? [n.command] : [n.command, n.what]);
|
|
338
|
+
return { message: `on ${target}: ${h.error.message}`, code: failureCode(h.error.code, "failed"), ...(h.error.why ? { why: h.error.why } : {}), ...(fix ? { fix } : {}), next };
|
|
339
|
+
}
|
|
340
|
+
/** Starts ssh and the bridge, with a free loopback port before them: the tunnel, or why not. */
|
|
341
|
+
export function openSshTunnel(o) {
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
let link = null;
|
|
344
|
+
// no link yet: nothing was handed out that names this port, so nothing that connects is ours
|
|
345
|
+
const local = net.createServer((socket) => { if (link === null)
|
|
346
|
+
socket.destroy();
|
|
347
|
+
else
|
|
348
|
+
link.open(socket); });
|
|
349
|
+
local.once("error", (e) => { resolve({ message: `no local port could be opened: ${e.message}`, code: "failed" }); });
|
|
350
|
+
local.listen(0, "127.0.0.1", () => {
|
|
351
|
+
const port = local.address().port;
|
|
352
|
+
const child = spawn("ssh", sshArgs(o.target, bridgeCommand(o.mason, port), o.interactive), { stdio: ["pipe", "pipe", "pipe"] });
|
|
353
|
+
let output = Buffer.alloc(0), said = "", settled = false, stopping = false, finish = () => undefined;
|
|
354
|
+
const ended = new Promise((r) => { finish = r; });
|
|
355
|
+
let gone = () => undefined;
|
|
356
|
+
const exited = new Promise((r) => { gone = r; });
|
|
357
|
+
const stop = () => {
|
|
358
|
+
stopping = true;
|
|
359
|
+
link?.end();
|
|
360
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
361
|
+
child.kill("SIGTERM");
|
|
362
|
+
setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
|
|
363
|
+
child.kill("SIGKILL"); }, STOP_GRACE_MS).unref();
|
|
364
|
+
}
|
|
365
|
+
return Promise.all([exited, new Promise((r) => { local.close(() => r()); })]).then(() => undefined);
|
|
366
|
+
};
|
|
367
|
+
const settle = (v) => {
|
|
368
|
+
if (settled)
|
|
369
|
+
return;
|
|
370
|
+
settled = true;
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
resolve(v);
|
|
373
|
+
};
|
|
374
|
+
// said only once ssh is gone: the command exits on this answer, and the timer that kills an ssh ignoring SIGTERM dies with it
|
|
375
|
+
const fail = (f) => {
|
|
376
|
+
if (settled)
|
|
377
|
+
return;
|
|
378
|
+
settled = true;
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
void stop().then(() => { resolve(f); });
|
|
381
|
+
};
|
|
382
|
+
const timer = setTimeout(() => {
|
|
383
|
+
const last = lastLines(said, null);
|
|
384
|
+
fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "timeout", ...(last === "" ? {} : { why: `ssh printed: ${last}` }),
|
|
385
|
+
fix: `run ssh ${o.target} on its own to see where it waits; --timeout gives it longer`, next: [[`ssh ${o.target} true`, "whether ssh reaches it on its own"]] });
|
|
386
|
+
}, o.timeoutMs);
|
|
387
|
+
// Ctrl-C before an answer: ssh goes too, a process signalled alone included
|
|
388
|
+
void o.stopped.then(() => { fail({ message: "stopped before the server answered", code: "failed" }); });
|
|
389
|
+
const helloIn = (h) => {
|
|
390
|
+
if ("error" in h) {
|
|
391
|
+
fail(refusal(h, o.target));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (h.protocol !== BRIDGE_PROTOCOL) {
|
|
395
|
+
fail({ message: `the Mason on ${o.target} (${h.version}) and this one (${version()}) speak different bridges`, code: "refused", why: `its bridge is version ${h.protocol}, this one's ${BRIDGE_PROTOCOL}`,
|
|
396
|
+
fix: "run the same Mason version on both ends", next: [["mason --version", "this machine's"]] });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// what a later drop repeats is what ssh says from here on, never the login's banner
|
|
400
|
+
said = "";
|
|
401
|
+
settle({ url: `http://127.0.0.1:${port}/?ticket=${encodeURIComponent(h.ticket)}`, port, project: h.project, serverVersion: h.version, ended, stop });
|
|
402
|
+
};
|
|
403
|
+
child.stdout.on("data", (b) => {
|
|
404
|
+
if (link !== null) {
|
|
405
|
+
link.read(b);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
output = Buffer.concat([output, b]);
|
|
409
|
+
const at = output.indexOf(`${HELLO} `);
|
|
410
|
+
const eol = at < 0 ? -1 : output.indexOf("\n", at);
|
|
411
|
+
if (eol < 0) {
|
|
412
|
+
if (output.length > MAX_FRAME)
|
|
413
|
+
output = output.subarray(-MAX_FRAME);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const rest = output.subarray(eol + 1);
|
|
417
|
+
let h;
|
|
418
|
+
try {
|
|
419
|
+
h = JSON.parse(output.subarray(at + HELLO.length + 1, eol).toString("utf8"));
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
fail({ message: `the bridge on ${o.target} said something that is not its hello`, code: "failed", why: e.message, fix: "run the same Mason version on both ends" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
link = new FrameLink({ input: child.stdout, output: child.stdin, broken: (why) => { void stop().then(() => { finish(why); }); } });
|
|
426
|
+
helloIn(h);
|
|
427
|
+
if (rest.length > 0)
|
|
428
|
+
link.read(rest);
|
|
429
|
+
});
|
|
430
|
+
child.stderr.on("data", (b) => { said = (said + b.toString("utf8")).slice(-16 * 1024); process.stderr.write(b); });
|
|
431
|
+
// ssh gone with bytes still to write: said by its exit
|
|
432
|
+
child.stdin.on("error", () => undefined);
|
|
433
|
+
child.on("error", (e) => {
|
|
434
|
+
if (e.code === "ENOENT") {
|
|
435
|
+
fail({ message: "ssh is not on this machine's PATH, and mason ui --ssh runs it", code: "not_found", fix: "install the OpenSSH client (it comes with macOS, Windows 10 and later, and most Linux; apt install openssh-client)" });
|
|
436
|
+
gone();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
fail({ message: `ssh could not be started: ${e.message}`, code: "failed" });
|
|
440
|
+
gone();
|
|
441
|
+
});
|
|
442
|
+
child.on("exit", (code, signal) => {
|
|
443
|
+
gone();
|
|
444
|
+
// read after a moment: a Ctrl-C reaches ssh and this process together, and `stop` says which end it was
|
|
445
|
+
setTimeout(() => {
|
|
446
|
+
const last = lastLines(said, null);
|
|
447
|
+
if (!settled) {
|
|
448
|
+
fail(exitFailure({ target: o.target, mason: o.mason, code, signal, said: last, stderr: said }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (stopping)
|
|
452
|
+
return;
|
|
453
|
+
link?.end();
|
|
454
|
+
local.close();
|
|
455
|
+
finish(last === "" ? `ssh exited ${code ?? signal}` : `ssh exited ${code ?? signal}: ${last}`);
|
|
456
|
+
}, SETTLE_MS).unref();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}
|