@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
package/dist/src/duck/token.js
CHANGED
|
@@ -14,11 +14,14 @@ export function mintToken(apiKey, seconds, now = Date.now()) {
|
|
|
14
14
|
const expiry = Math.floor(now / 1000) + seconds;
|
|
15
15
|
return { token: `${PREFIX}_${expiry}_${mac(apiKey, expiry)}`, expiresAt: new Date(expiry * 1000).toISOString() };
|
|
16
16
|
}
|
|
17
|
+
const TOKEN_RE = /^mason_(\d{1,12})_([A-Za-z0-9_-]{43})$/;
|
|
18
|
+
/** Whether a password is a token by its shape, good or not. */
|
|
19
|
+
export const isToken = (password) => TOKEN_RE.test(password);
|
|
17
20
|
/** null when the token is good; else why not, in a sentence a person reads after "password authentication failed" */
|
|
18
21
|
export function tokenProblem(apiKey, token, now = Date.now()) {
|
|
19
22
|
if (token === apiKey)
|
|
20
23
|
return "the API key is not a password here: mason user add <name> makes a user whose password does not expire";
|
|
21
|
-
const m =
|
|
24
|
+
const m = TOKEN_RE.exec(token);
|
|
22
25
|
if (m === null)
|
|
23
26
|
return "not a Mason user's password or token: mason user add <name> makes a user whose password does not expire";
|
|
24
27
|
const expiry = Number(m[1]);
|
package/dist/src/duck/types.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* UNION as its text. Each column that diverges carries the reason, and the runner logs it once per statement.
|
|
12
12
|
*/
|
|
13
13
|
import { Buffer } from "node:buffer";
|
|
14
|
-
import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
|
|
14
|
+
import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimeTZValue, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
|
|
15
15
|
import { jsonbText } from "./jsonb.js";
|
|
16
16
|
import { gapMark } from "./gaps.js";
|
|
17
|
+
import { heldType, ZONED_HELD } from "./held-types.js";
|
|
18
|
+
import { pgEvalSync } from "./pg-eval.js";
|
|
17
19
|
import { EncodeError, encodeValue, OID, TIMESTAMP } from "../api/postgres/encode.js";
|
|
18
20
|
const MICROS_PER_DAY = 86400000000n;
|
|
19
21
|
const DATE_INFINITY = 2_147_483_647, DATE_NEG_INFINITY = -2_147_483_647;
|
|
@@ -137,6 +139,12 @@ function timeText(micros) {
|
|
|
137
139
|
const secs = micros / 1000000n, frac = micros % 1000000n;
|
|
138
140
|
return `${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6).replace(/0+$/, "")}`}`;
|
|
139
141
|
}
|
|
142
|
+
/** A timetz as timetz_out writes it: the time, then its offset as `+HH`, `+HH:MM` or `+HH:MM:SS` (DuckDB's offset, as
|
|
143
|
+
* Postgres writes it, is east of UTC; its stored zone is west) */
|
|
144
|
+
export function timetzText(v) {
|
|
145
|
+
const abs = Math.abs(v.offset), s = abs % 60, m = Math.trunc(abs / 60) % 60;
|
|
146
|
+
return `${timeText(v.micros)}${v.offset < 0 ? "-" : "+"}${pad(Math.trunc(abs / 3600))}${m !== 0 || s !== 0 ? `:${pad(m)}` : ""}${s !== 0 ? `:${pad(s)}` : ""}`;
|
|
147
|
+
}
|
|
140
148
|
/** An interval as Postgres's `interval_out` writes it under IntervalStyle postgres: `1 year 2 mons 3 days 04:05:06.7` */
|
|
141
149
|
export function intervalText(months, days, micros) {
|
|
142
150
|
const parts = [];
|
|
@@ -213,7 +221,7 @@ function arrayElement(text) {
|
|
|
213
221
|
/** Postgres's array OIDs of the element OIDs this file sends */
|
|
214
222
|
const ARRAY_OID = {
|
|
215
223
|
16: 1000, 17: 1001, 20: 1016, 21: 1005, 23: 1007, 25: 1009, 700: 1021, 701: 1022, 1082: 1182, 1083: 1183,
|
|
216
|
-
1114: 1115, 1184: 1185, 1186: 1187, 1700: 1231, 2950: 2951, 3802: 3807,
|
|
224
|
+
1114: 1115, 1184: 1185, 1186: 1187, 1266: 1270, 1700: 1231, 2950: 2951, 3802: 3807,
|
|
217
225
|
};
|
|
218
226
|
function custom(name, oid, size, text, binary, divergence = null) {
|
|
219
227
|
return {
|
|
@@ -285,6 +293,8 @@ export function wireColumn(name, t) {
|
|
|
285
293
|
case DuckDBTypeId.TIMESTAMP_TZ: return timestampColumn(name, true);
|
|
286
294
|
case DuckDBTypeId.TIME:
|
|
287
295
|
return custom(name, 1083, 8, (v) => timeText(v.micros), (v) => { const b = Buffer.alloc(8); b.writeBigInt64BE(v.micros); return b; });
|
|
296
|
+
case DuckDBTypeId.TIME_TZ:
|
|
297
|
+
return custom(name, 1266, 12, (v) => timetzText(v), (v) => { const t = v; const b = Buffer.alloc(12); b.writeBigInt64BE(t.micros, 0); b.writeInt32BE(-t.offset, 8); return b; });
|
|
288
298
|
case DuckDBTypeId.INTERVAL:
|
|
289
299
|
return custom(name, 1186, 16, (v) => { const i = v; return intervalText(i.months, i.days, i.micros); }, (v) => { const i = v; const b = Buffer.alloc(16); b.writeBigInt64BE(i.micros, 0); b.writeInt32BE(i.days, 8); b.writeInt32BE(i.months, 12); return b; });
|
|
290
300
|
case DuckDBTypeId.BLOB:
|
|
@@ -313,7 +323,7 @@ export function wireColumn(name, t) {
|
|
|
313
323
|
// int4, its DECIMAL as a numeric, its JSON as jsonb's canonical text. Where DuckDB's value cannot be written as
|
|
314
324
|
// Postgres's type (a timestamp where Postgres says date), DuckDB's own encoding stands and the column says why.
|
|
315
325
|
/** pg_type.typlen of the OIDs a result column may have */
|
|
316
|
-
const TYPLEN = { 16: 1, 18: 1, 19: 64, 20: 8, 21: 2, 23: 4, 26: 4, 700: 4, 701: 8, 1082: 4, 1083: 8, 1114: 8, 1184: 8, 1186: 16, 2950: 16 };
|
|
326
|
+
const TYPLEN = { 16: 1, 18: 1, 19: 64, 20: 8, 21: 2, 23: 4, 26: 4, 700: 4, 701: 8, 1082: 4, 1083: 8, 1114: 8, 1184: 8, 1186: 16, 1266: 12, 2950: 16 };
|
|
317
327
|
const PG_TEXT = new Set([25, 1043, 1042, 19, 18]);
|
|
318
328
|
const PG_INT = { 21: "int2", 23: "int4", 20: "int8" };
|
|
319
329
|
const DUCK_EXACT = new Set([DuckDBTypeId.TINYINT, DuckDBTypeId.SMALLINT, DuckDBTypeId.INTEGER, DuckDBTypeId.BIGINT, DuckDBTypeId.HUGEINT,
|
|
@@ -349,6 +359,45 @@ export function floatAsNumeric(x, digits = 15) {
|
|
|
349
359
|
}
|
|
350
360
|
return s === "-0" ? "0" : s;
|
|
351
361
|
}
|
|
362
|
+
/** the binary forms of the text-held types a driver asks for by default (pgjdbc: point, box): each a float8's bytes */
|
|
363
|
+
const HELD_BINARY = {
|
|
364
|
+
600: floats, 603: floats,
|
|
365
|
+
};
|
|
366
|
+
function floats(text) {
|
|
367
|
+
const parts = text.match(/-?(?:Infinity|NaN|[\d.]+(?:e[-+]?\d+)?)/g) ?? [];
|
|
368
|
+
const b = Buffer.alloc(parts.length * 8);
|
|
369
|
+
parts.forEach((p, i) => b.writeDoubleBE(Number(p), i * 8));
|
|
370
|
+
return b;
|
|
371
|
+
}
|
|
372
|
+
/** a time range's text in the session's zone: Postgres's own output, a value at a time, the last few remembered */
|
|
373
|
+
const zonedTexts = new Map();
|
|
374
|
+
function inZone(type, text, zone) {
|
|
375
|
+
const key = `${zone}\u0000${type}\u0000${text}`;
|
|
376
|
+
let written = zonedTexts.get(key);
|
|
377
|
+
if (written === undefined) {
|
|
378
|
+
written = pgEvalSync(`SELECT $1::${type}::text`, [text], zone)[0]?.[0] ?? text;
|
|
379
|
+
if (zonedTexts.size >= 1024)
|
|
380
|
+
zonedTexts.clear();
|
|
381
|
+
zonedTexts.set(key, written);
|
|
382
|
+
}
|
|
383
|
+
return written;
|
|
384
|
+
}
|
|
385
|
+
/** a column of a type held as its text. A value's text only: Mason writes the binary form of few of them */
|
|
386
|
+
function heldColumn(name, oid, own, size) {
|
|
387
|
+
const binary = HELD_BINARY[oid], zoned = ZONED_HELD.get(oid);
|
|
388
|
+
return {
|
|
389
|
+
name, oid, size, divergence: null,
|
|
390
|
+
encode: (v, format, s) => {
|
|
391
|
+
if (v === null)
|
|
392
|
+
return null;
|
|
393
|
+
if (format === 0)
|
|
394
|
+
return zoned !== undefined ? Buffer.from(inZone(zoned, str(v), s.timeZone), "utf8") : own.encode(v, 0, s);
|
|
395
|
+
if (binary !== undefined)
|
|
396
|
+
return binary(str(v));
|
|
397
|
+
throw new EncodeError(`column ${JSON.stringify(name)} (type OID ${oid}) is held as its text and cannot be sent in binary by Mason: ask for text (${gapMark("binary-held-type")})`, "0A000", "binary-held-type");
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
352
401
|
/** The wire column of a result column whose Postgres type the analysis gave, holding DuckDB values of type `t`. */
|
|
353
402
|
export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
354
403
|
const own = wireColumn(name, t);
|
|
@@ -360,6 +409,16 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
|
360
409
|
return retyped(viaApi(name, "numeric", (v) => padDecimals(exactText(v), (typmod - 4) & 0xffff)));
|
|
361
410
|
if (own.oid === oid)
|
|
362
411
|
return retyped(own);
|
|
412
|
+
// a type the copy holds as its text, or an array of one (src/duck/held-types.ts): the text, under Postgres's OID
|
|
413
|
+
const held = heldType(oid);
|
|
414
|
+
if (held !== undefined && (own.oid === 25 || own.oid === 1009))
|
|
415
|
+
return heldColumn(name, oid, own, held.oid === oid ? held.size : -1);
|
|
416
|
+
// an oid element of an oid[], held as its digits
|
|
417
|
+
if (oid === 26 && own.oid === 25)
|
|
418
|
+
return custom(name, 26, 4, str, (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(str(v))); return b; });
|
|
419
|
+
// an oid: DuckDB's UINTEGER
|
|
420
|
+
if (oid === 26 && DUCK_EXACT.has(t.typeId))
|
|
421
|
+
return custom(name, 26, 4, (v) => exactText(v), (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(exactText(v))); return b; });
|
|
363
422
|
// an inet: stored as Postgres's text with its mask (`10.3.6.45/32`), sent as inet_out writes it (a full mask dropped)
|
|
364
423
|
if (oid === 869 && own.oid === 25)
|
|
365
424
|
return custom(name, 869, -1, (v) => inetOut(str(v)), (v) => inetBytes(str(v)));
|
|
@@ -404,12 +463,9 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
|
404
463
|
}
|
|
405
464
|
return { ...own, divergence: `Postgres says type OID ${oid}; the engine computed ${t.toString()}, sent as its own type` };
|
|
406
465
|
}
|
|
407
|
-
/** How a cell of an exact numeric (src/duck/rewrite.ts ExactColumn) reads as a decimal text, by DuckDB's type: an exact
|
|
408
|
-
* number's digits, a float as Postgres's cast to numeric writes it — or null where the cell needs digits DuckDB did not
|
|
409
|
-
* keep (a double where the program wants exact ones). */
|
|
410
466
|
export function cellText(t, conv) {
|
|
411
467
|
// a numeric's text as Postgres's numeric_out wrote it (a text read by numeric_in, src/duck/pg-eval.ts)
|
|
412
|
-
if (conv === "text")
|
|
468
|
+
if (conv === "text" || conv === "parsed")
|
|
413
469
|
return t.typeId === DuckDBTypeId.VARCHAR ? (v) => String(v) : null;
|
|
414
470
|
if (DUCK_EXACT.has(t.typeId))
|
|
415
471
|
return exactText;
|
package/dist/src/duck/usage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
-
import { History, Streak } from "./history.js";
|
|
2
|
+
import { History, Streak, cutoff } from "./history.js";
|
|
3
3
|
import { outcomeOf } from "../api/request-log.js";
|
|
4
4
|
export const USAGE_TABLE = `${MASON_SCHEMA}.usage`;
|
|
5
5
|
export const SIZES_TABLE = `${MASON_SCHEMA}.copy_sizes`;
|
|
@@ -265,10 +265,12 @@ export class UsageSampler {
|
|
|
265
265
|
this.log(`usage: measured ${rows.length} synced tables' own size in ${Math.round(performance.now() - t0)} ms (every ${Math.round((this.options.sizeEveryMs ?? SIZE_EVERY_MS) / 60_000)} min)`);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
|
-
/** Deletes the samples and sizes older than the retention
|
|
268
|
+
/** Deletes the samples and sizes older than the retention, counted back from the module's ONE clock (`now`, which
|
|
269
|
+
* stamps each sample), never the engine's `now()` (src/duck/events.ts `trim` says why). */
|
|
269
270
|
async trim() {
|
|
270
|
-
|
|
271
|
-
await this.history.writer.run(`DELETE FROM ${
|
|
271
|
+
const before = cutoff(this.now(), this.days);
|
|
272
|
+
await this.history.writer.run(`DELETE FROM ${USAGE_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
|
|
273
|
+
await this.history.writer.run(`DELETE FROM ${SIZES_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
|
|
272
274
|
}
|
|
273
275
|
/**
|
|
274
276
|
* The samples from `since` until `until`, oldest first; with `stepSeconds`, re-bucketed in ONE query: buckets of that
|
package/dist/src/duck/users.js
CHANGED
|
@@ -24,11 +24,19 @@
|
|
|
24
24
|
* user's session, the file read again when its inode, size or mtime moved), so a removal refuses the next connection
|
|
25
25
|
* at once and ends the sessions that user holds at their next statement, without a restart. FAIL CLOSED: a file that
|
|
26
26
|
* cannot be read refuses every user login with the reason (57P03, "could not tell"), never "live"; no file is no users.
|
|
27
|
+
*
|
|
28
|
+
* A USER'S ADDRESS RANGES (`from`, `mason user add --from`, `mason user from`): where it may connect from, read by the one
|
|
29
|
+
* grammar `MASON_PG_PROXY_FROM` is read by (src/lib/cidr.ts). Judged at login BEFORE the password, on the session's own
|
|
30
|
+
* address (`Session.clientAddr`, the PROXY header's client behind a named proxy), and again before each statement; an
|
|
31
|
+
* address that could not be read is outside every list (fail closed). While any user has ranges, a name that is no user
|
|
32
|
+
* is refused in the same words, and with the same work, as an address outside them: a limited user cannot be told from
|
|
33
|
+
* a missing one by the answer or its time (a user with no ranges still answers a wrong password 28P01).
|
|
27
34
|
*/
|
|
28
35
|
import * as crypto from "node:crypto";
|
|
29
36
|
import * as fs from "node:fs";
|
|
30
37
|
import * as path from "node:path";
|
|
31
|
-
import { ApiError, USER_DISABLED } from "../api/error.js";
|
|
38
|
+
import { ADDRESS_NOT_ALLOWED, ApiError, USER_DISABLED } from "../api/error.js";
|
|
39
|
+
import { cidrOf, cidrText, inRanges, rangeOf } from "../lib/cidr.js";
|
|
32
40
|
import { tokenProblem } from "./token.js";
|
|
33
41
|
import { ATTRIBUTE_NAME_RE } from "./row-filter.js";
|
|
34
42
|
export const USERS_FILE = "users.json";
|
|
@@ -36,6 +44,46 @@ export const USERS_FILE = "users.json";
|
|
|
36
44
|
* capitalises it is told so, never another user. */
|
|
37
45
|
export const USER_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
|
|
38
46
|
export const USER_NAME_RULE = "1 to 63 lower case letters, digits, \"_\", \".\" or \"-\", starting with a letter or digit (e.g. metabase)";
|
|
47
|
+
/**
|
|
48
|
+
* A user's address ranges as given (`--from`, the file, `/api/users`): each read by the one grammar (src/lib/cidr.ts
|
|
49
|
+
* `cidrOf`), written back in its one form (`cidrText`: `10.0.0.0/16`, `2001:db8::/32`, `192.0.2.10`), a range given
|
|
50
|
+
* twice once; or every entry that is not a range, each with what is wrong. An empty list is no list of ranges: the way
|
|
51
|
+
* to any address is to have none (`mason user from <name> --any`).
|
|
52
|
+
*/
|
|
53
|
+
export function userRanges(list) {
|
|
54
|
+
if (list.length === 0)
|
|
55
|
+
return { problem: "no address range: name one or more (10.0.0.0/16, 2001:db8::/32, 192.0.2.10)" };
|
|
56
|
+
if (list.length > MAX_RANGES)
|
|
57
|
+
return { problem: `${list.length} address ranges: a user takes at most ${MAX_RANGES}` };
|
|
58
|
+
const from = [], ranges = [], wrong = [];
|
|
59
|
+
for (const entry of list) {
|
|
60
|
+
const c = cidrOf(entry);
|
|
61
|
+
if ("problem" in c) {
|
|
62
|
+
wrong.push(c.problem);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const t = cidrText(c);
|
|
66
|
+
if (!from.includes(t)) {
|
|
67
|
+
from.push(t);
|
|
68
|
+
ranges.push(rangeOf(c));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return wrong.length ? { problem: wrong.join("; ") } : { from, ranges };
|
|
72
|
+
}
|
|
73
|
+
/** The most address ranges one user takes (all input is untrusted: a bound). */
|
|
74
|
+
export const MAX_RANGES = 100;
|
|
75
|
+
/** Whether a user may connect from `address` (`Session.clientAddr`): any address when it has no ranges; with ranges, one
|
|
76
|
+
* in them, and never an address that could not be read (null), nor any when its ranges cannot be read (fail closed). */
|
|
77
|
+
export function addressAllowed(u, address) {
|
|
78
|
+
if (u.from === undefined)
|
|
79
|
+
return true;
|
|
80
|
+
if (address === null)
|
|
81
|
+
return false;
|
|
82
|
+
const r = userRanges(u.from);
|
|
83
|
+
return !("problem" in r) && inRanges(r.ranges, address);
|
|
84
|
+
}
|
|
85
|
+
/** an address as a refusal says it */
|
|
86
|
+
export const addressShown = (address) => address === null ? "an address that could not be read" : address;
|
|
39
87
|
/** a user's attributes as the file keeps them: a name each, a string or a list of strings */
|
|
40
88
|
function attributesValid(v) {
|
|
41
89
|
// a NUL ends the statement it is bound into, wherever it is: every statement of the user would fail as a syntax error
|
|
@@ -59,6 +107,8 @@ export const newUserId = () => crypto.randomBytes(8).toString("hex");
|
|
|
59
107
|
export const userPassword = (apiKey, name, id) => `mason_${id}_${mac(apiKey, name, id)}`;
|
|
60
108
|
/** Whether a password is a user's by its shape (else it is read as a token, src/duck/token.ts). */
|
|
61
109
|
export const isUserPassword = (password) => PASSWORD_RE.test(password);
|
|
110
|
+
/** What every `mason share` password begins with (src/cli/share-run.ts `newSharePassword`): no user's has it, `share` being no hex. */
|
|
111
|
+
export const SHARE_PASSWORD_PREFIX = "mason_share_";
|
|
62
112
|
/** Whether a user was added under this API key (one rotated since is no longer live). */
|
|
63
113
|
export const underKey = (u, apiKey) => u.key === keyFingerprint(apiKey);
|
|
64
114
|
/** The users the file lists; none when there is no file; why not, when it cannot be read — never an empty list then. */
|
|
@@ -91,7 +141,12 @@ function parseUsers(file, text) {
|
|
|
91
141
|
if (typeof o !== "object" || o === null || typeof o.name !== "string" || !USER_NAME_RE.test(o.name) || typeof o.id !== "string" || !/^[0-9a-f]{16}$/.test(o.id) ||
|
|
92
142
|
typeof o.addedAt !== "string" || typeof o.key !== "string" || (o.role !== undefined && (typeof o.role !== "string" || !ROLE_RE.test(o.role))) ||
|
|
93
143
|
(o.attributes !== undefined && !attributesValid(o.attributes))) {
|
|
94
|
-
return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?[, removedAt][, disabledAt] } as mason user add writes it` };
|
|
144
|
+
return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?, from?[, removedAt][, disabledAt] } as mason user add writes it` };
|
|
145
|
+
}
|
|
146
|
+
if (o.from !== undefined) {
|
|
147
|
+
const r = Array.isArray(o.from) && o.from.every((x) => typeof x === "string") ? userRanges(o.from) : { problem: "not a list of strings" };
|
|
148
|
+
if ("problem" in r)
|
|
149
|
+
return { problem: `${file}: users[${i}].from is not a list of address ranges as mason user from writes it: ${r.problem}` };
|
|
95
150
|
}
|
|
96
151
|
if (o.removedAt !== undefined && typeof o.removedAt !== "string") {
|
|
97
152
|
return { problem: `${file}: users[${i}].removedAt is not a timestamp as mason user remove writes it` };
|
|
@@ -100,7 +155,7 @@ function parseUsers(file, text) {
|
|
|
100
155
|
return { problem: `${file}: users[${i}].disabledAt is not a timestamp as mason user disable writes it` };
|
|
101
156
|
}
|
|
102
157
|
users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key, ...(typeof o.role === "string" ? { role: o.role } : {}),
|
|
103
|
-
...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}),
|
|
158
|
+
...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}), ...(Array.isArray(o.from) ? { from: o.from } : {}),
|
|
104
159
|
...(o.removedAt !== undefined ? { removedAt: o.removedAt } : {}), ...(o.disabledAt !== undefined ? { disabledAt: o.disabledAt } : {}) });
|
|
105
160
|
}
|
|
106
161
|
return { users };
|
|
@@ -182,6 +237,11 @@ export class LiveUsers {
|
|
|
182
237
|
apiKey;
|
|
183
238
|
seen = null;
|
|
184
239
|
read = { users: [] };
|
|
240
|
+
/** each live user's ranges, parsed once per read of the file (so a login or a statement costs a lookup, never a parse) */
|
|
241
|
+
ranges = new Map();
|
|
242
|
+
/** the longest of those lists: what a name that is no user is judged against, so its refusal costs what a limited
|
|
243
|
+
* user's does and the time of the answer does not tell them apart */
|
|
244
|
+
decoy = [];
|
|
185
245
|
constructor(file, apiKey) { this.file = file; this.apiKey = apiKey; }
|
|
186
246
|
/** the users not removed now (disabled ones included: their login is refused by name); why not, when the file cannot be read */
|
|
187
247
|
now() {
|
|
@@ -200,11 +260,38 @@ export class LiveUsers {
|
|
|
200
260
|
// a removed user is kept in the file for the list: it is no user to the listener
|
|
201
261
|
this.read = "problem" in r ? r : { users: r.users.filter(isActive) };
|
|
202
262
|
this.seen = stamp;
|
|
263
|
+
this.ranges = new Map();
|
|
264
|
+
this.decoy = [];
|
|
265
|
+
for (const u of "problem" in this.read ? [] : this.read.users) {
|
|
266
|
+
if (u.from === undefined)
|
|
267
|
+
continue;
|
|
268
|
+
const parsed = userRanges(u.from);
|
|
269
|
+
// the file was read whole, so every list parses; one that would not stays out of the map, which refuses it
|
|
270
|
+
if ("problem" in parsed)
|
|
271
|
+
continue;
|
|
272
|
+
this.ranges.set(u.id, parsed.ranges);
|
|
273
|
+
if (parsed.ranges.length > this.decoy.length)
|
|
274
|
+
this.decoy = parsed.ranges;
|
|
275
|
+
}
|
|
203
276
|
}
|
|
204
277
|
return this.read;
|
|
205
278
|
}
|
|
206
|
-
/**
|
|
207
|
-
|
|
279
|
+
/** Whether `address` is inside `ranges` (null: no list, any address); an address not read is inside none. The ranges
|
|
280
|
+
* are the parsed ones this read of the file holds, so this is a comparison, never a parse. */
|
|
281
|
+
inside(ranges, address) {
|
|
282
|
+
if (ranges === null)
|
|
283
|
+
return true;
|
|
284
|
+
return address !== null && inRanges(ranges, address);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* A connection's password, read as a user's: the session's token, or why it is refused. `from` is the client's address
|
|
288
|
+
* (`Session.clientAddr`; null when it could not be read). THE ADDRESS IS JUDGED FIRST, before the password: the user
|
|
289
|
+
* the startup packet names, held to its ranges (`inside`); and while any user has ranges, a name that is no
|
|
290
|
+
* user is refused the same way (`ADDRESS_NOT_ALLOWED`, whose words the listener writes from the name and the address
|
|
291
|
+
* alone), so a connection from outside cannot tell a limited user from a missing one by its answer. Only the log line
|
|
292
|
+
* says which (the ApiError's message).
|
|
293
|
+
*/
|
|
294
|
+
login(user, password, from = null) {
|
|
208
295
|
const m = PASSWORD_RE.exec(password);
|
|
209
296
|
if (m === null)
|
|
210
297
|
throw refusal(401, "not a Mason user's password: mason user add <name> makes a user and prints its password", user);
|
|
@@ -212,6 +299,13 @@ export class LiveUsers {
|
|
|
212
299
|
const now = this.now();
|
|
213
300
|
if ("problem" in now)
|
|
214
301
|
throw refusal(503, `the list of users cannot be read, so no user can connect: ${now.problem}`, user);
|
|
302
|
+
const named = now.users.find((u) => u.name === user);
|
|
303
|
+
// the same work on both sides: a limited user's own list, or for a name that is no user the longest list there is
|
|
304
|
+
const inside = this.inside(named === undefined ? this.decoy : named.from === undefined ? null : this.ranges.get(named.id) ?? [], from);
|
|
305
|
+
if (named !== undefined ? !inside : this.decoy.length > 0) {
|
|
306
|
+
throw refusal(401, named !== undefined ? `the user "${user}" may not connect from ${addressShown(from)}, outside its address ranges (mason user list shows them)`
|
|
307
|
+
: `no user "${user}" (from ${addressShown(from)}): refused as an address outside a user's ranges is, since some users have ranges`, user, ADDRESS_NOT_ALLOWED);
|
|
308
|
+
}
|
|
215
309
|
const found = now.users.find((u) => u.id === id);
|
|
216
310
|
const verifies = (name) => crypto.timingSafeEqual(Buffer.from(mac(this.apiKey, name, id)), Buffer.from(given));
|
|
217
311
|
if (found === undefined) {
|
|
@@ -237,7 +331,7 @@ export class LiveUsers {
|
|
|
237
331
|
* token as the file has the user NOW — its role as `mason user add --role` wrote it, so a role given or taken
|
|
238
332
|
* applies at the user's next statement, without a restart.
|
|
239
333
|
*/
|
|
240
|
-
recheck(token) {
|
|
334
|
+
recheck(token, from = null) {
|
|
241
335
|
if (token.user === undefined)
|
|
242
336
|
return token;
|
|
243
337
|
const now = this.now();
|
|
@@ -248,6 +342,10 @@ export class LiveUsers {
|
|
|
248
342
|
throw refusal(401, `the user "${token.user}" was removed`, token.user);
|
|
249
343
|
if (isDisabled(found))
|
|
250
344
|
throw refusal(401, `the user "${token.user}" was disabled`, token.user, USER_DISABLED);
|
|
345
|
+
// its ranges changed since it logged in: a session from outside them ends, as a new connection would be refused
|
|
346
|
+
if (!this.inside(found.from === undefined ? null : this.ranges.get(found.id) ?? [], from)) {
|
|
347
|
+
throw refusal(401, `the user "${token.user}" may not connect from ${addressShown(from)}: its address ranges are ${found.from.join(", ")}`, token.user, ADDRESS_NOT_ALLOWED);
|
|
348
|
+
}
|
|
251
349
|
if (found.role === token.role && JSON.stringify(found.attributes ?? null) === JSON.stringify(token.attributes ?? null))
|
|
252
350
|
return token;
|
|
253
351
|
const { role: _was, attributes: _had, ...rest } = token;
|
|
@@ -263,18 +361,21 @@ export const disabledMessage = (name) => `role "${name}" is not permitted to log
|
|
|
263
361
|
* HOW THE DUCKDB RUN'S LISTENER TAKES A PASSWORD (src/cli/dev.ts; the tests wire the same): a user's password (its shape)
|
|
264
362
|
* is the users file's to judge, and its session is asked again before every statement (a stat); anything else is read as a
|
|
265
363
|
* token, the kind `mason connect` minted before `mason user` (src/duck/token.ts: the API key used as a password is refused by name).
|
|
364
|
+
* A `mason share` password is refused by name too: it was never this port's, and making a user is not the way out.
|
|
266
365
|
*/
|
|
267
366
|
export function listenerLogins(users, apiKey) {
|
|
268
367
|
return {
|
|
269
|
-
authenticate: (password, user) => {
|
|
368
|
+
authenticate: (password, user, from) => {
|
|
270
369
|
if (isUserPassword(password))
|
|
271
|
-
return users.login(user, password);
|
|
370
|
+
return users.login(user, password, from ?? null);
|
|
371
|
+
if (password.startsWith(SHARE_PASSWORD_PREFIX))
|
|
372
|
+
throw new Error("a mason share password logs in only at the address mason share printed, never on this port");
|
|
272
373
|
const problem = tokenProblem(apiKey, password);
|
|
273
374
|
if (problem !== null)
|
|
274
375
|
throw new Error(problem);
|
|
275
376
|
return { filters: [], subject: undefined };
|
|
276
377
|
},
|
|
277
|
-
recheck: async (t) => users.recheck(t),
|
|
378
|
+
recheck: async (t, from) => users.recheck(t, from ?? null),
|
|
278
379
|
recheckMs: 0,
|
|
279
380
|
};
|
|
280
381
|
}
|