@mason-data/cli 0.7.0 → 0.8.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 +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUN'S SIDE OF `mason share` (src/cli/share.ts is the command's; docs/POSTGRES-API.md "From a cloud BI tool"): the
|
|
3
|
+
* second socket of the Postgres listener (src/api/postgres/server.ts `openShare`), and who may log in on it.
|
|
4
|
+
*
|
|
5
|
+
* A SHARE LASTS AS LONG AS THE COMMAND'S CONNECTION TO THE RUN (`POST /api/share`, src/cli/run-server.ts): the answer
|
|
6
|
+
* stays open, and when it goes, however it goes — Ctrl-C, the tunnel's end, a `kill -9` —, the login goes, its sessions
|
|
7
|
+
* end, its tunnel client is stopped, and with the last share the socket closes. A second `mason share` meets the same
|
|
8
|
+
* socket with a login of its own.
|
|
9
|
+
*
|
|
10
|
+
* THE LOGIN IS IN THIS PROCESS'S MEMORY ALONE, AND ONLY ITS HASH: the command makes the password and sends its SHA-256
|
|
11
|
+
* (`shareSecret`), so no state, log line or answer of the run can carry a password the run was never given (a fast hash
|
|
12
|
+
* is enough: the password is 192 random bits, nothing to guess). It is no row of `.mason/users.json`, because that file
|
|
13
|
+
* outlives a process and a share must not: a run that dies takes it along.
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* A LOGIN REFUSED HERE IS LOGGED WITH NO SOURCE: every connection through a tunnel comes from 127.0.0.1, so naming
|
|
18
|
+
* 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
|
|
19
|
+
* how many it stands for; the counts are `mason status`'s.
|
|
20
|
+
*/
|
|
21
|
+
import * as crypto from "node:crypto";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { ApiError } from "../api/error.js";
|
|
24
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
25
|
+
import { missingAttributes } from "../duck/row-filter.js";
|
|
26
|
+
import { ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE } from "./roles.js";
|
|
27
|
+
import { sameSecret } from "./security.js";
|
|
28
|
+
/** What the password is stored and sent as: its SHA-256, hex. */
|
|
29
|
+
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 form and URL takes. */
|
|
31
|
+
export const newSharePassword = () => crypto.randomBytes(24).toString("hex");
|
|
32
|
+
/** How often a refused login is written to the log, at most: the rest are counted. */
|
|
33
|
+
const REFUSED_LOG_MS = 60_000;
|
|
34
|
+
/** How many refused logins a minute are rows of the request log, at most: the rest are counted (`mason status`), so a
|
|
35
|
+
* stranger on a public address cannot set the log's growth. */
|
|
36
|
+
const REFUSED_ROWS_A_MINUTE = 10;
|
|
37
|
+
/** The tunnel of a share, as its command tells the run (`POST /api/share/<user>`). */
|
|
38
|
+
/** What `POST /api/share/<user>` was sent, as a ShareTunnel, or null: its `pid` is a process the run may stop, so
|
|
39
|
+
* what the run keeps is these fields, each its own type, never the body. */
|
|
40
|
+
export function tunnelReport(body) {
|
|
41
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
42
|
+
return null;
|
|
43
|
+
const { via, host, port, endsAt, state, pid } = body;
|
|
44
|
+
const text = (x) => typeof x === "string" && x !== "" && x.length <= 255;
|
|
45
|
+
const whole = (x, max) => typeof x === "number" && Number.isInteger(x) && x >= 1 && x <= max;
|
|
46
|
+
if (via !== undefined && !text(via))
|
|
47
|
+
return null;
|
|
48
|
+
if (host !== undefined && !text(host))
|
|
49
|
+
return null;
|
|
50
|
+
if (port !== undefined && !whole(port, 65_535))
|
|
51
|
+
return null;
|
|
52
|
+
if (endsAt !== undefined && endsAt !== null && !(text(endsAt) && !Number.isNaN(Date.parse(endsAt))))
|
|
53
|
+
return null;
|
|
54
|
+
if (state !== undefined && state !== "opening" && state !== "up" && state !== "reconnecting")
|
|
55
|
+
return null;
|
|
56
|
+
if (pid !== undefined && !whole(pid, 2 ** 31 - 1))
|
|
57
|
+
return null;
|
|
58
|
+
return { ...(via !== undefined ? { via } : {}), ...(host !== undefined ? { host } : {}), ...(port !== undefined ? { port } : {}),
|
|
59
|
+
...(endsAt !== undefined ? { endsAt } : {}), ...(state !== undefined ? { state } : {}), ...(pid !== undefined ? { pid } : {}) };
|
|
60
|
+
}
|
|
61
|
+
/** What a role reads, in a line. */
|
|
62
|
+
function readsText(g) {
|
|
63
|
+
if (g.all)
|
|
64
|
+
return "every synced table";
|
|
65
|
+
const names = [...g.tables.keys()];
|
|
66
|
+
return `${names.length} synced table${names.length === 1 ? "" : "s"}: ${names.slice(0, 6).join(", ")}${names.length > 6 ? ` and ${names.length - 6} more` : ""}`;
|
|
67
|
+
}
|
|
68
|
+
/** The attributes a role's filters read, by the rule a session is refused by: a share's login is a password and no
|
|
69
|
+
* values, so under such a role its every statement would be refused. */
|
|
70
|
+
const attributesOf = (g) => g.filters === undefined ? [] : missingAttributes(g.filters.values(), {});
|
|
71
|
+
/** The roles a share may read as, the file's default first. */
|
|
72
|
+
function choicesOf(live) {
|
|
73
|
+
return [...live.grants.values()].filter((g) => attributesOf(g).length === 0).map((g) => ({ role: g.name, reads: readsText(g), default: g.name === live.default }))
|
|
74
|
+
.sort((a, b) => Number(b.default) - Number(a.default));
|
|
75
|
+
}
|
|
76
|
+
/** Why roles that read attributes cannot be shared: what each reads, the roles that can be, and the login that carries values. */
|
|
77
|
+
function needsAttributes(message, roles, choices, file) {
|
|
78
|
+
const first = roles[0];
|
|
79
|
+
const sets = attributesOf(first).map((m) => `--set ${m.attr}=<value>`).join(" ");
|
|
80
|
+
return { message, code: "refused", why: roles.flatMap(attributesOf).map((m) => `${m.at} reads :${m.attr} (${m.where})`).join("; "),
|
|
81
|
+
fix: choices.length > 0 ? choices.map((c) => `--role ${c.role}: reads ${c.reads}`).join("\n") : `add a role to ${file} whose rows need no attribute, then mason share --role <role>`,
|
|
82
|
+
next: [...choices.map((c) => [`mason share --role ${c.role}`, `reads ${c.reads}`]),
|
|
83
|
+
[`mason user add <name> --role ${first.name} ${sets}`, "a login that carries the values, for a tool that can reach this machine"]] };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* WHAT A SHARE READS: `--role`'s role; with none named, the one answer the project gives — no roles file, so every synced
|
|
87
|
+
* table, or a file of one role — and otherwise a refusal naming the choices. A file with a `default:` and other roles
|
|
88
|
+
* is refused too: a default says what a local user gets, and a public address is no moment to widen it silently.
|
|
89
|
+
* The refusal is a Failure with `choices` beside it: a person is asked (src/cli/share.ts), and an agent reads the lines.
|
|
90
|
+
*/
|
|
91
|
+
export function shareRole(asked, live) {
|
|
92
|
+
const file = live.file ?? `mason/${ROLES_FILE}`;
|
|
93
|
+
if (live.checkFailed === true) {
|
|
94
|
+
return { failure: { message: `nothing is shared: ${live.problem}`, code: "failed", why: `the file may be right: a share opens only on filters Postgres has read`, fix: "mason logs says why, then mason share again", next: [["mason logs", "the run's log"]] } };
|
|
95
|
+
}
|
|
96
|
+
if (live.problem !== null) {
|
|
97
|
+
return { failure: { message: `${file} cannot be read as it is now, so nothing is shared: ${live.problem}`, code: "bad_roles", why: "what a share reads is that file's", fix: `repair ${file}, then mason share` } };
|
|
98
|
+
}
|
|
99
|
+
const choices = choicesOf(live);
|
|
100
|
+
const left = [...live.grants.keys()].filter((r) => !choices.some((c) => c.role === r));
|
|
101
|
+
const lines = choices.map((c) => [`mason share --role ${c.role}`, `reads ${c.reads}`]);
|
|
102
|
+
if (asked !== undefined) {
|
|
103
|
+
if (!ROLE_NAME_RE.test(asked))
|
|
104
|
+
return { failure: { message: `${JSON.stringify(asked)} is not a role name: ${ROLE_NAME_RULE}`, code: "usage" } };
|
|
105
|
+
const g = live.grants.get(asked);
|
|
106
|
+
if (g !== undefined) {
|
|
107
|
+
if (attributesOf(g).length === 0)
|
|
108
|
+
return { role: asked, reads: readsText(g) };
|
|
109
|
+
return { failure: needsAttributes(`a share cannot read as the role ${asked}: its rows depend on ${attributesOf(g).map((m) => `:${m.attr}`).join(", ")}, and a share's login has no attributes`, [g], choices, file) };
|
|
110
|
+
}
|
|
111
|
+
if (live.file === null) {
|
|
112
|
+
return { failure: { message: `there is no role ${asked}: this project has no roles file`, code: "not_found", why: `a role is a key of ${file}, beside the sources file, naming the tables (and the columns of each) it may read`,
|
|
113
|
+
fix: `write ${file}:\n roles:\n ${asked}:\n tables:\n <a synced table>: {}\nthen mason share --role ${asked}`, next: [["mason share", "or share every synced table: this project has no roles file"]] } };
|
|
114
|
+
}
|
|
115
|
+
const roles = [...choices.map((c) => c.role), ...left.map((r) => `${r} (its rows depend on an attribute, so a share cannot read as it)`)];
|
|
116
|
+
return { failure: { message: `there is no role ${asked} in ${file}${didYouMean(asked, [...live.grants.keys()])}`, code: "not_found", why: `the roles: ${roles.join(", ")}`, next: lines }, choices };
|
|
117
|
+
}
|
|
118
|
+
if (live.file === null)
|
|
119
|
+
return { role: null, reads: "every synced table" };
|
|
120
|
+
if (live.grants.size === 1 && choices.length === 1)
|
|
121
|
+
return { role: choices[0].role, reads: choices[0].reads };
|
|
122
|
+
if (choices.length === 0 && live.grants.size > 0) {
|
|
123
|
+
return { failure: needsAttributes(`no role of ${file} can be shared: the rows of each depend on an attribute, and a share's login has no attributes`, [...live.grants.values()], choices, file) };
|
|
124
|
+
}
|
|
125
|
+
if (choices.length === 0) {
|
|
126
|
+
return { failure: { message: `${file} names no role, so a share would read nothing`, code: "not_found", why: "what a share reads is one role of that file", fix: `add a role to ${file}, then mason share --role <role>` } };
|
|
127
|
+
}
|
|
128
|
+
return { failure: { message: `${file} names ${live.grants.size} roles: mason share needs to know which one this address reads as`, code: "usage",
|
|
129
|
+
why: "anyone with the address and password reads what that role reads", fix: [...choices.map((c) => `--role ${c.role}: reads ${c.reads}${c.default ? " (the file's default)" : ""}`), ...(left.length > 0 ? [`left out, because a share's login has no attributes: ${left.join(", ")}`] : [])].join("\n"), next: lines }, choices };
|
|
130
|
+
}
|
|
131
|
+
/** Stops a share's tunnel client once its command is gone: by its pid, and only while that pid is still a process
|
|
132
|
+
* naming the share's port (a pid is reused; never kill by anything broader). */
|
|
133
|
+
export function stopTunnelClient(pid, port, log) {
|
|
134
|
+
execFile("ps", ["-o", "command=", "-p", String(pid)], (err, stdout) => {
|
|
135
|
+
// ps exits 1 for a pid that is gone: its command ended it, the usual case
|
|
136
|
+
if (err !== null || !stdout.includes(String(port)))
|
|
137
|
+
return;
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, "SIGTERM");
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
log(`share: stopping its tunnel client (pid ${pid}) failed: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
export class RunShares {
|
|
147
|
+
deps;
|
|
148
|
+
held = new Map();
|
|
149
|
+
socket = null;
|
|
150
|
+
/** the socket once it listens */
|
|
151
|
+
live = null;
|
|
152
|
+
refused = 0;
|
|
153
|
+
rowsFrom = -Infinity;
|
|
154
|
+
rows = 0;
|
|
155
|
+
dropped = 0;
|
|
156
|
+
lastSaid = -Infinity;
|
|
157
|
+
unsaid = 0;
|
|
158
|
+
constructor(deps) { this.deps = deps; }
|
|
159
|
+
/** 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
|
+
async open(ask) {
|
|
161
|
+
const role = shareRole(ask.role, await this.deps.listener.checkRoles());
|
|
162
|
+
if ("failure" in role)
|
|
163
|
+
return role;
|
|
164
|
+
const user = `share_${crypto.randomBytes(3).toString("hex")}`;
|
|
165
|
+
// 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 });
|
|
167
|
+
let socket, fingerprint;
|
|
168
|
+
try {
|
|
169
|
+
const made = this.socket ?? this.openSocket();
|
|
170
|
+
if ("message" in made) {
|
|
171
|
+
this.held.delete(user);
|
|
172
|
+
return { failure: made };
|
|
173
|
+
}
|
|
174
|
+
this.socket = made;
|
|
175
|
+
fingerprint = made.fingerprint;
|
|
176
|
+
socket = await made.opening;
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
this.held.delete(user);
|
|
180
|
+
this.socket = null;
|
|
181
|
+
return { failure: { message: `the share's socket could not be opened: ${e.message}`, code: "failed", next: [["mason logs", "the run's log"]] } };
|
|
182
|
+
}
|
|
183
|
+
this.live = socket;
|
|
184
|
+
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
|
+
return { user, port: socket.port, fingerprint, database: this.deps.database, role: role.role, reads: role.reads };
|
|
186
|
+
}
|
|
187
|
+
openSocket() {
|
|
188
|
+
const cert = this.deps.certificate();
|
|
189
|
+
if ("message" in cert)
|
|
190
|
+
return cert;
|
|
191
|
+
const opening = this.deps.listener.openShare({
|
|
192
|
+
tls: { cert: cert.cert, key: cert.key },
|
|
193
|
+
authenticate: (password, user) => this.authenticate(password, user),
|
|
194
|
+
recheck: (token) => this.recheck(token),
|
|
195
|
+
loginHint: "the password mason share printed: every run of it makes a new one",
|
|
196
|
+
refused: (user) => { this.refused++; this.note(`a login refused for the user ${JSON.stringify(user)}`); return this.rowFree(Date.now()); },
|
|
197
|
+
dropped: (why) => { this.dropped++; this.note(`a connection dropped before its login: it ${why}`); },
|
|
198
|
+
});
|
|
199
|
+
return { opening, fingerprint: cert.fingerprint };
|
|
200
|
+
}
|
|
201
|
+
authenticate(password, user) {
|
|
202
|
+
const given = shareSecret(password);
|
|
203
|
+
const found = [...this.held.values()].find((h) => sameSecret(h.secret, given));
|
|
204
|
+
if (found === undefined || found.user !== user) {
|
|
205
|
+
const refused = new ApiError(401, "not this share's password", "invalid_share");
|
|
206
|
+
refused.userName = user;
|
|
207
|
+
throw refused;
|
|
208
|
+
}
|
|
209
|
+
return { filters: [], subject: undefined, user: found.user, jti: found.user, ...(found.role !== null ? { role: found.role } : {}) };
|
|
210
|
+
}
|
|
211
|
+
/** a session's login, asked again before a statement: a share closed ends its sessions at once, and this is why none outlives it */
|
|
212
|
+
async recheck(token) {
|
|
213
|
+
if (token.jti === undefined || !this.held.has(token.jti))
|
|
214
|
+
throw new ApiError(401, "the share ended", "invalid_share");
|
|
215
|
+
}
|
|
216
|
+
/** whether a refused login at `now` is a row of the request log */
|
|
217
|
+
rowFree(now) {
|
|
218
|
+
if (now - this.rowsFrom >= 60_000) {
|
|
219
|
+
this.rowsFrom = now;
|
|
220
|
+
this.rows = 0;
|
|
221
|
+
}
|
|
222
|
+
return ++this.rows <= REFUSED_ROWS_A_MINUTE;
|
|
223
|
+
}
|
|
224
|
+
/** the log's line: at most one a minute, saying how many it stands for, with no source (the header says why) */
|
|
225
|
+
note(what) {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (now - this.lastSaid < REFUSED_LOG_MS) {
|
|
228
|
+
this.unsaid++;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const more = this.unsaid > 0 ? ` (and ${this.unsaid} more before it)` : "";
|
|
232
|
+
this.lastSaid = now;
|
|
233
|
+
this.unsaid = 0;
|
|
234
|
+
this.deps.log(`share: ${what}${more}; who cannot be known: every connection through a tunnel comes from 127.0.0.1`);
|
|
235
|
+
}
|
|
236
|
+
/** What a share's command says of its tunnel; false: no such share open. */
|
|
237
|
+
update(user, tunnel) {
|
|
238
|
+
const h = this.held.get(user);
|
|
239
|
+
if (h === undefined)
|
|
240
|
+
return false;
|
|
241
|
+
h.tunnel = { ...h.tunnel, ...tunnel };
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/** Called once when a share ends by anything but its command's connection (the run stopping). */
|
|
245
|
+
onEnd(user, ended) {
|
|
246
|
+
const h = this.held.get(user);
|
|
247
|
+
if (h !== undefined)
|
|
248
|
+
h.ended = ended;
|
|
249
|
+
}
|
|
250
|
+
/** Ends a share: its login gone first, then its sessions, its tunnel client, and with the last one the socket. */
|
|
251
|
+
async close(user, why) {
|
|
252
|
+
const h = this.held.get(user);
|
|
253
|
+
if (h === undefined)
|
|
254
|
+
return;
|
|
255
|
+
this.held.delete(user);
|
|
256
|
+
const opening = this.socket?.opening;
|
|
257
|
+
if (opening === undefined)
|
|
258
|
+
return;
|
|
259
|
+
const socket = await opening;
|
|
260
|
+
const ended = socket.endSessions(user, "the share ended");
|
|
261
|
+
if (h.tunnel.pid !== undefined)
|
|
262
|
+
(this.deps.stopTunnel ?? ((pid, port) => stopTunnelClient(pid, port, this.deps.log)))(h.tunnel.pid, socket.port);
|
|
263
|
+
this.deps.log(`share: ${user} closed: ${why}; ${ended} session${ended === 1 ? "" : "s"} ended`);
|
|
264
|
+
h.ended?.(why);
|
|
265
|
+
if (this.held.size > 0)
|
|
266
|
+
return;
|
|
267
|
+
this.socket = null;
|
|
268
|
+
this.live = null;
|
|
269
|
+
const counts = `${this.refused} login${this.refused === 1 ? "" : "s"} refused, ${this.dropped} connection${this.dropped === 1 ? "" : "s"} dropped before a login`;
|
|
270
|
+
this.refused = 0;
|
|
271
|
+
this.dropped = 0;
|
|
272
|
+
this.unsaid = 0;
|
|
273
|
+
this.lastSaid = -Infinity;
|
|
274
|
+
await socket.close();
|
|
275
|
+
this.deps.log(`share: the share's socket closed (${counts})`);
|
|
276
|
+
}
|
|
277
|
+
/** Every share ended (the run stopping). */
|
|
278
|
+
async closeAll(why) { await Promise.all([...this.held.keys()].map((u) => this.close(u, why))); }
|
|
279
|
+
state() {
|
|
280
|
+
if (this.socket === null || this.live === null)
|
|
281
|
+
return null;
|
|
282
|
+
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,
|
|
284
|
+
endsAt: h.tunnel.endsAt ?? null, state: h.tunnel.state ?? "opening" })) };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason share`: a run on a laptop reached by a tool in the cloud, through a free tunnel service, with TLS end to end
|
|
3
|
+
* between that tool and Mason (docs/POSTGRES-API.md "From a cloud BI tool"). This is the command's side: it makes the
|
|
4
|
+
* password, asks the run for a socket that requires TLS (`POST /api/share`, src/cli/share-run.ts), starts the tunnel's
|
|
5
|
+
* client (src/cli/tunnel.ts) pointed at that socket, prints what to type into the tool, and holds all of it in the
|
|
6
|
+
* foreground: the address is temporary by nature, and a share nobody is watching is a public login nobody is watching.
|
|
7
|
+
*
|
|
8
|
+
* ITS CONNECTION TO THE RUN IS THE SHARE: however this process ends the run closes the share, a `kill -9` included.
|
|
9
|
+
*
|
|
10
|
+
* A FREE TUNNEL ENDS THE SHARE WHEN IT ENDS: there is no reconnect, because a new tunnel is a new address the person would
|
|
11
|
+
* have to copy anyway; this says so and exits 1 with what to run next, and the next run gives a new address and a new
|
|
12
|
+
* password. WITH A PAID TOKEN the address survives, so the client is opened again, the password unchanged, and it is said.
|
|
13
|
+
*
|
|
14
|
+
* WHAT `--json` PRINTS IS ONE OBJECT, ONCE, when the address is known; everything after it (a reconnect, the end and
|
|
15
|
+
* why) is on stderr with the exit code, so stdout stays one document.
|
|
16
|
+
*/
|
|
17
|
+
import { once } from "node:events";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { failed, jsonText, parseCommand, reportText } from "./command.js";
|
|
20
|
+
import { guarded, runFailure } from "./commands.js";
|
|
21
|
+
import { callRun, holdRun } from "./control.js";
|
|
22
|
+
import { reach } from "./state.js";
|
|
23
|
+
import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
|
|
24
|
+
import { alive } from "./runs.js";
|
|
25
|
+
import { appEnvValue } from "../lib/dotenv.js";
|
|
26
|
+
import { newSharePassword, shareSecret } from "./share-run.js";
|
|
27
|
+
import { probeShare, startTunnel, sweepTunnelFiles, TOKEN_RE, TUNNELS, tunnelPlan } from "./tunnel.js";
|
|
28
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
29
|
+
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
30
|
+
const DEFAULT_TIMEOUT_S = 30;
|
|
31
|
+
/** The first wait before a dropped tunnel is opened again, and the longest. */
|
|
32
|
+
const RECONNECT_FIRST_MS = 1_000;
|
|
33
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
34
|
+
export const SHARE = {
|
|
35
|
+
needs: "run",
|
|
36
|
+
name: "share",
|
|
37
|
+
summary: "share the synced data with a tool outside this machine: a temporary address, TLS end to end, a password for this run",
|
|
38
|
+
usage: `usage: mason share [--role <role>] [--via pinggy|bore] [--timeout <seconds>] [--json]
|
|
39
|
+
|
|
40
|
+
For a BI tool in the cloud, which cannot reach 127.0.0.1: opens a temporary public address for this project's Postgres
|
|
41
|
+
API, through a free tunnel service, and prints what to type into the tool. It stays in the foreground: Ctrl-C ends
|
|
42
|
+
the share. Your local tools notice nothing: the share has a socket of its own, and no session is dropped.
|
|
43
|
+
|
|
44
|
+
The socket requires TLS of every connection, so a service that forwards bytes cannot read them. The certificate is
|
|
45
|
+
self-signed (the tool connects with sslmode=require, and checks no certificate), so a malicious service could sit in
|
|
46
|
+
the middle of the handshake: this is for trying Mason, on data you may send that way. mason serve on a host is the
|
|
47
|
+
way to keep a dashboard running.
|
|
48
|
+
|
|
49
|
+
The password is made here, printed once and good for this run alone: it is kept nowhere, works on this address only,
|
|
50
|
+
and is gone when the share ends. A mason user's password and an API token are refused here. What the share reads is
|
|
51
|
+
one role of mason/roles.yml: --role names it; with one role in the file that one is used, and with several you are
|
|
52
|
+
asked, or with --json refused with the line to run. With no roles file it reads every synced table, and says so.
|
|
53
|
+
|
|
54
|
+
--via pinggy (the default) needs only ssh; a free tunnel ends after about an hour, and the share with it: run mason
|
|
55
|
+
share again for a new address and a new password. MASON_PINGGY_TOKEN (the shell or .env, never a flag: a command
|
|
56
|
+
line is visible to every user of the machine), a paid Pinggy token, keeps the address, so a tunnel that drops is opened
|
|
57
|
+
again by itself, the password unchanged. --via bore needs bore (cargo install bore-cli).
|
|
58
|
+
|
|
59
|
+
mason share
|
|
60
|
+
mason share --role sales
|
|
61
|
+
mason share --via bore
|
|
62
|
+
|
|
63
|
+
--role <role> what it reads: a role of mason/roles.yml
|
|
64
|
+
--via <tunnel> pinggy (default) or bore
|
|
65
|
+
--timeout <s> how long the tunnel has to give its address (default ${DEFAULT_TIMEOUT_S})
|
|
66
|
+
--json as data, once the address is known: { share: { host, port, database, user, password, sslmode,
|
|
67
|
+
fingerprint, role, reads, via, endsAt (ISO 8601, or null), verified (the address answered with this
|
|
68
|
+
share's certificate; false: it could not be reached from this machine) }, next }; the command
|
|
69
|
+
then holds the share: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
70
|
+
`,
|
|
71
|
+
options: { role: { type: "string" }, via: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
72
|
+
json: ["share", "next"],
|
|
73
|
+
};
|
|
74
|
+
/** A role from what a person typed: a choice's number or its name; null for anything else. */
|
|
75
|
+
export function chosenRole(answer, choices) {
|
|
76
|
+
const a = answer.trim();
|
|
77
|
+
const byNumber = /^\d+$/.test(a) ? choices[Number(a) - 1] : undefined;
|
|
78
|
+
return byNumber?.role ?? choices.find((c) => c.role === a)?.role ?? null;
|
|
79
|
+
}
|
|
80
|
+
/** Whether a person is there to ask: a terminal both ways, and no --json (never an environment variable). */
|
|
81
|
+
const canAsk = (json) => !json && process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
82
|
+
/** Asks a person which role: the answer, or null for an empty line, an end of input or something that is no choice. */
|
|
83
|
+
async function askRole(choices) {
|
|
84
|
+
say("Which role does this share read as? Anyone with its address and password reads what it reads.");
|
|
85
|
+
choices.forEach((c, i) => say(` ${i + 1}. ${c.role}: ${c.reads}${c.default ? " (the file's default)" : ""}`));
|
|
86
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
87
|
+
const closed = once(rl, "close").then(() => null);
|
|
88
|
+
const answer = await Promise.race([rl.question("Role (a number or a name): "), closed]);
|
|
89
|
+
rl.close();
|
|
90
|
+
return answer === null ? null : chosenRole(answer, choices);
|
|
91
|
+
}
|
|
92
|
+
/** The run's refusal with its own next steps (`failure.next`, src/cli/run-server.ts sendFailure). */
|
|
93
|
+
function refusalOf(reply) {
|
|
94
|
+
const next = reply.body.failure?.next ?? [];
|
|
95
|
+
return runFailure(reply, next.map((n) => n.what === undefined ? [n.command] : [n.command, n.what]));
|
|
96
|
+
}
|
|
97
|
+
/** The share opened by the run: asked once, and again with what a person chose. */
|
|
98
|
+
async function openOnRun(run, secret, role, json) {
|
|
99
|
+
const asked = await holdRun(run, "/api/share", { secret, ...(role !== undefined ? { role } : {}) });
|
|
100
|
+
if ("first" in asked)
|
|
101
|
+
return asked;
|
|
102
|
+
const choices = asked.body.choices;
|
|
103
|
+
if (choices === undefined || role !== undefined || !canAsk(json))
|
|
104
|
+
return refusalOf(asked);
|
|
105
|
+
const chosen = await askRole(choices);
|
|
106
|
+
if (chosen === null)
|
|
107
|
+
return refusalOf(asked);
|
|
108
|
+
const again = await holdRun(run, "/api/share", { secret, role: chosen });
|
|
109
|
+
return "first" in again ? again : refusalOf(again);
|
|
110
|
+
}
|
|
111
|
+
/** `mason status`'s and the report's words for when it ends. */
|
|
112
|
+
function endsText(endsAt, now) {
|
|
113
|
+
if (endsAt === null)
|
|
114
|
+
return "when this command ends (Ctrl-C)";
|
|
115
|
+
const minutes = Math.max(0, Math.round((Date.parse(endsAt) - now) / 60_000));
|
|
116
|
+
return `in about ${minutes} minutes, at ${new Date(endsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: the free tunnel's limit (mason share again gives a new address and a new password)`;
|
|
117
|
+
}
|
|
118
|
+
/** What a tunnel's address is checked to be, once the client gave it: the Failure when something else answers there. */
|
|
119
|
+
async function verified(t, fingerprint) {
|
|
120
|
+
const probe = await probeShare(t, fingerprint);
|
|
121
|
+
if (probe === "other") {
|
|
122
|
+
return { message: `${t.host}:${t.port} is not this share: something else answers there`, code: "failed", why: "the tunnel printed that address, and the certificate there is not this share's: its output may have changed, or something stands in the way",
|
|
123
|
+
fix: "nothing was left open. Do not connect a tool to that address", next: [["mason share --via bore", "another tunnel"]] };
|
|
124
|
+
}
|
|
125
|
+
return probe === "same";
|
|
126
|
+
}
|
|
127
|
+
export async function share(argv) {
|
|
128
|
+
const f = parseCommand(SHARE, argv);
|
|
129
|
+
if (typeof f === "number")
|
|
130
|
+
return f;
|
|
131
|
+
const v = f.values;
|
|
132
|
+
const json = v.json;
|
|
133
|
+
if (f.positionals.length)
|
|
134
|
+
return failed("share", { message: "it takes no argument: --role names what it reads", code: "usage", usage: SHARE.usage }, { json });
|
|
135
|
+
const via = (v.via ?? "pinggy");
|
|
136
|
+
if (!TUNNELS.includes(via))
|
|
137
|
+
return failed("share", { message: `--via must be ${TUNNELS.join(" or ")}, not ${JSON.stringify(v.via)}`, code: "usage", usage: SHARE.usage }, { json });
|
|
138
|
+
const timeoutS = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
|
|
139
|
+
if (!Number.isFinite(timeoutS) || timeoutS <= 0)
|
|
140
|
+
return failed("share", { message: `--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`, code: "usage" }, { json });
|
|
141
|
+
const w = await reach(SHARE, json);
|
|
142
|
+
if (w === null || !("run" in w))
|
|
143
|
+
return 1;
|
|
144
|
+
const { run, project } = w;
|
|
145
|
+
const token = via === "pinggy" ? appEnvValue(project.root, "MASON_PINGGY_TOKEN")?.value ?? null : null;
|
|
146
|
+
if (token !== null && !TOKEN_RE.test(token)) {
|
|
147
|
+
return failed("share", { message: "MASON_PINGGY_TOKEN is not a token: it holds a character no Pinggy token has", code: "usage", fix: "set it to the token as the Pinggy dashboard shows it" }, { json });
|
|
148
|
+
}
|
|
149
|
+
return guarded("share", json, async () => {
|
|
150
|
+
sweepTunnelFiles(project.dir, alive);
|
|
151
|
+
const password = newSharePassword();
|
|
152
|
+
const held = await openOnRun(run, shareSecret(password), v.role, json);
|
|
153
|
+
if ("message" in held)
|
|
154
|
+
return failed("share", held, { json });
|
|
155
|
+
const opened = held.first;
|
|
156
|
+
return holding({ run, held, opened, password, json, plan: () => tunnelPlan(via, { port: opened.port, home: project.dir, token }), timeoutMs: timeoutS * 1000 });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** The share from its socket on: the tunnel, the report, and the wait for its end. Everything is left closed on every way out. */
|
|
160
|
+
/** `send`, each call begun once the one before ended: an "up" must never be overtaken and set back by its "opening". */
|
|
161
|
+
export function inOrder(send) {
|
|
162
|
+
let last = Promise.resolve();
|
|
163
|
+
return (t) => (last = last.then(() => send(t)));
|
|
164
|
+
}
|
|
165
|
+
async function holding(h) {
|
|
166
|
+
const { run, held, opened, password, json } = h;
|
|
167
|
+
const tell = inOrder(async (t) => {
|
|
168
|
+
// what mason status shows, and the run's way to stop the client: the run gone is said by `held.ended`, the share's end
|
|
169
|
+
await callRun(run, "POST", `/api/share/${encodeURIComponent(opened.user)}`, t).catch((e) => say(` the run was not told of the tunnel: ${e.message}`));
|
|
170
|
+
});
|
|
171
|
+
const stop = new Promise((resolve) => { process.once("SIGINT", () => resolve("SIGINT")); process.once("SIGTERM", () => resolve("SIGTERM")); });
|
|
172
|
+
const runEnded = held.ended.then((why) => ({ runEnded: why ?? "the connection to Mason was lost" }));
|
|
173
|
+
const steps = stepRenderer({ line: (t) => { if (!json)
|
|
174
|
+
say(t); }, live: !json && process.stderr.isTTY ? { write: (t) => { process.stderr.write(t); } } : null, style: styleErr() });
|
|
175
|
+
steps.done(`A socket of its own for the share, TLS required (127.0.0.1:${opened.port})`);
|
|
176
|
+
const open = async () => {
|
|
177
|
+
const plan = h.plan();
|
|
178
|
+
const t = await startTunnel(plan, { timeoutMs: h.timeoutMs, onPid: (pid) => { void tell({ pid, via: plan.via, state: "opening" }); } });
|
|
179
|
+
if ("message" in t)
|
|
180
|
+
return t;
|
|
181
|
+
const checked = await verified(t, opened.fingerprint);
|
|
182
|
+
if (typeof checked !== "boolean") {
|
|
183
|
+
t.stop();
|
|
184
|
+
return checked;
|
|
185
|
+
}
|
|
186
|
+
return { tunnel: t, verified: checked };
|
|
187
|
+
};
|
|
188
|
+
const plan = h.plan();
|
|
189
|
+
steps.begin(`Opening a tunnel (${plan.via})`);
|
|
190
|
+
const opening = open();
|
|
191
|
+
const first = await Promise.race([opening, stop, runEnded]);
|
|
192
|
+
if (typeof first === "string" || "runEnded" in first) {
|
|
193
|
+
// ended while it opened: the tunnel, if it comes, goes at once
|
|
194
|
+
void opening.then((o) => { if ("tunnel" in o)
|
|
195
|
+
o.tunnel.stop(); }).catch((e) => say(` the tunnel: ${e.message}`));
|
|
196
|
+
steps.fail();
|
|
197
|
+
held.close();
|
|
198
|
+
return typeof first === "string" ? 0 : failed("share", ended(first.runEnded), { json });
|
|
199
|
+
}
|
|
200
|
+
if ("message" in first) {
|
|
201
|
+
steps.fail();
|
|
202
|
+
held.close();
|
|
203
|
+
return failed("share", first, { json });
|
|
204
|
+
}
|
|
205
|
+
let tunnel = first.tunnel;
|
|
206
|
+
steps.done(`Tunnel open: ${tunnel.host}:${tunnel.port}`);
|
|
207
|
+
await tell({ host: tunnel.host, port: tunnel.port, endsAt: tunnel.endsAt, state: "up", pid: tunnel.pid });
|
|
208
|
+
const next = [["mason status", "the share, its sessions and the logins refused"], ["mason logs --requests", "what the tool asks"]];
|
|
209
|
+
if (json) {
|
|
210
|
+
out(jsonText(SHARE, { share: { host: tunnel.host, port: tunnel.port, database: opened.database, user: opened.user, password, sslmode: "require", fingerprint: opened.fingerprint,
|
|
211
|
+
role: opened.role, reads: opened.reads, via: plan.via, endsAt: tunnel.endsAt, verified: first.verified } }, next));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const st = styleOut();
|
|
215
|
+
out(reportText({
|
|
216
|
+
headline: "Sharing the synced data with a tool outside this machine",
|
|
217
|
+
details: [["host", tunnel.host], ["port", String(tunnel.port)], ["database", opened.database], ["user", opened.user], ["password", password], ["sslmode", "require"],
|
|
218
|
+
["certificate", `SHA-256 ${opened.fingerprint}`], ["reads", opened.role === null ? `${opened.reads} (this project has no roles file)` : `the role ${opened.role}: ${opened.reads}`],
|
|
219
|
+
["via", plan.via], ["ends", endsText(tunnel.endsAt, Date.now())]],
|
|
220
|
+
tips: [
|
|
221
|
+
"Paste it into the tool now: the password is printed this once, is kept nowhere, and ends with this command.",
|
|
222
|
+
...(opened.role === null ? ["With no roles file, anyone with this address and password reads every synced table: mason/roles.yml narrows it."] : []),
|
|
223
|
+
...(first.verified ? [] : [`${tunnel.host}:${tunnel.port} could not be reached from this machine, so it is not verified: a firewall here may be why, and a tool elsewhere may reach it.`]),
|
|
224
|
+
"A self-signed certificate: a service that forwards bytes cannot read them, a malicious one could sit in the middle. For anything lasting: mason help production.",
|
|
225
|
+
],
|
|
226
|
+
next,
|
|
227
|
+
}, st));
|
|
228
|
+
say("Holding the share: Ctrl-C ends it.");
|
|
229
|
+
}
|
|
230
|
+
// THE WAIT: the signal, the run, or the tunnel; a tunnel that keeps its address is opened again
|
|
231
|
+
for (;;) {
|
|
232
|
+
const end = await Promise.race([stop, runEnded, tunnel.ended.then((why) => ({ tunnelEnded: why }))]);
|
|
233
|
+
if (typeof end === "string") {
|
|
234
|
+
tunnel.stop();
|
|
235
|
+
await Promise.race([tunnel.ended, new Promise((r) => setTimeout(r, 2_500).unref())]);
|
|
236
|
+
held.close();
|
|
237
|
+
if (json)
|
|
238
|
+
say("The share ended.");
|
|
239
|
+
else
|
|
240
|
+
out(reportText({ headline: "The share ended: its address no longer answers, and its password no longer works", next: [["mason share", "a new address and a new password"]] }));
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
if ("runEnded" in end) {
|
|
244
|
+
tunnel.stop();
|
|
245
|
+
held.close();
|
|
246
|
+
return failed("share", ended(end.runEnded), { json: false });
|
|
247
|
+
}
|
|
248
|
+
if (!plan.reconnects) {
|
|
249
|
+
held.close();
|
|
250
|
+
return failed("share", { ...ended(`its tunnel closed (${end.tunnelEnded})`), fix: "a free tunnel lasts about an hour: the address is gone, and the password no longer works" }, { json: false });
|
|
251
|
+
}
|
|
252
|
+
const again = await reconnect({ open, tell, why: end.tunnelEnded, stop, runEnded, was: tunnel });
|
|
253
|
+
if (again === null)
|
|
254
|
+
continue; // a signal or the run's end: the next turn of the loop reads it
|
|
255
|
+
tunnel = again;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** The share's end as a failure: what ended it, and the one way on. */
|
|
259
|
+
const ended = (why) => ({ message: `the share ended: ${why}`, code: "failed", next: [["mason share", "a new address and a new password"]] });
|
|
260
|
+
/** Opens a dropped tunnel again until it is up, saying so each time; null when a signal or the run's end came first. */
|
|
261
|
+
async function reconnect(o) {
|
|
262
|
+
say(`The tunnel dropped (${o.why}): opening it again; the password stays the same.`);
|
|
263
|
+
await o.tell({ state: "reconnecting" });
|
|
264
|
+
let waitMs = RECONNECT_FIRST_MS;
|
|
265
|
+
for (;;) {
|
|
266
|
+
const waited = await Promise.race([new Promise((r) => setTimeout(() => r("waited"), waitMs).unref()), o.stop.then(() => "ended"), o.runEnded.then(() => "ended")]);
|
|
267
|
+
if (waited === "ended")
|
|
268
|
+
return null;
|
|
269
|
+
const opening = o.open();
|
|
270
|
+
const got = await Promise.race([opening, o.stop.then(() => null), o.runEnded.then(() => null)]);
|
|
271
|
+
if (got === null) {
|
|
272
|
+
void opening.then((a) => { if ("tunnel" in a)
|
|
273
|
+
a.tunnel.stop(); }).catch((e) => say(` the tunnel: ${e.message}`));
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if ("tunnel" in got) {
|
|
277
|
+
const t = got.tunnel;
|
|
278
|
+
const moved = t.host !== o.was.host || t.port !== o.was.port;
|
|
279
|
+
say(moved ? `Reconnected, at a NEW address: host ${t.host}, port ${t.port}. The password is the same; the tool needs the new host and port.` : `Reconnected: ${t.host}:${t.port}, the same address and password.`);
|
|
280
|
+
await o.tell({ host: t.host, port: t.port, endsAt: t.endsAt, state: "up", pid: t.pid });
|
|
281
|
+
return t;
|
|
282
|
+
}
|
|
283
|
+
say(` not yet: ${got.message}${got.why ? ` (${got.why})` : ""}; trying again in ${waitMs / 1000} s`);
|
|
284
|
+
waitMs = Math.min(waitMs * 2, RECONNECT_MAX_MS);
|
|
285
|
+
}
|
|
286
|
+
}
|