@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,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SECRET READ FROM THE PERSON OR THE PIPE, NEVER FROM A FLAG, AN ARGUMENT OR A FILE (`mason replica create`'s database
|
|
3
|
+
* URL, issue 720): on a terminal a hidden prompt, otherwise stdin read whole. Node's own TTY handling, no dependency.
|
|
4
|
+
*
|
|
5
|
+
* - THE PROMPT (stdin a terminal): the question on stderr, stdin in raw mode, so the terminal echoes nothing and keeps
|
|
6
|
+
* no line of it; each key is read here. Enter ends it; Ctrl-C, or Ctrl-D or Enter on an empty line, cancels; Backspace
|
|
7
|
+
* and Ctrl-U edit; a terminal's escape sequences (a bracketed paste's markers, an arrow) are dropped. The terminal is
|
|
8
|
+
* put back as it was on EVERY way out — the answer, a cancel, an error of the stream, its end, a signal (SIGINT,
|
|
9
|
+
* SIGTERM, SIGHUP while raw mode keeps Ctrl-C from being one), and the process's exit.
|
|
10
|
+
* - THE PIPE (stdin not a terminal): read whole, bounded at `max` bytes and by a time limit (a pipe left open and
|
|
11
|
+
* silent never holds the command), one trailing newline (`\n` or `\r\n`) trimmed; more than one line is refused.
|
|
12
|
+
*
|
|
13
|
+
* Nothing read is ever written anywhere by this module: what went wrong is said by kind, never by value.
|
|
14
|
+
*/
|
|
15
|
+
import { StringDecoder } from "node:string_decoder";
|
|
16
|
+
const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
17
|
+
/**
|
|
18
|
+
* THE HIDDEN PROMPT: `question` on `output` (stderr), a line read from `input` (a terminal) in raw mode, echoing nothing.
|
|
19
|
+
* At most `max` characters are kept; a longer line is read to its end (so nothing of it is left for the shell to echo)
|
|
20
|
+
* and refused.
|
|
21
|
+
*/
|
|
22
|
+
export function readHidden(input, output, question, max, signals = process) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const wasRaw = input.isRaw === true;
|
|
25
|
+
const decoder = new StringDecoder("utf8");
|
|
26
|
+
let line = [];
|
|
27
|
+
let tooLong = false;
|
|
28
|
+
let escape = "";
|
|
29
|
+
let ended = false;
|
|
30
|
+
const restore = () => { if (!wasRaw)
|
|
31
|
+
input.setRawMode?.(false); };
|
|
32
|
+
const finish = (r) => {
|
|
33
|
+
if (ended)
|
|
34
|
+
return;
|
|
35
|
+
ended = true;
|
|
36
|
+
input.removeListener("data", onData);
|
|
37
|
+
input.removeListener("end", onEnd);
|
|
38
|
+
input.removeListener("close", onEnd);
|
|
39
|
+
input.removeListener("error", onError);
|
|
40
|
+
for (const s of SIGNALS)
|
|
41
|
+
signals.removeListener(s, onSignal);
|
|
42
|
+
signals.removeListener("exit", restore);
|
|
43
|
+
try {
|
|
44
|
+
restore();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
input.pause();
|
|
48
|
+
// the person's Enter was not echoed: the next line starts on a line of its own
|
|
49
|
+
output.write("\n");
|
|
50
|
+
line = [];
|
|
51
|
+
if (r instanceof Error)
|
|
52
|
+
reject(r);
|
|
53
|
+
else
|
|
54
|
+
resolve(r);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const onSignal = () => finish({ none: "cancelled" });
|
|
58
|
+
const onEnd = () => finish({ none: line.length ? "closed" : "cancelled" });
|
|
59
|
+
const onError = (e) => finish(e);
|
|
60
|
+
const onData = (chunk) => {
|
|
61
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
62
|
+
for (const c of text) {
|
|
63
|
+
if (ended)
|
|
64
|
+
return;
|
|
65
|
+
// a terminal's escape sequence (an arrow, a bracketed paste's markers, a title report): dropped whole
|
|
66
|
+
if (escape === "esc") {
|
|
67
|
+
escape = c === "[" ? "csi" : c === "]" ? "osc" : "";
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (escape === "csi") {
|
|
71
|
+
if (c >= "@" && c <= "~")
|
|
72
|
+
escape = "";
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (escape === "osc") {
|
|
76
|
+
if (c === "\x07" || c === "\x1b")
|
|
77
|
+
escape = "";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === "\x1b") {
|
|
81
|
+
escape = "esc";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === "\r" || c === "\n") {
|
|
85
|
+
finish(tooLong ? { none: "too_long" } : line.length === 0 ? { none: "empty" } : { value: line.join("") });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (c === "\x03") {
|
|
89
|
+
finish({ none: "cancelled" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (c === "\x04") {
|
|
93
|
+
if (line.length === 0 && !tooLong) {
|
|
94
|
+
finish({ none: "cancelled" });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c === "\x7f" || c === "\b") {
|
|
100
|
+
line.pop();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === "\x15") {
|
|
104
|
+
line = [];
|
|
105
|
+
tooLong = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c < " ")
|
|
109
|
+
continue;
|
|
110
|
+
if (line.length >= max) {
|
|
111
|
+
tooLong = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
line.push(c);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
if (input.setRawMode === undefined)
|
|
119
|
+
throw new Error("stdin is a terminal that cannot turn its echo off");
|
|
120
|
+
// the question first, then echo off: nothing typed before it is shown as asked
|
|
121
|
+
output.write(question);
|
|
122
|
+
input.setRawMode(true);
|
|
123
|
+
for (const s of SIGNALS)
|
|
124
|
+
signals.on(s, onSignal);
|
|
125
|
+
signals.on("exit", restore);
|
|
126
|
+
input.on("data", onData);
|
|
127
|
+
input.on("end", onEnd);
|
|
128
|
+
input.on("close", onEnd);
|
|
129
|
+
input.on("error", onError);
|
|
130
|
+
input.resume();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
finish(e instanceof Error ? e : new Error(String(e)));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* THE PIPE: `input` read whole, at most `max` bytes after one trailing newline is trimmed, within `timeoutMs`. Empty,
|
|
139
|
+
* more than one line, longer than `max`, or nothing by the time limit, each said by kind.
|
|
140
|
+
*/
|
|
141
|
+
export function readPiped(input, max, timeoutMs) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let bytes = 0;
|
|
145
|
+
let ended = false;
|
|
146
|
+
const finish = (r) => {
|
|
147
|
+
if (ended)
|
|
148
|
+
return;
|
|
149
|
+
ended = true;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
input.removeListener("data", onData);
|
|
152
|
+
input.removeListener("end", onEnd);
|
|
153
|
+
input.removeListener("error", onError);
|
|
154
|
+
input.pause();
|
|
155
|
+
if (r instanceof Error)
|
|
156
|
+
reject(r);
|
|
157
|
+
else
|
|
158
|
+
resolve(r);
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(() => finish({ none: "timeout" }), timeoutMs);
|
|
161
|
+
// the newline a shell's echo or a file ends with is not the URL's: room for it, then the bound
|
|
162
|
+
const most = max + 2;
|
|
163
|
+
const onData = (chunk) => {
|
|
164
|
+
const b = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
165
|
+
bytes += b.length;
|
|
166
|
+
if (bytes > most) {
|
|
167
|
+
finish({ none: "too_long" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
chunks.push(b);
|
|
171
|
+
};
|
|
172
|
+
const onEnd = () => {
|
|
173
|
+
let text = Buffer.concat(chunks).toString("utf8");
|
|
174
|
+
chunks.length = 0;
|
|
175
|
+
if (text.endsWith("\r\n"))
|
|
176
|
+
text = text.slice(0, -2);
|
|
177
|
+
else if (text.endsWith("\n"))
|
|
178
|
+
text = text.slice(0, -1);
|
|
179
|
+
if (text === "")
|
|
180
|
+
return finish({ none: "empty" });
|
|
181
|
+
if (/[\r\n]/.test(text))
|
|
182
|
+
return finish({ none: "lines" });
|
|
183
|
+
if (Buffer.byteLength(text, "utf8") > max)
|
|
184
|
+
return finish({ none: "too_long" });
|
|
185
|
+
finish({ value: text });
|
|
186
|
+
};
|
|
187
|
+
const onError = (e) => finish(e);
|
|
188
|
+
input.on("data", onData);
|
|
189
|
+
input.on("end", onEnd);
|
|
190
|
+
input.on("error", onError);
|
|
191
|
+
input.resume();
|
|
192
|
+
});
|
|
193
|
+
}
|
package/dist/src/cli/security.js
CHANGED
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
* `mason_dev_<port>`): two runs on one machine each keep their own, and opening one's page never logs the other's out
|
|
27
27
|
* (one name for every run did). A page of another run, or one the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
|
|
28
28
|
* (tests/cli-security.test.ts).
|
|
29
|
+
*
|
|
30
|
+
* THROUGH A TUNNEL (`mason ui --ssh`, src/cli/ui-ssh.ts): the browser is on another machine, on a local port of its own,
|
|
31
|
+
* so its Host is 127.0.0.1:<that port>. The guard never takes any other Host: a tunnel is a `PageTunnel`, opened by a
|
|
32
|
+
* process holding the run's token (`POST /api/ui/tunnel`) and gone with that process's connection, and only the loopback
|
|
33
|
+
* names at ITS port are added, for as long as it lasts. A DNS-rebinding page still names itself, and is refused by that
|
|
34
|
+
* name. The run's token never crosses: the laptop gets a TICKET, one use and minutes old, because the URL a browser is
|
|
35
|
+
* opened at is on a command line; the first request trades it for a cookie holding the tunnel's own SESSION, named for
|
|
36
|
+
* the laptop's port, which dies with the tunnel. So nothing that reaches the laptop outlives it. The tunnel itself is raw
|
|
37
|
+
* TCP to this server, so a client chooses its path by the Host it sends: Bearer is refused by a tunnel's Host, and by the
|
|
38
|
+
* server's own Host it is the commands' path, which needs the token, which never crosses.
|
|
29
39
|
*/
|
|
30
40
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
31
41
|
/** The cookie of the run whose server is on `port`: one per run, as a browser shares a host's cookies across ports. */
|
|
@@ -44,12 +54,55 @@ export function cookieOf(header, name) {
|
|
|
44
54
|
}
|
|
45
55
|
return null;
|
|
46
56
|
}
|
|
57
|
+
/** How long a tunnel's ticket can be traded: a person with `--no-open` pastes it; nobody needs it longer. */
|
|
58
|
+
export const TICKET_MS = 5 * 60_000;
|
|
59
|
+
export function newTunnel(port, now = Date.now()) {
|
|
60
|
+
return { port, ticket: newToken(), ticketUntil: now + TICKET_MS, session: newToken() };
|
|
61
|
+
}
|
|
47
62
|
export function setCookieHeader(token, port) {
|
|
48
63
|
return `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
|
49
64
|
}
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
/** The Host a browser may send for a loopback port: its two names, that port, and nothing else. */
|
|
66
|
+
const loopbackHosts = (port) => [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
67
|
+
/** A change must come from the page at `hosts`: its Origin is one of them. */
|
|
68
|
+
function foreignOrigin(req, hosts) {
|
|
69
|
+
if (req.method === "GET" || req.method === "HEAD")
|
|
70
|
+
return null;
|
|
71
|
+
if (req.origin && hosts.map((h) => `http://${h}`).includes(req.origin.toLowerCase()))
|
|
72
|
+
return null;
|
|
73
|
+
return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
|
|
74
|
+
}
|
|
75
|
+
/** A request by the Host of a tunnel's port (the header says why it is its own path): a ticket, or the session's cookie. */
|
|
76
|
+
function throughTunnel(req, tunnels, now) {
|
|
77
|
+
const port = tunnels[0].port;
|
|
78
|
+
if (req.authorization !== undefined)
|
|
79
|
+
return { ok: false, status: 403, reason: "a tunnel carries a browser, never a command: the run's token is not sent through one" };
|
|
80
|
+
const cookie = cookieOf(req.cookie, cookieName(port));
|
|
81
|
+
const held = cookie !== null && tunnels.some((t) => sameSecret(t.session, cookie));
|
|
82
|
+
const url = new URL(req.url, "http://tunnel");
|
|
83
|
+
const offered = url.searchParams.get("ticket");
|
|
84
|
+
if (offered !== null && (req.method === "GET" || req.method === "HEAD")) {
|
|
85
|
+
url.searchParams.delete("ticket");
|
|
86
|
+
const location = url.pathname + (url.search || "") + url.hash;
|
|
87
|
+
const t = tunnels.find((x) => x.ticket !== null && now < x.ticketUntil && sameSecret(x.ticket, offered));
|
|
88
|
+
if (t !== undefined) {
|
|
89
|
+
t.ticket = null;
|
|
90
|
+
return { ok: false, status: 303, location, setCookie: setCookieHeader(t.session, port) };
|
|
91
|
+
}
|
|
92
|
+
if (held)
|
|
93
|
+
return { ok: false, status: 303, location };
|
|
94
|
+
return { ok: false, status: 403, reason: "this ticket was used already, or is too old: mason ui --ssh gives a new one" };
|
|
95
|
+
}
|
|
96
|
+
if (!held)
|
|
97
|
+
return { ok: false, status: 403, reason: "this tunnel's page takes its ticket or its cookie: mason ui --ssh opens it" };
|
|
98
|
+
return foreignOrigin(req, loopbackHosts(port)) ?? { ok: true };
|
|
99
|
+
}
|
|
100
|
+
/** Whether `req` may be served by the server on `port` whose run token is `token`, `tunnels` the ones open now. */
|
|
101
|
+
export function guard(req, port, token, tunnels = [], now = Date.now()) {
|
|
102
|
+
const hosts = loopbackHosts(port);
|
|
103
|
+
const through = tunnels.filter((t) => loopbackHosts(t.port).includes((req.host ?? "").toLowerCase()));
|
|
104
|
+
if (through.length > 0 && !hosts.includes((req.host ?? "").toLowerCase()))
|
|
105
|
+
return throughTunnel(req, through, now);
|
|
53
106
|
if (!req.host || !hosts.includes(req.host.toLowerCase())) {
|
|
54
107
|
return { ok: false, status: 421, reason: `this server answers only as ${hosts[0]} (the request named ${JSON.stringify(req.host ?? "")})` };
|
|
55
108
|
}
|
|
@@ -70,13 +123,7 @@ export function guard(req, port, token) {
|
|
|
70
123
|
if (cookie === null || !sameSecret(cookie, token)) {
|
|
71
124
|
return { ok: false, status: 403, reason: "this is mason start's command server for the mason command: a request carries the run's token (Authorization: Bearer, from .mason/config.json)" };
|
|
72
125
|
}
|
|
73
|
-
|
|
74
|
-
const own = hosts.map((h) => `http://${h}`);
|
|
75
|
-
if (!req.origin || !own.includes(req.origin.toLowerCase())) {
|
|
76
|
-
return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return { ok: true };
|
|
126
|
+
return foreignOrigin(req, hosts) ?? { ok: true };
|
|
80
127
|
}
|
|
81
128
|
/** The headers every response of a guarded server carries. */
|
|
82
129
|
export const SECURITY_HEADERS = {
|
package/dist/src/cli/serve.js
CHANGED
|
@@ -17,15 +17,20 @@
|
|
|
17
17
|
* (counts, lag, ages, sizes) for the same reason: never a table's, a user's or a host's name, never a statement.
|
|
18
18
|
* - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
|
|
19
19
|
*
|
|
20
|
+
* - ITS WEB PAGE ON LOOPBACK ALONE (src/cli/run-server.ts `listenOnLoopback`): the run's server, MASON_UI_PORT, reached
|
|
21
|
+
* from a developer's machine with `mason ui --ssh`, never published. No setting moves it to another address.
|
|
22
|
+
*
|
|
20
23
|
* The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
|
|
21
24
|
*/
|
|
22
25
|
import * as fs from "node:fs";
|
|
23
26
|
import * as http from "node:http";
|
|
24
27
|
import * as path from "node:path";
|
|
25
28
|
import { failed, parseCommand } from "./command.js";
|
|
29
|
+
import { DEFAULT_UI_PORT, heldText, portHolder } from "./ports.js";
|
|
30
|
+
import { pgListenerAddress } from "../api/postgres/listener.js";
|
|
26
31
|
import { dev } from "./dev.js";
|
|
27
32
|
import { parseSource } from "./preflight.js";
|
|
28
|
-
import { NAME_RE, newConfig, readProject, STATE_DIR, writeConfig } from "./project.js";
|
|
33
|
+
import { NAME_RE, newConfig, projectReadOnlySetting, projectWriteRefusal, readProject, STATE_DIR, writeConfig } from "./project.js";
|
|
29
34
|
import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir, sourcesTop, writtenDatabase } from "./sources.js";
|
|
30
35
|
import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/database-name.js";
|
|
31
36
|
import { appEnvValue } from "../lib/dotenv.js";
|
|
@@ -34,6 +39,8 @@ import { commandPinProblem } from "./pin.js";
|
|
|
34
39
|
import { recordRefusal } from "./replication-mode.js";
|
|
35
40
|
import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
|
|
36
41
|
import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
|
|
42
|
+
import { DEFAULT_MEMORY_RULE, memoryProblems, memoryProblemText, memorySize, MEMORY_FLOOR_BYTES } from "../duck/store.js";
|
|
43
|
+
import { wrap } from "./style.js";
|
|
37
44
|
import { runningVersion, version } from "./version.js";
|
|
38
45
|
import { phaseCounts } from "./readiness.js";
|
|
39
46
|
import { METRICS_CONTENT_TYPE, metricsText } from "./metrics.js";
|
|
@@ -47,11 +54,13 @@ export const SERVE = {
|
|
|
47
54
|
GET /metrics for Prometheus (numbers only: no name, no statement), and a clean stop on SIGTERM. The tables it
|
|
48
55
|
syncs are the ones the project folder's sources file names (sources/<name>.yml): make it with mason init and mason
|
|
49
56
|
add on a laptop and commit it, or with mason add beside the running server. A project folder with no sources file
|
|
50
|
-
gets one, url: \${DATABASE_URL}, with no table yet.
|
|
57
|
+
gets one, url: \${DATABASE_URL}, with no table yet — unless MASON_PROJECT_READONLY=1, which writes none.
|
|
51
58
|
|
|
52
59
|
DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
|
|
53
60
|
docker compose exec mason mason add public.orders beside it, in its container
|
|
54
61
|
|
|
62
|
+
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
63
|
+
|
|
55
64
|
Settings (the environment):
|
|
56
65
|
DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
|
|
57
66
|
the file names is required); read-only: SELECTs, or a replication slot
|
|
@@ -60,25 +69,36 @@ export const SERVE = {
|
|
|
60
69
|
this one), else this; default none: Mason makes its own
|
|
61
70
|
MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
|
|
62
71
|
MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
|
|
72
|
+
MASON_PROJECT_READONLY 1 when the project folder is deployed as committed (CI, GitOps, a platform that deploys
|
|
73
|
+
the folder): every edit of its files is refused — mason add and mason remove, beside it or
|
|
74
|
+
on the web page — and says to edit the files and deploy them (default 0)
|
|
63
75
|
MASON_DATA the folder whose .mason/ holds the synced data and the state: the volume (default .)
|
|
64
76
|
MASON_NAME the project's name, which the replication slot is named for (default mason); renaming it
|
|
65
77
|
over the same synced data is refused
|
|
66
78
|
MASON_PG_DATABASE the database name a tool connects with, for a sources file with no database: (default mason;
|
|
67
79
|
the two must agree when both are set)
|
|
68
80
|
MASON_HEALTH_PORT /healthz's and /metrics' port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
|
|
81
|
+
MASON_UI_PORT the web page's and the commands' port, on 127.0.0.1 alone: no setting moves the address, and
|
|
82
|
+
mason ui --ssh <user@server> opens the page from your machine (default ${DEFAULT_UI_PORT}, or the next
|
|
83
|
+
free one; a port named here is that port, or the start is refused)
|
|
69
84
|
MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
|
|
70
85
|
MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
|
|
71
86
|
for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
MASON_PG_PROXY_FROM behind a TCP proxy or load balancer that sends the PROXY protocol v2: its own addresses,
|
|
88
|
+
CIDRs comma-separated (10.0.0.0/16); a connection from one must begin with a header, whose
|
|
89
|
+
client the request log names; a header from any other address is refused (default unset:
|
|
90
|
+
none is read)
|
|
91
|
+
MASON_DUCKDB_MEMORY ${wrap(`the engine's memory, 4GB or 512MiB (default ${DEFAULT_MEMORY_RULE}; mason doctor says it); a statement past it fails alone with 53200. Unset, under ${memorySize(MEMORY_FLOOR_BYTES)} Mason starts and warns: the system may stop it`, 120, 28)}
|
|
92
|
+
MASON_SPILL_LIMIT what statements may spill to disk, 40GB or 512MiB (default half of what is free, followed as
|
|
93
|
+
the disk fills; never past 90% of it); a statement past it fails alone with 53400
|
|
74
94
|
MASON_STATEMENT_TIMEOUT each statement's limit, as Postgres's statement_timeout (default 5min; 0 for none)
|
|
75
95
|
MASON_HISTORY_DAYS how many days Mason keeps its history: the request log, the sync's events, the usage
|
|
76
96
|
samples and the synced tables' sizes (default 7)
|
|
77
97
|
MASON_STORE_JSON_PATHS on (the default) stores the JSON paths statements keep reading as columns; off stores none
|
|
78
98
|
MASON_COPY_PARALLEL a first sync's chunks read at once, each a backend on the database (1 to 8, default 3)
|
|
79
99
|
MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
UV_THREADPOOL_SIZE threads for running statements, one each, 1 to 1024 (default 32, lowered to fit a limit
|
|
101
|
+
on threads; one that does not fit, or is no such number, refuses the start)
|
|
82
102
|
|
|
83
103
|
Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
|
|
84
104
|
`,
|
|
@@ -86,6 +106,11 @@ export const SERVE = {
|
|
|
86
106
|
json: ["next"],
|
|
87
107
|
};
|
|
88
108
|
const MIN_API_KEY = 32;
|
|
109
|
+
/** The Postgres API's port as the settings say it; null while MASON_PG_PORT is wrong, which the run's own check says. */
|
|
110
|
+
function pgPortOf(env) {
|
|
111
|
+
const a = pgListenerAddress(env, "127.0.0.1");
|
|
112
|
+
return "problem" in a ? null : a.port;
|
|
113
|
+
}
|
|
89
114
|
/** THE SETTINGS, OR EVERY ONE MISSING OR WRONG AT ONCE: a platform's deploy is slow, and one variable a deploy wastes it. */
|
|
90
115
|
export function serveSettings(env, cwd) {
|
|
91
116
|
const wrong = [];
|
|
@@ -116,6 +141,13 @@ export function serveSettings(env, cwd) {
|
|
|
116
141
|
const days = historyDaysSetting(env);
|
|
117
142
|
if (typeof days !== "number")
|
|
118
143
|
wrong.push(historyDaysWrongText(days));
|
|
144
|
+
// MASON_DUCKDB_MEMORY not an amount (src/duck/store.ts memoryProblems), said here with the rest, before anything is
|
|
145
|
+
// written or connected; a container under the floor starts, and the run warns (memoryWarning)
|
|
146
|
+
wrong.push(...memoryProblems(env).map(memoryProblemText));
|
|
147
|
+
// the project's files deployed read-only (src/cli/project.ts projectWriteRefusal): 1 or 0, anything else refused here
|
|
148
|
+
const readOnly = projectReadOnlySetting(env);
|
|
149
|
+
if (typeof readOnly !== "boolean")
|
|
150
|
+
wrong.push(readOnly.problem);
|
|
119
151
|
const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
|
|
120
152
|
if (selfSigned !== "0" && selfSigned !== "1")
|
|
121
153
|
wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
|
|
@@ -123,6 +155,17 @@ export function serveSettings(env, cwd) {
|
|
|
123
155
|
const port = Number(portText);
|
|
124
156
|
if (!/^\d+$/.test(portText) || port < 1 || port > 65_535)
|
|
125
157
|
wrong.push(`MASON_HEALTH_PORT=${JSON.stringify(portText)}: a port number, 1-65535`);
|
|
158
|
+
const uiText = env.MASON_UI_PORT || String(DEFAULT_UI_PORT);
|
|
159
|
+
const uiPort = Number(uiText);
|
|
160
|
+
if (!/^\d+$/.test(uiText) || uiPort < 1 || uiPort > 65_535)
|
|
161
|
+
wrong.push(`MASON_UI_PORT=${JSON.stringify(uiText)}: a port number, 1-65535`);
|
|
162
|
+
else if (env.MASON_UI_PORT && uiPort === port)
|
|
163
|
+
wrong.push(`MASON_UI_PORT=${uiText} is MASON_HEALTH_PORT too: two ports`);
|
|
164
|
+
else if (env.MASON_UI_PORT && uiPort === pgPortOf(env))
|
|
165
|
+
wrong.push(`MASON_UI_PORT=${uiText} is the Postgres API's port too (MASON_PG_PORT): two ports`);
|
|
166
|
+
// a host would only be believed: the page has no address but loopback, and running on while one is asked for would hide that
|
|
167
|
+
if (env.MASON_UI_HOST)
|
|
168
|
+
wrong.push("MASON_UI_HOST is set, and the web page has no address but 127.0.0.1: remove it (mason ui --ssh <user@server> opens the page from your machine)");
|
|
126
169
|
if (wrong.length) {
|
|
127
170
|
return { message: `${wrong.length === 1 ? "a setting is" : `${wrong.length} settings are`} missing or wrong: ${wrong.join("; ")}`, code: "bad_config",
|
|
128
171
|
why: "mason serve reads its settings from the environment, and asks nothing",
|
|
@@ -133,6 +176,7 @@ export function serveSettings(env, cwd) {
|
|
|
133
176
|
name, apiKey, selfSignedTls: selfSigned === "1",
|
|
134
177
|
project, data,
|
|
135
178
|
health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
|
|
179
|
+
ui: { port: uiPort, named: !!env.MASON_UI_PORT },
|
|
136
180
|
};
|
|
137
181
|
}
|
|
138
182
|
/**
|
|
@@ -186,8 +230,8 @@ function urlSettingProblems(project, data, env) {
|
|
|
186
230
|
const found = appEnvValue(data, variable, env, { shellFirst: true });
|
|
187
231
|
if (found === null)
|
|
188
232
|
return [`${variable} is not set: the database to sync from, as the sources file's url: \${${variable}} names it`];
|
|
189
|
-
const p = parseSource(found.value);
|
|
190
|
-
return "problem" in p ? [
|
|
233
|
+
const p = parseSource(found.value, variable);
|
|
234
|
+
return "problem" in p ? [p.problem] : [];
|
|
191
235
|
}
|
|
192
236
|
/**
|
|
193
237
|
* THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
|
|
@@ -247,6 +291,14 @@ export function serveSource(s, env) {
|
|
|
247
291
|
fix: "set DATABASE_URL=postgres://user:password@host:5432/app (the sources file mason init writes names it), or mount the project folder with its sources/ (MASON_PROJECT)",
|
|
248
292
|
next: [["mason serve --help", "every setting, with its default"]] };
|
|
249
293
|
}
|
|
294
|
+
// a deployed project's files are its commit's (src/cli/project.ts projectWriteRefusal: this serve's own environment
|
|
295
|
+
// decides, the config not written yet): a folder deployed without its sources file is said, never filled in
|
|
296
|
+
const readOnly = projectWriteRefusal(null, env);
|
|
297
|
+
if (readOnly !== null) {
|
|
298
|
+
return { ...readOnly, message: `the project folder (${s.project}) has no sources file, and ${readOnly.message}`,
|
|
299
|
+
fix: "edit the files and deploy them: mason init and mason add where the project is committed write its sources file; commit it, and deploy the folder with it",
|
|
300
|
+
next: [["mason serve --help", "every setting, with its default"]] };
|
|
301
|
+
}
|
|
250
302
|
const p = parseSource(url);
|
|
251
303
|
const file = path.join(sourcesDir(s.project), `${sourceNameFor("source" in p ? p.source.database : "")}.yml`);
|
|
252
304
|
try {
|
|
@@ -365,6 +417,10 @@ export async function serve(argv) {
|
|
|
365
417
|
return failed("serve", { message: `the state cannot be written to ${path.join(s.data, STATE_DIR)}: ${e.message}`, code: "refused",
|
|
366
418
|
fix: "MASON_DATA names a folder this user can write: the volume's mount point" }, { json });
|
|
367
419
|
}
|
|
420
|
+
// a port named is that port: said here, before anything listens, as the next free one would hide the setting
|
|
421
|
+
const held = s.ui.named ? await portHolder(s.ui.port) : null;
|
|
422
|
+
if (held !== null)
|
|
423
|
+
return failed("serve", { message: `MASON_UI_PORT: ${heldText(s.ui.port, held)}`, code: "refused", fix: "name another port, or leave MASON_UI_PORT unset for the next free one" }, { json });
|
|
368
424
|
const startedAt = Date.now();
|
|
369
425
|
let state = { phase: "starting" };
|
|
370
426
|
const health = healthServer(() => state, startedAt);
|
|
@@ -388,7 +444,7 @@ export async function serve(argv) {
|
|
|
388
444
|
process.chdir(s.data);
|
|
389
445
|
let code;
|
|
390
446
|
try {
|
|
391
|
-
code = await dev({ port:
|
|
447
|
+
code = await dev({ port: s.ui.port, exactPort: s.ui.named, version: version(), selfSignedTls: s.selfSignedTls,
|
|
392
448
|
serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
|
|
393
449
|
}
|
|
394
450
|
finally {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* is enough: the password is 192 random bits, nothing to guess). It is no row of `.mason/users.json`, because that file
|
|
13
13
|
* outlives a process and a share must not: a run that dies takes it along.
|
|
14
14
|
* Only `authenticate` below reads this list, and only the share's socket asks it: the password is refused on the
|
|
15
|
-
* run's own listener by nobody knowing it, and a `mason user` or a token here by the same
|
|
15
|
+
* run's own listener by nobody knowing it, and a `mason user` or a token here by the same; each is told so by its shape,
|
|
16
|
+
* which names what was given and where it works, and decides nothing.
|
|
16
17
|
*
|
|
17
18
|
* A LOGIN REFUSED HERE IS LOGGED WITH NO SOURCE: every connection through a tunnel comes from 127.0.0.1, so naming
|
|
18
19
|
* one would be naming the tunnel's client. The log is written once a minute at most (a scanner's rate is anyone's), with
|
|
@@ -23,12 +24,15 @@ import { execFile } from "node:child_process";
|
|
|
23
24
|
import { ApiError } from "../api/error.js";
|
|
24
25
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
25
26
|
import { missingAttributes } from "../duck/row-filter.js";
|
|
27
|
+
import { isToken } from "../duck/token.js";
|
|
28
|
+
import { isUserPassword, SHARE_PASSWORD_PREFIX } from "../duck/users.js";
|
|
26
29
|
import { ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE } from "./roles.js";
|
|
27
30
|
import { sameSecret } from "./security.js";
|
|
28
31
|
/** What the password is stored and sent as: its SHA-256, hex. */
|
|
29
32
|
export const shareSecret = (password) => crypto.createHash("sha256").update(password).digest("hex");
|
|
30
|
-
/** The new password `mason share` prints once: 192 random bits, in characters every
|
|
31
|
-
|
|
33
|
+
/** The new password `mason share` prints once: `mason_share_` and 192 random bits as 48 hex digits, in characters every
|
|
34
|
+
* form and URL takes. Recognisably Mason's and apart from a user's (`isUserPassword`, src/duck/users.ts) and a token's. */
|
|
35
|
+
export const newSharePassword = () => `${SHARE_PASSWORD_PREFIX}${crypto.randomBytes(24).toString("hex")}`;
|
|
32
36
|
/** How often a refused login is written to the log, at most: the rest are counted. */
|
|
33
37
|
const REFUSED_LOG_MS = 60_000;
|
|
34
38
|
/** How many refused logins a minute are rows of the request log, at most: the rest are counted (`mason status`), so a
|
|
@@ -58,6 +62,11 @@ export function tunnelReport(body) {
|
|
|
58
62
|
return { ...(via !== undefined ? { via } : {}), ...(host !== undefined ? { host } : {}), ...(port !== undefined ? { port } : {}),
|
|
59
63
|
...(endsAt !== undefined ? { endsAt } : {}), ...(state !== undefined ? { state } : {}), ...(pid !== undefined ? { pid } : {}) };
|
|
60
64
|
}
|
|
65
|
+
/** How each holder ends its share, as a refusal names it. */
|
|
66
|
+
const HOW_TO_END = {
|
|
67
|
+
command: ["mason share", "Ctrl-C in the terminal running mason share ends it"],
|
|
68
|
+
page: ["the web page", "Stop sharing on the web page's Overview ends it (mason ui opens the page)"],
|
|
69
|
+
};
|
|
61
70
|
/** What a role reads, in a line. */
|
|
62
71
|
function readsText(g) {
|
|
63
72
|
if (g.all)
|
|
@@ -155,15 +164,24 @@ export class RunShares {
|
|
|
155
164
|
dropped = 0;
|
|
156
165
|
lastSaid = -Infinity;
|
|
157
166
|
unsaid = 0;
|
|
167
|
+
lastEnded = null;
|
|
158
168
|
constructor(deps) { this.deps = deps; }
|
|
159
169
|
/** Opens a share: the socket when none is open, and a login of its own. Refused: the failure, with the choices when a role is what is missing. */
|
|
160
170
|
async open(ask) {
|
|
171
|
+
const from = ask.from ?? "command";
|
|
172
|
+
const busy = this.busy(from);
|
|
173
|
+
if (busy !== null)
|
|
174
|
+
return { failure: busy };
|
|
161
175
|
const role = shareRole(ask.role, await this.deps.listener.checkRoles());
|
|
162
176
|
if ("failure" in role)
|
|
163
177
|
return role;
|
|
178
|
+
// asked again after the wait: a share opened meanwhile is one too many
|
|
179
|
+
const raced = this.busy(from);
|
|
180
|
+
if (raced !== null)
|
|
181
|
+
return { failure: raced };
|
|
164
182
|
const user = `share_${crypto.randomBytes(3).toString("hex")}`;
|
|
165
183
|
// in the list before the first wait, so a share ending meanwhile does not close the socket under this one
|
|
166
|
-
this.held.set(user, { user, secret: ask.secret, role: role.role, reads: role.reads, openedAt: new Date().toISOString(), tunnel: { state: "opening" }, ended: null });
|
|
184
|
+
this.held.set(user, { user, from, secret: ask.secret, role: role.role, reads: role.reads, openedAt: new Date().toISOString(), tunnel: { state: "opening" }, ended: null });
|
|
167
185
|
let socket, fingerprint;
|
|
168
186
|
try {
|
|
169
187
|
const made = this.socket ?? this.openSocket();
|
|
@@ -184,6 +202,29 @@ export class RunShares {
|
|
|
184
202
|
this.deps.log(`share: ${user} opened, reading ${role.role === null ? "every synced table (no roles file)" : `as the role ${role.role}`}, TLS required on 127.0.0.1:${socket.port}`);
|
|
185
203
|
return { user, port: socket.port, fingerprint, database: this.deps.database, role: role.role, reads: role.reads };
|
|
186
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* ONE SHARE AT A TIME BETWEEN THE PAGE AND THE COMMAND: the page's share is refused while any share is open, and any
|
|
207
|
+
* share while the page's is (two `mason share`s still meet one socket, as before). The refusal names the share open
|
|
208
|
+
* and how its holder ends it. null: it may open.
|
|
209
|
+
*/
|
|
210
|
+
busy(from) {
|
|
211
|
+
const open = [...this.held.values()].find((h) => from === "page" || h.from === "page");
|
|
212
|
+
if (open === undefined)
|
|
213
|
+
return null;
|
|
214
|
+
const [who, how] = HOW_TO_END[open.from];
|
|
215
|
+
return { message: `a share is open already: ${open.user}, opened by ${who}${open.tunnel.host !== undefined ? ` at ${open.tunnel.host}:${open.tunnel.port}` : ""}`, code: "refused",
|
|
216
|
+
why: "one share at a time between the web page and mason share: each is a public address someone must be watching", fix: `${how}, then share again`,
|
|
217
|
+
next: [["mason status", "the share open now"]] };
|
|
218
|
+
}
|
|
219
|
+
/** What a share with no role named reads (`shareRole`'s answer, the one authority): the web page asks it before it asks
|
|
220
|
+
* the person, to offer the roles as `mason share` would. */
|
|
221
|
+
async choices() {
|
|
222
|
+
return shareRole(undefined, await this.deps.listener.checkRoles());
|
|
223
|
+
}
|
|
224
|
+
/** Who holds a share: null when none is open by that name. */
|
|
225
|
+
fromOf(user) { return this.held.get(user)?.from ?? null; }
|
|
226
|
+
/** The last share that ended, and why (`/api/state` `shareEnded`). */
|
|
227
|
+
get ended() { return this.lastEnded; }
|
|
187
228
|
openSocket() {
|
|
188
229
|
const cert = this.deps.certificate();
|
|
189
230
|
if ("message" in cert)
|
|
@@ -199,10 +240,11 @@ export class RunShares {
|
|
|
199
240
|
return { opening, fingerprint: cert.fingerprint };
|
|
200
241
|
}
|
|
201
242
|
authenticate(password, user) {
|
|
202
|
-
const
|
|
203
|
-
const found = [...this.held.values()].find((h) => sameSecret(h.secret,
|
|
243
|
+
const secret = shareSecret(password);
|
|
244
|
+
const found = [...this.held.values()].find((h) => sameSecret(h.secret, secret));
|
|
204
245
|
if (found === undefined || found.user !== user) {
|
|
205
|
-
const
|
|
246
|
+
const given = isUserPassword(password) ? "a mason user's password" : isToken(password) ? "an API token" : null;
|
|
247
|
+
const refused = new ApiError(401, given === null ? "not this share's password" : `that is ${given}: it logs in on the run's own port, never at a share's address`, "invalid_share");
|
|
206
248
|
refused.userName = user;
|
|
207
249
|
throw refused;
|
|
208
250
|
}
|
|
@@ -253,6 +295,7 @@ export class RunShares {
|
|
|
253
295
|
if (h === undefined)
|
|
254
296
|
return;
|
|
255
297
|
this.held.delete(user);
|
|
298
|
+
this.lastEnded = { user, from: h.from, why, at: new Date().toISOString() };
|
|
256
299
|
const opening = this.socket?.opening;
|
|
257
300
|
if (opening === undefined)
|
|
258
301
|
return;
|
|
@@ -280,7 +323,7 @@ export class RunShares {
|
|
|
280
323
|
if (this.socket === null || this.live === null)
|
|
281
324
|
return null;
|
|
282
325
|
return { port: this.live.port, fingerprint: this.socket.fingerprint, sessions: this.live.sessions, refusedLogins: this.refused, droppedConnections: this.dropped,
|
|
283
|
-
shares: [...this.held.values()].map((h) => ({ user: h.user, role: h.role, reads: h.reads, openedAt: h.openedAt, via: h.tunnel.via ?? null, host: h.tunnel.host ?? null, port: h.tunnel.port ?? null,
|
|
326
|
+
shares: [...this.held.values()].map((h) => ({ user: h.user, from: h.from, role: h.role, reads: h.reads, openedAt: h.openedAt, via: h.tunnel.via ?? null, host: h.tunnel.host ?? null, port: h.tunnel.port ?? null,
|
|
284
327
|
endsAt: h.tunnel.endsAt ?? null, state: h.tunnel.state ?? "opening" })) };
|
|
285
328
|
}
|
|
286
329
|
}
|