@mason-data/cli 0.9.0 → 0.11.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 +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
package/dist/src/cli/tunnel.js
CHANGED
|
@@ -133,7 +133,7 @@ export function startTunnel(plan, o) {
|
|
|
133
133
|
const timer = setTimeout(() => {
|
|
134
134
|
stop();
|
|
135
135
|
const said = lastLines(output, plan.token);
|
|
136
|
-
settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "
|
|
136
|
+
settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "timeout", why: said === "" ? `${plan.bin} printed nothing` : `${plan.bin} printed: ${said}`,
|
|
137
137
|
fix: `its output is the service's, and may have changed: this Mason cannot read it${plan.via === "pinggy" ? ", and --via bore is another tunnel" : ""}. Nothing was left open` });
|
|
138
138
|
}, o.timeoutMs);
|
|
139
139
|
const read = (b) => {
|
|
@@ -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,23 @@
|
|
|
1
|
+
export const UI_READ_ONLY_SETTING = "MASON_UI_READ_ONLY";
|
|
2
|
+
/** MASON_UI_READ_ONLY as the environment says it: 1 true, 0 or unset false, anything else what is wrong with it. */
|
|
3
|
+
export function uiReadOnlySetting(env) {
|
|
4
|
+
const v = env[UI_READ_ONLY_SETTING];
|
|
5
|
+
if (v === undefined || v === "" || v === "0")
|
|
6
|
+
return false;
|
|
7
|
+
if (v === "1")
|
|
8
|
+
return true;
|
|
9
|
+
return { problem: `${UI_READ_ONLY_SETTING}=${JSON.stringify(v)}: 1 (the web page only shows, and changes nothing) or 0 (the default)` };
|
|
10
|
+
}
|
|
11
|
+
/** Why the page offers no control that changes anything: `/api/state` `pageReadOnly`, the page's words. */
|
|
12
|
+
export const PAGE_READ_ONLY_WHY = `the web page is read-only here (${UI_READ_ONLY_SETTING}=1)`;
|
|
13
|
+
/** The answer to every change the page asks for under the setting. */
|
|
14
|
+
export const PAGE_READ_ONLY = {
|
|
15
|
+
message: `${PAGE_READ_ONLY_WHY}: it shows what Mason syncs, and changes nothing`, code: "refused",
|
|
16
|
+
why: `Mason was started with ${UI_READ_ONLY_SETTING}=1, so the page refuses every change, whatever it asks`,
|
|
17
|
+
fix: "make the change with the mason command where Mason runs, or ask whoever runs it",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Whether a request the guard let in is a change the page asks for: anything but a GET or a HEAD, sent without the
|
|
21
|
+
* run's token (a request with it is the commands', and the guard has checked that the token is the run's).
|
|
22
|
+
*/
|
|
23
|
+
export const pageWrite = (method, authorization) => method !== "GET" && method !== "HEAD" && authorization === undefined;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MASON_UI_RUN_FILE` (mason serve): THE RUN'S PAGE PORT AND TOKEN IN A FILE THE OPERATOR NAMES, for a process on the
|
|
3
|
+
* same machine that supervises the run (a supervisor, a local reverse proxy beside MASON_UI_SOCKET) and opens a tunnel
|
|
4
|
+
* (`POST /api/ui/tunnel`) without reading `.mason/config.json`, which is the run's own and holds more.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT HOLDS: `{ "port", "token" }` and nothing else. The token is the run's: it grants the commands' writes (mason
|
|
7
|
+
* add, mason sync, mason cancel, the users) as well as a tunnel, so the file is held to the bar the page's socket is
|
|
8
|
+
* (src/cli/ui-socket.ts): in a folder only Mason's user can write (`ownFolderProblem`), mode 0600 from the moment it
|
|
9
|
+
* exists, never through a symbolic link. The socket's path is not in it: whoever reads the file named the socket, and
|
|
10
|
+
* /healthz says it.
|
|
11
|
+
*
|
|
12
|
+
* WRITTEN WHOLE, ONCE THE RUN ANSWERS: a 0600 file beside it, written and flushed, then linked to the path (a link
|
|
13
|
+
* never replaces what is there), so a reader finds no file or the whole of it. It is removed when the run stops, and only
|
|
14
|
+
* when it is still the one written. A file already at the path is replaced only when it is a run file of Mason's user
|
|
15
|
+
* (a regular file, 0600, of exactly those two keys) whose run does not answer with its token; anything else — a link,
|
|
16
|
+
* a folder, another user's file, a file of other content, a run that answers — refuses the start and is left as it is.
|
|
17
|
+
*/
|
|
18
|
+
import * as crypto from "node:crypto";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { octal, ownFolderProblem } from "./ui-socket.js";
|
|
22
|
+
export const UI_RUN_FILE_SETTING = "MASON_UI_RUN_FILE";
|
|
23
|
+
/** How long the run a stale file names has to answer before whether it runs is taken as unknown. */
|
|
24
|
+
const PROBE_MS = 2_000;
|
|
25
|
+
/** The file MASON_UI_RUN_FILE names, absolute; null when it is unset (or set empty); or what is wrong with it. */
|
|
26
|
+
export function uiRunFileSetting(env, cwd, platform = process.platform) {
|
|
27
|
+
const set = env[UI_RUN_FILE_SETTING];
|
|
28
|
+
if (!set)
|
|
29
|
+
return null;
|
|
30
|
+
// a file mode is how the token is kept to Mason's user, and Windows keeps none
|
|
31
|
+
if (platform === "win32")
|
|
32
|
+
return { problem: `${UI_RUN_FILE_SETTING} is set, and on Windows a file cannot be kept to Mason's user by its mode: unset it` };
|
|
33
|
+
return path.resolve(cwd, set);
|
|
34
|
+
}
|
|
35
|
+
const refused = (file, problem, fix) => ({ message: `${UI_RUN_FILE_SETTING}=${file}: ${problem}`, code: "refused", why: "the file holds the run's token, which grants the commands' writes as well as a tunnel to the page, so only Mason's user may make or replace it", fix });
|
|
36
|
+
/** The content of a run file Mason wrote, or null when `text` is anything else. */
|
|
37
|
+
function runFileContent(text) {
|
|
38
|
+
let v;
|
|
39
|
+
try {
|
|
40
|
+
v = JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
} // not JSON: not a run file, which the caller refuses
|
|
45
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
46
|
+
return null;
|
|
47
|
+
const o = v;
|
|
48
|
+
if (Object.keys(o).sort().join(",") !== "port,token")
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof o.port !== "number" || !Number.isInteger(o.port) || o.port < 1 || o.port > 65_535 || typeof o.token !== "string")
|
|
51
|
+
return null;
|
|
52
|
+
return { port: o.port, token: o.token };
|
|
53
|
+
}
|
|
54
|
+
/** Whether the run a file names answers with its token: true, false (nothing that is that run), or the error that left it unknown. */
|
|
55
|
+
async function runAnswers(c) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`http://127.0.0.1:${c.port}/api/ping`, { headers: { Authorization: `Bearer ${c.token}` }, signal: AbortSignal.timeout(PROBE_MS) });
|
|
58
|
+
if (res.status !== 200) {
|
|
59
|
+
await res.body?.cancel();
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
let body;
|
|
63
|
+
try {
|
|
64
|
+
body = await res.json();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
// a 200 whose body is not JSON is no run's ping (a body cut off midway is not knowing, said as such)
|
|
68
|
+
if (e instanceof SyntaxError)
|
|
69
|
+
return false;
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
return typeof body?.pid === "number";
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const code = e.cause?.code;
|
|
76
|
+
if (code === "ECONNREFUSED")
|
|
77
|
+
return false;
|
|
78
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The folder and the path held to the bar (the header): null when the file may be written, after a stale one is
|
|
83
|
+
* removed (`said` is told), or the refusal. Fails closed: a fact it cannot read refuses.
|
|
84
|
+
*/
|
|
85
|
+
export async function prepareUiRunFile(file, said, uid = process.getuid()) {
|
|
86
|
+
const folder = ownFolderProblem(file, "run file", uid);
|
|
87
|
+
if (folder !== null)
|
|
88
|
+
return refused(file, folder.problem, folder.fix);
|
|
89
|
+
let f;
|
|
90
|
+
try {
|
|
91
|
+
f = fs.lstatSync(file);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e.code === "ENOENT")
|
|
95
|
+
return null;
|
|
96
|
+
return refused(file, `it cannot be read: ${e.message}`, "name another path");
|
|
97
|
+
}
|
|
98
|
+
const notMine = "remove it yourself if it is not needed, or name another path";
|
|
99
|
+
if (f.isSymbolicLink())
|
|
100
|
+
return refused(file, "it is a symbolic link, and Mason never replaces one", notMine);
|
|
101
|
+
if (!f.isFile())
|
|
102
|
+
return refused(file, `it is ${f.isDirectory() ? "a folder" : "not a regular file"}, and Mason replaces only a run file a run of its own left`, notMine);
|
|
103
|
+
if (f.uid !== uid)
|
|
104
|
+
return refused(file, `it is a file of uid ${f.uid}, not of Mason's user (uid ${uid})`, notMine);
|
|
105
|
+
if ((f.mode & 0o777) !== 0o600)
|
|
106
|
+
return refused(file, `it is a file of mode ${octal(f.mode)}, and a run file Mason writes is 0600`, notMine);
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
// never through a link that appeared since: O_NOFOLLOW
|
|
110
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
111
|
+
try {
|
|
112
|
+
text = f.size > 4096 ? "" : fs.readFileSync(fd, "utf8");
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
fs.closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return refused(file, `it cannot be read: ${e.message}`, notMine);
|
|
120
|
+
}
|
|
121
|
+
const content = runFileContent(text);
|
|
122
|
+
if (content === null)
|
|
123
|
+
return refused(file, "it is a file of other content than a run file Mason writes ({ port, token })", notMine);
|
|
124
|
+
const live = await runAnswers(content);
|
|
125
|
+
if (live === true)
|
|
126
|
+
return refused(file, "the run it names answers with its token: another Mason writes it now", "stop that Mason, or name another path");
|
|
127
|
+
if (live instanceof Error)
|
|
128
|
+
return refused(file, `whether the run it names still runs could not be told (${live.message})`, notMine);
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(file);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return refused(file, `the stale run file could not be removed: ${e.message}`, notMine);
|
|
134
|
+
}
|
|
135
|
+
said(`replaced the run file a run that did not stop left at ${file}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** Whether `file` holds `text`, read never through a link. */
|
|
139
|
+
function readsAs(file, text) {
|
|
140
|
+
let fd;
|
|
141
|
+
try {
|
|
142
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
} // gone, or a link since: not the file written, which the caller leaves
|
|
147
|
+
try {
|
|
148
|
+
return fs.readFileSync(fd, "utf8") === text;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
fs.closeSync(fd);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Writes `content` to `file` whole (the header): a 0600 file beside it, written and flushed, linked to the path, which
|
|
156
|
+
* fails when anything is there, then checked. `said` is told of a removal that failed.
|
|
157
|
+
*/
|
|
158
|
+
export function writeUiRunFile(file, content, said, uid = process.getuid()) {
|
|
159
|
+
const folder = ownFolderProblem(file, "run file", uid);
|
|
160
|
+
if (folder !== null)
|
|
161
|
+
return refused(file, folder.problem, folder.fix);
|
|
162
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}`);
|
|
163
|
+
const text = `${JSON.stringify({ port: content.port, token: content.token })}\n`;
|
|
164
|
+
let fd = null;
|
|
165
|
+
let linked = false;
|
|
166
|
+
const cleanUp = () => {
|
|
167
|
+
if (fd !== null) {
|
|
168
|
+
try {
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
said(`the run file's descriptor could not be closed: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(temp);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (e.code !== "ENOENT")
|
|
180
|
+
said(`the run file's temporary ${temp} could not be removed: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
let made;
|
|
184
|
+
try {
|
|
185
|
+
// made 0600 by open itself (O_EXCL: never a file that was there; O_NOFOLLOW: never through a link), then set
|
|
186
|
+
// exactly 0600 whatever the umask, before a byte is written
|
|
187
|
+
fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
188
|
+
fs.fchmodSync(fd, 0o600);
|
|
189
|
+
fs.writeFileSync(fd, text);
|
|
190
|
+
fs.fsyncSync(fd);
|
|
191
|
+
made = fs.fstatSync(fd);
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
fd = null;
|
|
194
|
+
// a link never replaces what is at the path: EEXIST when something appeared there since the start checked it
|
|
195
|
+
fs.linkSync(temp, file);
|
|
196
|
+
linked = true;
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
cleanUp();
|
|
200
|
+
const code = e.code;
|
|
201
|
+
return refused(file, code === "EEXIST" && !linked ? "something was put at the path after the start checked it, and Mason writes over nothing" : `it cannot be written: ${e.message}`, code === "EEXIST" ? "remove it yourself if it is not needed, or name another path" : "name a path in a folder only Mason's user can write, on a file system that keeps hard links");
|
|
202
|
+
}
|
|
203
|
+
cleanUp();
|
|
204
|
+
let now;
|
|
205
|
+
try {
|
|
206
|
+
now = fs.lstatSync(file);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return refused(file, `it cannot be read once written: ${e.message}`, "name a path in a folder only Mason's user can write");
|
|
210
|
+
}
|
|
211
|
+
// the file written: its inode, and its text too, since a file made at the path after it was removed may be handed
|
|
212
|
+
// the same inode number
|
|
213
|
+
const same = (s) => s.isFile() && s.ino === made.ino && s.dev === made.dev && s.size === Buffer.byteLength(text) && readsAs(file, text);
|
|
214
|
+
if (!same(now) || now.uid !== uid || (now.mode & 0o777) !== 0o600) {
|
|
215
|
+
if (same(now)) {
|
|
216
|
+
try {
|
|
217
|
+
fs.unlinkSync(file);
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
said(`the run file ${file} could not be removed: ${e.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return refused(file, `it is not Mason's alone once written (mode ${octal(now.mode)}, uid ${now.uid})`, "name a path in a folder only Mason's user can write");
|
|
224
|
+
}
|
|
225
|
+
let removed = false;
|
|
226
|
+
return {
|
|
227
|
+
file,
|
|
228
|
+
remove: () => {
|
|
229
|
+
if (removed)
|
|
230
|
+
return;
|
|
231
|
+
removed = true;
|
|
232
|
+
// removed only while it is the file written: another's at the path since is left as it is
|
|
233
|
+
try {
|
|
234
|
+
if (same(fs.lstatSync(file)))
|
|
235
|
+
fs.unlinkSync(file);
|
|
236
|
+
else
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (e.code !== "ENOENT")
|
|
241
|
+
said(`the run file ${file} could not be removed: ${e.message}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
said(`removed the run file ${file}`);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MASON_UI_SOCKET` (mason serve): THE WEB PAGE ON A UNIX SOCKET THE OPERATOR NAMES, beside its loopback port, for a
|
|
3
|
+
* process on the same machine (a supervisor, a local reverse proxy) that should reach the page without a port published
|
|
4
|
+
* or a `docker exec` per request.
|
|
5
|
+
*
|
|
6
|
+
* WHO CAN CONNECT is who can write the socket, so the socket is made mode 0600 in a folder only Mason's user can write
|
|
7
|
+
* (owned by it, no group or other write), never through a symbolic link: anything else refuses the start, with the way
|
|
8
|
+
* out. A path that is already there is replaced only when it is a socket of Mason's user that nobody answers on (a run
|
|
9
|
+
* that crashed left it); a file of any other kind, a link, another user's socket, or one a process answers on is never
|
|
10
|
+
* removed. The socket is removed when the run stops.
|
|
11
|
+
*
|
|
12
|
+
* WHAT A REQUEST ON IT IS TRUSTED WITH: exactly what one through `mason ui --bridge` is, because it reaches the run's own
|
|
13
|
+
* request handler (src/cli/run-server.ts), behind the same guard (src/cli/security.ts): its Host, the run's token or a
|
|
14
|
+
* tunnel's ticket and cookie, its Origin. Arriving on the socket proves nothing: no request is taken for a local
|
|
15
|
+
* browser, or let in at all, for where it came from.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as http from "node:http";
|
|
19
|
+
import * as net from "node:net";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
export const UI_SOCKET_SETTING = "MASON_UI_SOCKET";
|
|
22
|
+
/** The longest path a Unix socket may have: `sun_path` holds 108 bytes on Linux, 104 on macOS, each with its NUL. */
|
|
23
|
+
const maxSocketBytes = (platform) => platform === "darwin" ? 103 : 107;
|
|
24
|
+
/** How long a socket at the path has to answer before it is taken for one nobody serves: a live run answers at once. */
|
|
25
|
+
const PROBE_MS = 1_000;
|
|
26
|
+
/** The socket MASON_UI_SOCKET names, absolute; null when it is unset (or set empty); or what is wrong with it. */
|
|
27
|
+
export function uiSocketSetting(env, cwd, platform = process.platform) {
|
|
28
|
+
const set = env[UI_SOCKET_SETTING];
|
|
29
|
+
if (!set)
|
|
30
|
+
return null;
|
|
31
|
+
if (platform === "win32")
|
|
32
|
+
return { problem: `${UI_SOCKET_SETTING} is set, and Windows has no Unix socket for the page: unset it (mason ui --ssh <user@server> opens the page from your machine)` };
|
|
33
|
+
const file = path.resolve(cwd, set);
|
|
34
|
+
const bytes = Buffer.byteLength(file);
|
|
35
|
+
if (bytes > maxSocketBytes(platform))
|
|
36
|
+
return { problem: `${UI_SOCKET_SETTING}=${JSON.stringify(set)} is ${bytes} bytes as a full path, and a Unix socket's path is at most ${maxSocketBytes(platform)}: name a shorter one` };
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
const refused = (file, problem, fix) => ({ message: `${UI_SOCKET_SETTING}=${file}: ${problem}`, code: "refused", why: "the socket is how a process on this machine reaches the web page, so only Mason's user may make or replace it", fix });
|
|
40
|
+
export const octal = (mode) => `0${(mode & 0o777).toString(8)}`;
|
|
41
|
+
/** Whether a process answers on the socket at `file`: true, false (nothing listens), or the error that left it unknown. */
|
|
42
|
+
function answers(file) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const c = net.connect(file);
|
|
45
|
+
const timer = setTimeout(() => { c.destroy(); resolve(new Error(`it neither accepted nor refused a connection in ${PROBE_MS} ms`)); }, PROBE_MS);
|
|
46
|
+
c.once("connect", () => { clearTimeout(timer); c.destroy(); resolve(true); });
|
|
47
|
+
c.once("error", (e) => { clearTimeout(timer); resolve(e.code === "ECONNREFUSED" ? false : e); });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* THE FOLDER OF A FILE ONLY MASON'S USER MAY MAKE (the page's socket, the run's file, src/cli/ui-run-file.ts): a folder,
|
|
52
|
+
* not a link to one, owned by Mason's user, which no group or other can write; null, or what is wrong and the way out.
|
|
53
|
+
* Fails closed: a folder it cannot read is refused. `what` names the file in the way out ("socket", "run file").
|
|
54
|
+
*/
|
|
55
|
+
export function ownFolderProblem(file, what, uid) {
|
|
56
|
+
const dir = path.dirname(file);
|
|
57
|
+
const ownFolder = `mkdir -m 700 ${dir} as Mason's user, or name a ${what} in a folder that is only its own (the data folder's .mason/ is)`;
|
|
58
|
+
let d;
|
|
59
|
+
try {
|
|
60
|
+
d = fs.lstatSync(dir);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return { problem: `its folder ${dir} cannot be read: ${e.code === "ENOENT" ? "it does not exist" : e.message}`, fix: ownFolder };
|
|
64
|
+
}
|
|
65
|
+
if (d.isSymbolicLink()) {
|
|
66
|
+
let real;
|
|
67
|
+
try {
|
|
68
|
+
real = fs.realpathSync(dir);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
real = `it names nothing that can be read: ${e.message}`;
|
|
72
|
+
}
|
|
73
|
+
return { problem: `its folder ${dir} is a symbolic link`, fix: `name the ${what} in the folder itself (${real}), not through a link to it` };
|
|
74
|
+
}
|
|
75
|
+
if (!d.isDirectory())
|
|
76
|
+
return { problem: `${dir} is not a folder`, fix: ownFolder };
|
|
77
|
+
if (d.uid !== uid)
|
|
78
|
+
return { problem: `its folder ${dir} is owned by uid ${d.uid}, not Mason's user (uid ${uid})`, fix: ownFolder };
|
|
79
|
+
if ((d.mode & 0o022) !== 0)
|
|
80
|
+
return { problem: `its folder ${dir} can be written by others than Mason's user (mode ${octal(d.mode)})`, fix: `chmod go-w ${dir}, or ${ownFolder}` };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The folder and the path held to the bar (the header): null when the socket may be made, after a stale one is removed
|
|
85
|
+
* (`said` is told), or the refusal. Fails closed: a fact it cannot read refuses.
|
|
86
|
+
*/
|
|
87
|
+
export async function prepareUiSocket(file, said, uid = process.getuid()) {
|
|
88
|
+
const folder = ownFolderProblem(file, "socket", uid);
|
|
89
|
+
if (folder !== null)
|
|
90
|
+
return refused(file, folder.problem, folder.fix);
|
|
91
|
+
let f;
|
|
92
|
+
try {
|
|
93
|
+
f = fs.lstatSync(file);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (e.code === "ENOENT")
|
|
97
|
+
return null;
|
|
98
|
+
return refused(file, `it cannot be read: ${e.message}`, "name another path");
|
|
99
|
+
}
|
|
100
|
+
const notMine = "remove it yourself if it is not needed, or name another path";
|
|
101
|
+
if (f.isSymbolicLink())
|
|
102
|
+
return refused(file, "it is a symbolic link, and Mason never replaces one", notMine);
|
|
103
|
+
if (!f.isSocket())
|
|
104
|
+
return refused(file, `it is ${f.isDirectory() ? "a folder" : "a file"}, not a socket, and Mason removes only a socket a run of its own left`, notMine);
|
|
105
|
+
if (f.uid !== uid)
|
|
106
|
+
return refused(file, `it is a socket of uid ${f.uid}, not of Mason's user (uid ${uid})`, notMine);
|
|
107
|
+
const live = await answers(file);
|
|
108
|
+
if (live === true)
|
|
109
|
+
return refused(file, "a process answers on it: another Mason, or something else, serves it now", "stop that process, or name another path");
|
|
110
|
+
if (live instanceof Error)
|
|
111
|
+
return refused(file, `whether a process answers on it could not be told (${live.message})`, notMine);
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(file);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
return refused(file, `the stale socket could not be removed: ${e.message}`, notMine);
|
|
117
|
+
}
|
|
118
|
+
said(`replaced the socket a run that did not stop left at ${file}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Listens on `file` (`prepareUiSocket` first) with mode 0600 from the moment it exists (the umask around the bind),
|
|
123
|
+
* checked after. Until `attach`, a request is answered 503, as /healthz says starting; then each one is the run's
|
|
124
|
+
* server's own request, behind its guard.
|
|
125
|
+
*/
|
|
126
|
+
export async function listenOnUiSocket(file, said, uid = process.getuid()) {
|
|
127
|
+
let run = null;
|
|
128
|
+
const server = http.createServer((req, res) => {
|
|
129
|
+
if (run !== null) {
|
|
130
|
+
run.emit("request", req, res);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Mason is starting: the page answers once it does\n");
|
|
134
|
+
});
|
|
135
|
+
const before = process.umask(0o177);
|
|
136
|
+
let listening;
|
|
137
|
+
try {
|
|
138
|
+
listening = new Promise((resolve) => { server.once("error", resolve); server.listen(file, () => resolve(null)); });
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
process.umask(before);
|
|
142
|
+
}
|
|
143
|
+
const err = await listening;
|
|
144
|
+
if (err !== null)
|
|
145
|
+
return refused(file, `the socket cannot be made: ${err.message}`, "name a path in a folder only Mason's user can write");
|
|
146
|
+
let made;
|
|
147
|
+
try {
|
|
148
|
+
fs.chmodSync(file, 0o600);
|
|
149
|
+
made = fs.lstatSync(file);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
server.close();
|
|
153
|
+
return refused(file, `the socket's mode cannot be set: ${e.message}`, "name a path in a folder only Mason's user can write");
|
|
154
|
+
}
|
|
155
|
+
if (!made.isSocket() || made.uid !== uid || (made.mode & 0o777) !== 0o600) {
|
|
156
|
+
server.close();
|
|
157
|
+
return refused(file, `the socket is not Mason's alone once made (mode ${octal(made.mode)}, uid ${made.uid})`, "name a path in a folder only Mason's user can write");
|
|
158
|
+
}
|
|
159
|
+
let closed = false;
|
|
160
|
+
return {
|
|
161
|
+
file,
|
|
162
|
+
attach: (r) => { run = r; },
|
|
163
|
+
close: () => {
|
|
164
|
+
if (closed)
|
|
165
|
+
return;
|
|
166
|
+
closed = true;
|
|
167
|
+
server.close();
|
|
168
|
+
server.closeAllConnections();
|
|
169
|
+
// the listening socket is unlinked as it closes; whatever is still at the path is removed only when it is this one
|
|
170
|
+
try {
|
|
171
|
+
const now = fs.lstatSync(file);
|
|
172
|
+
if (now.isSocket() && now.ino === made.ino && now.dev === made.dev)
|
|
173
|
+
fs.unlinkSync(file);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
if (e.code !== "ENOENT")
|
|
177
|
+
said(`the web page's socket ${file} could not be removed: ${e.message}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
said(`removed the web page's socket ${file}`);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
package/dist/src/cli/ui-ssh.js
CHANGED
|
@@ -32,7 +32,7 @@ import * as net from "node:net";
|
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
33
|
import { Worker } from "node:worker_threads";
|
|
34
34
|
import { runnable } from "../lib/runnable.js";
|
|
35
|
-
import { errorJson, nextJson } from "./command.js";
|
|
35
|
+
import { errorJson, failureCode, nextJson } from "./command.js";
|
|
36
36
|
import { holdRun, RunUnreachable } from "./control.js";
|
|
37
37
|
import { whereIs } from "./state.js";
|
|
38
38
|
import { lastLines } from "./tunnel.js";
|
|
@@ -335,7 +335,7 @@ function refusal(h, target) {
|
|
|
335
335
|
: stopped ? `start mason serve on ${target}: its service or container` : h.error.fix;
|
|
336
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
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: h.error.code, ...(h.error.why ? { why: h.error.why } : {}), ...(fix ? { fix } : {}), next };
|
|
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
339
|
}
|
|
340
340
|
/** Starts ssh and the bridge, with a free loopback port before them: the tunnel, or why not. */
|
|
341
341
|
export function openSshTunnel(o) {
|
|
@@ -381,7 +381,7 @@ export function openSshTunnel(o) {
|
|
|
381
381
|
};
|
|
382
382
|
const timer = setTimeout(() => {
|
|
383
383
|
const last = lastLines(said, null);
|
|
384
|
-
fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "
|
|
384
|
+
fail({ message: `no answer from ${o.target} in ${Math.round(o.timeoutMs / 1000)} s`, code: "timeout", ...(last === "" ? {} : { why: `ssh printed: ${last}` }),
|
|
385
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
386
|
}, o.timeoutMs);
|
|
387
387
|
// Ctrl-C before an answer: ssh goes too, a process signalled alone included
|