@wpelevator/tug 0.4.0 → 0.5.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 +7 -8
- package/dist/agent.d.ts +0 -2
- package/dist/agent.js +8 -30
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +2 -2
- package/dist/cli-build.js +3 -4
- package/dist/cli.js +2 -33
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +2 -4
- package/dist/control.d.ts +4 -2
- package/dist/control.js +342 -366
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +9 -6
- package/dist/db.js +40 -26
- package/dist/docker-api.js +15 -3
- package/dist/protocol.d.ts +5 -5
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +24 -21
- package/package.json +1 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The portable subset shared by synchronous SQLite runtimes. Only anonymous ? bindings are supported. */
|
|
2
|
+
export type SqlValue = string | number | bigint | null | Uint8Array;
|
|
3
|
+
export type SqlRow = Record<string, SqlValue>;
|
|
4
|
+
export interface Db {
|
|
5
|
+
get<T = SqlRow>(sql: string, ...values: SqlValue[]): T | undefined;
|
|
6
|
+
all<T = SqlRow>(sql: string, ...values: SqlValue[]): T[];
|
|
7
|
+
run(sql: string, ...values: SqlValue[]): {
|
|
8
|
+
changes: number;
|
|
9
|
+
};
|
|
10
|
+
exec(sql: string): void;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
export type ConnectDb = (filename: string, options: {
|
|
14
|
+
readOnly: boolean;
|
|
15
|
+
}) => Db;
|
|
16
|
+
export declare class DbError extends Error {
|
|
17
|
+
readonly kind: 'unique' | 'busy';
|
|
18
|
+
constructor(kind: DbError['kind'], cause: Error);
|
|
19
|
+
}
|
|
20
|
+
export declare function isUniqueViolation(error: unknown): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class DbError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(kind, cause) {
|
|
4
|
+
super(cause.message, { cause });
|
|
5
|
+
this.name = 'DbError';
|
|
6
|
+
this.kind = kind;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isUniqueViolation(error) {
|
|
10
|
+
return error instanceof DbError && error.kind === 'unique';
|
|
11
|
+
}
|
package/dist/db-node.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { DbError } from "./db-driver.js";
|
|
3
|
+
function sqliteCall(operation) {
|
|
4
|
+
try {
|
|
5
|
+
return operation();
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof Error && 'errcode' in error) {
|
|
9
|
+
const code = Number(error.errcode);
|
|
10
|
+
if (code === 1555 || code === 2067)
|
|
11
|
+
throw new DbError('unique', error);
|
|
12
|
+
if ((code & 255) === 5 || (code & 255) === 6)
|
|
13
|
+
throw new DbError('busy', error);
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Count anonymous placeholders outside SQL literals, quoted identifiers and comments. */
|
|
19
|
+
function checkBindings(sql, values) {
|
|
20
|
+
const tokens = sql.match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[[^\]]*\]|--[^\r\n]*|\/\*[\s\S]*?\*\/|\?\d*|[:@$][\w]+/g) ?? [];
|
|
21
|
+
const parameters = tokens.filter((token) => /^[?:@$]/.test(token));
|
|
22
|
+
if (parameters.some((token) => token !== '?'))
|
|
23
|
+
throw new TypeError('Only anonymous ? SQL parameters are supported');
|
|
24
|
+
if (parameters.length !== values.length)
|
|
25
|
+
throw new TypeError(`Expected ${parameters.length} SQL bindings, received ${values.length}`);
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
if (value !== null &&
|
|
28
|
+
typeof value !== 'string' &&
|
|
29
|
+
typeof value !== 'bigint' &&
|
|
30
|
+
!(typeof value === 'number' && Number.isFinite(value)) &&
|
|
31
|
+
!(value instanceof Uint8Array))
|
|
32
|
+
throw new TypeError('SQL bindings must be strings, finite numbers, bigints, null or Uint8Array');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
|
|
36
|
+
export const connectNodeDb = (filename, { readOnly }) => {
|
|
37
|
+
const database = sqliteCall(() => new DatabaseSync(filename, { readOnly }));
|
|
38
|
+
const prepare = (sql, values) => {
|
|
39
|
+
checkBindings(sql, values);
|
|
40
|
+
return database.prepare(sql);
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
get: (sql, ...values) => sqliteCall(() => {
|
|
44
|
+
const row = prepare(sql, values).get(...values);
|
|
45
|
+
return row === undefined ? undefined : { ...row };
|
|
46
|
+
}),
|
|
47
|
+
all: (sql, ...values) => sqliteCall(() => prepare(sql, values)
|
|
48
|
+
.all(...values)
|
|
49
|
+
.map((row) => ({ ...row }))),
|
|
50
|
+
run: (sql, ...values) => sqliteCall(() => {
|
|
51
|
+
const { changes } = prepare(sql, values).run(...values);
|
|
52
|
+
const count = Number(changes);
|
|
53
|
+
if (!Number.isSafeInteger(count))
|
|
54
|
+
throw new RangeError('SQL change count exceeds the safe integer range');
|
|
55
|
+
return { changes: count };
|
|
56
|
+
}),
|
|
57
|
+
exec: (sql) => sqliteCall(() => database.exec(sql)),
|
|
58
|
+
close: () => sqliteCall(() => database.close()),
|
|
59
|
+
};
|
|
60
|
+
};
|
package/dist/db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
import type { ConnectDb, Db } from './db-driver.ts';
|
|
2
|
+
export type { Db } from './db-driver.ts';
|
|
3
|
+
export declare const openDb: (filename: string) => Promise<Db>, openAgentDb: (filename: string) => Promise<Db>, openSnapshotDb: (filename: string) => Db;
|
|
4
4
|
export interface ProjectRow {
|
|
5
5
|
id: string;
|
|
6
6
|
name: string;
|
|
@@ -19,7 +19,6 @@ export interface NodeRow {
|
|
|
19
19
|
status: string;
|
|
20
20
|
labels_json: string | null;
|
|
21
21
|
agent_token_hash: string | null;
|
|
22
|
-
registry_token_hash: string | null;
|
|
23
22
|
last_seen_at: string | null;
|
|
24
23
|
created_at: string;
|
|
25
24
|
updated_at: string;
|
|
@@ -120,5 +119,9 @@ export interface AppliedStateRow {
|
|
|
120
119
|
service_states_json: string;
|
|
121
120
|
secret_versions_json: string;
|
|
122
121
|
}
|
|
123
|
-
|
|
124
|
-
export declare function
|
|
122
|
+
/** Shared policy for every driver, including read-only restore validation. */
|
|
123
|
+
export declare function databaseAccess(connect: ConnectDb): {
|
|
124
|
+
openDb: (filename: string) => Promise<Db>;
|
|
125
|
+
openAgentDb: (filename: string) => Promise<Db>;
|
|
126
|
+
openSnapshotDb: (filename: string) => Db;
|
|
127
|
+
};
|
package/dist/db.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { open } from 'sqlite';
|
|
1
|
+
import { connectNodeDb } from "./db-node.js";
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { ensureDir, now } from "./util.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// One composition point for all writable and snapshot connections.
|
|
5
|
+
export const { openDb, openAgentDb, openSnapshotDb } = databaseAccess(connectNodeDb);
|
|
6
|
+
function configure(db, writable, initialize) {
|
|
7
|
+
try {
|
|
8
|
+
db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;');
|
|
9
|
+
if (writable)
|
|
10
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
11
|
+
initialize?.(db);
|
|
12
|
+
return db;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
db.close();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* Preserve the initialization failure. */
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
11
23
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
/** Shared policy for every driver, including read-only restore validation. */
|
|
25
|
+
export function databaseAccess(connect) {
|
|
26
|
+
function openSnapshotDb(filename) {
|
|
27
|
+
return configure(connect(filename, { readOnly: true }), false);
|
|
28
|
+
}
|
|
29
|
+
async function openDb(filename) {
|
|
30
|
+
await ensureDir(path.dirname(filename));
|
|
31
|
+
return configure(connect(filename, { readOnly: false }), true, initializeControl);
|
|
32
|
+
}
|
|
33
|
+
async function openAgentDb(filename) {
|
|
34
|
+
await ensureDir(path.dirname(filename));
|
|
35
|
+
return configure(connect(filename, { readOnly: false }), true, (db) => db.exec(`
|
|
17
36
|
CREATE TABLE IF NOT EXISTS applied_state(
|
|
18
37
|
project TEXT PRIMARY KEY,
|
|
19
38
|
version TEXT,
|
|
@@ -27,14 +46,15 @@ CREATE TABLE IF NOT EXISTS event_state(
|
|
|
27
46
|
fingerprint TEXT NOT NULL,
|
|
28
47
|
phase TEXT NOT NULL
|
|
29
48
|
);
|
|
30
|
-
`);
|
|
31
|
-
|
|
49
|
+
`));
|
|
50
|
+
}
|
|
51
|
+
return { openDb, openAgentDb, openSnapshotDb };
|
|
32
52
|
}
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
function initializeControl(db) {
|
|
54
|
+
db.exec(`
|
|
35
55
|
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
36
56
|
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
|
|
37
|
-
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT,
|
|
57
|
+
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
38
58
|
CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
|
|
39
59
|
CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
|
|
40
60
|
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
|
|
@@ -51,13 +71,7 @@ CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT
|
|
|
51
71
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
52
72
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
53
73
|
`);
|
|
54
|
-
|
|
55
|
-
await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
/* column already exists */
|
|
59
|
-
}
|
|
60
|
-
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
74
|
+
const row = db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
61
75
|
if (!row)
|
|
62
|
-
|
|
76
|
+
db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
|
|
63
77
|
}
|
package/dist/docker-api.js
CHANGED
|
@@ -19,8 +19,14 @@ export const isNotFound = (error) => error instanceof DockerError && error.statu
|
|
|
19
19
|
/** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
|
|
20
20
|
export function splitImageRef(ref) {
|
|
21
21
|
const digest = ref.indexOf('@');
|
|
22
|
-
if (digest >= 0)
|
|
23
|
-
|
|
22
|
+
if (digest >= 0) {
|
|
23
|
+
const name = ref.slice(0, digest);
|
|
24
|
+
const colon = name.lastIndexOf(':');
|
|
25
|
+
return {
|
|
26
|
+
fromImage: colon > name.lastIndexOf('/') ? name.slice(0, colon) : name,
|
|
27
|
+
tag: ref.slice(digest + 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
24
30
|
const colon = ref.lastIndexOf(':');
|
|
25
31
|
if (colon > ref.lastIndexOf('/'))
|
|
26
32
|
return { fromImage: ref.slice(0, colon), tag: ref.slice(colon + 1) };
|
|
@@ -396,8 +402,14 @@ export class DockerEngine {
|
|
|
396
402
|
const { fromImage, tag } = splitImageRef(ref);
|
|
397
403
|
const res = await this.request('POST', '/images/create', {
|
|
398
404
|
query: { fromImage, tag },
|
|
405
|
+
// Docker uses Go's padded base64.URLEncoding; Node's base64url encoding omits required '='.
|
|
399
406
|
headers: auth
|
|
400
|
-
? {
|
|
407
|
+
? {
|
|
408
|
+
'x-registry-auth': Buffer.from(JSON.stringify(auth))
|
|
409
|
+
.toString('base64')
|
|
410
|
+
.replaceAll('+', '-')
|
|
411
|
+
.replaceAll('/', '_'),
|
|
412
|
+
}
|
|
401
413
|
: {},
|
|
402
414
|
});
|
|
403
415
|
await this.progress(res, `pull ${ref}`);
|
package/dist/protocol.d.ts
CHANGED
|
@@ -421,6 +421,10 @@ export interface StatusReport {
|
|
|
421
421
|
ingress?: IngressStatus;
|
|
422
422
|
}
|
|
423
423
|
export interface SecretsResponse {
|
|
424
|
+
registryAuth?: Record<string, {
|
|
425
|
+
username: string;
|
|
426
|
+
password: string;
|
|
427
|
+
}>;
|
|
424
428
|
secrets: Record<string, string>;
|
|
425
429
|
versions: Record<string, number>;
|
|
426
430
|
}
|
|
@@ -447,7 +451,6 @@ export interface NodeInfo {
|
|
|
447
451
|
export interface RegisterResponse {
|
|
448
452
|
node: NodeInfo;
|
|
449
453
|
agentToken: string;
|
|
450
|
-
registryToken: string;
|
|
451
454
|
}
|
|
452
455
|
/** Body of `POST /agent/v1/enroll`. */
|
|
453
456
|
export interface EnrollRequest {
|
|
@@ -461,16 +464,13 @@ export interface EnrollRequest {
|
|
|
461
464
|
labels?: string[];
|
|
462
465
|
}
|
|
463
466
|
/** Response of `POST /agent/v1/enroll`. */
|
|
464
|
-
export
|
|
465
|
-
registry: string;
|
|
466
|
-
}
|
|
467
|
+
export type EnrollResponse = RegisterResponse;
|
|
467
468
|
/** Response of `POST /api/v1/nodes/enroll-token`. */
|
|
468
469
|
export interface EnrollTokenResponse {
|
|
469
470
|
enrollToken: string;
|
|
470
471
|
name: string | null;
|
|
471
472
|
usesRemaining: number;
|
|
472
473
|
expiresAt: string;
|
|
473
|
-
registry: string;
|
|
474
474
|
}
|
|
475
475
|
/** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
|
|
476
476
|
export interface JoinKey {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const REGISTRY_AUTH_SECRET = "TUG_REGISTRY_AUTH";
|
|
2
|
+
export interface RegistryCredential {
|
|
3
|
+
username: string;
|
|
4
|
+
password: string;
|
|
5
|
+
}
|
|
6
|
+
export type RegistryCredentials = Record<string, RegistryCredential>;
|
|
7
|
+
/** A project secret containing registry-host -> username/password entries; never a desired-state field. */
|
|
8
|
+
export declare function parseRegistryCredentials(value?: string): RegistryCredentials;
|
|
9
|
+
export declare function imageLocation(image: string): {
|
|
10
|
+
host: string;
|
|
11
|
+
repository: string;
|
|
12
|
+
tag: string;
|
|
13
|
+
digest: string;
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
16
|
+
/** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
|
|
17
|
+
export declare function resolveImage(image: string, credentials?: RegistryCredentials, request?: typeof fetch): Promise<string>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const REGISTRY_AUTH_SECRET = 'TUG_REGISTRY_AUTH';
|
|
3
|
+
/** A project secret containing registry-host -> username/password entries; never a desired-state field. */
|
|
4
|
+
export function parseRegistryCredentials(value) {
|
|
5
|
+
if (!value)
|
|
6
|
+
return {};
|
|
7
|
+
try {
|
|
8
|
+
const entries = JSON.parse(value);
|
|
9
|
+
if (!entries || typeof entries !== 'object' || Array.isArray(entries))
|
|
10
|
+
throw new Error();
|
|
11
|
+
const credentials = {};
|
|
12
|
+
for (const [host, auth] of Object.entries(entries)) {
|
|
13
|
+
if (!/^[a-zA-Z0-9.:[\]-]+$/.test(host) ||
|
|
14
|
+
!auth ||
|
|
15
|
+
typeof auth !== 'object' ||
|
|
16
|
+
typeof auth.username !== 'string' ||
|
|
17
|
+
typeof auth.password !== 'string')
|
|
18
|
+
throw new Error();
|
|
19
|
+
credentials[host] = { username: auth.username, password: auth.password };
|
|
20
|
+
}
|
|
21
|
+
return credentials;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`${REGISTRY_AUTH_SECRET} must be a JSON object mapping registry hosts to username/password objects`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function imageLocation(image) {
|
|
28
|
+
const match = /^(?<name>[^@\s]+?)(?:@(?<digest>sha256:[a-f0-9]{64}))?$/.exec(image);
|
|
29
|
+
if (!match?.groups)
|
|
30
|
+
throw new Error('invalid image reference');
|
|
31
|
+
const { name, digest } = match.groups;
|
|
32
|
+
const slash = name.indexOf('/');
|
|
33
|
+
const qualified = slash >= 0 && /[.:]|^localhost$/.test(name.slice(0, slash));
|
|
34
|
+
let host = qualified ? name.slice(0, slash) : 'docker.io';
|
|
35
|
+
if (host === 'index.docker.io')
|
|
36
|
+
host = 'docker.io';
|
|
37
|
+
let repository = qualified ? name.slice(slash + 1) : name;
|
|
38
|
+
const colon = repository.lastIndexOf(':');
|
|
39
|
+
const tag = colon >= 0 ? repository.slice(colon + 1) : 'latest';
|
|
40
|
+
if (colon >= 0)
|
|
41
|
+
repository = repository.slice(0, colon);
|
|
42
|
+
if (host === 'docker.io' && !repository.includes('/'))
|
|
43
|
+
repository = `library/${repository}`;
|
|
44
|
+
if (!/^[a-z0-9]+(?:[._-]+[a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-]+[a-z0-9]+)*)*$/.test(repository) ||
|
|
45
|
+
!/^[\w][\w.-]{0,127}$/.test(tag) ||
|
|
46
|
+
!/^[a-zA-Z0-9.:[\]-]+$/.test(host))
|
|
47
|
+
throw new Error('invalid image reference');
|
|
48
|
+
return { host, repository, tag, digest, name };
|
|
49
|
+
}
|
|
50
|
+
const accept = [
|
|
51
|
+
'application/vnd.oci.image.index.v1+json',
|
|
52
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
53
|
+
'application/vnd.oci.image.manifest.v1+json',
|
|
54
|
+
'application/vnd.docker.distribution.manifest.v2+json',
|
|
55
|
+
].join(', ');
|
|
56
|
+
/** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
|
|
57
|
+
export async function resolveImage(image, credentials = {}, request = fetch) {
|
|
58
|
+
const location = imageLocation(image);
|
|
59
|
+
if (location.digest)
|
|
60
|
+
return image;
|
|
61
|
+
const { host, repository, tag, name } = location;
|
|
62
|
+
const endpoint = host === 'docker.io' ? 'registry-1.docker.io' : host;
|
|
63
|
+
const url = `https://${endpoint}/v2/${repository}/manifests/${tag}`;
|
|
64
|
+
const auth = credentials[host];
|
|
65
|
+
const basic = auth
|
|
66
|
+
? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`
|
|
67
|
+
: undefined;
|
|
68
|
+
const get = (authorization) => request(url, {
|
|
69
|
+
headers: { Accept: accept, ...(authorization ? { Authorization: authorization } : {}) },
|
|
70
|
+
redirect: 'error',
|
|
71
|
+
signal: AbortSignal.timeout(30_000),
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
let response = await get();
|
|
75
|
+
if (response.status === 401) {
|
|
76
|
+
const challenge = response.headers.get('www-authenticate') || '';
|
|
77
|
+
await response.body?.cancel();
|
|
78
|
+
if (/^Basic\s/i.test(challenge) && basic)
|
|
79
|
+
response = await get(basic);
|
|
80
|
+
else if (/^Bearer\s/i.test(challenge)) {
|
|
81
|
+
const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|
82
|
+
const tokenUrl = new URL(params.realm);
|
|
83
|
+
if (tokenUrl.protocol !== 'https:' || tokenUrl.username || tokenUrl.password)
|
|
84
|
+
throw new Error();
|
|
85
|
+
if (params.service)
|
|
86
|
+
tokenUrl.searchParams.set('service', params.service);
|
|
87
|
+
tokenUrl.searchParams.set('scope', `repository:${repository}:pull`);
|
|
88
|
+
const tokenResponse = await request(tokenUrl, {
|
|
89
|
+
headers: basic ? { Authorization: basic } : {},
|
|
90
|
+
redirect: 'error',
|
|
91
|
+
signal: AbortSignal.timeout(30_000),
|
|
92
|
+
});
|
|
93
|
+
if (!tokenResponse.ok) {
|
|
94
|
+
await tokenResponse.body?.cancel();
|
|
95
|
+
throw new Error();
|
|
96
|
+
}
|
|
97
|
+
const body = (await tokenResponse.json());
|
|
98
|
+
const token = body.token || body.access_token;
|
|
99
|
+
if (!token || typeof token !== 'string')
|
|
100
|
+
throw new Error();
|
|
101
|
+
response = await get(`Bearer ${token}`);
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
throw new Error();
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
await response.body?.cancel();
|
|
108
|
+
throw new Error();
|
|
109
|
+
}
|
|
110
|
+
// Hash the exact bytes instead of trusting a registry's digest header. Bound manifest memory use.
|
|
111
|
+
const chunks = [];
|
|
112
|
+
let size = 0;
|
|
113
|
+
for await (const chunk of response.body) {
|
|
114
|
+
size += chunk.length;
|
|
115
|
+
if (size > 8 * 1024 * 1024)
|
|
116
|
+
throw new Error();
|
|
117
|
+
chunks.push(chunk);
|
|
118
|
+
}
|
|
119
|
+
const bytes = Buffer.concat(chunks);
|
|
120
|
+
const manifest = JSON.parse(bytes.toString());
|
|
121
|
+
if (manifest.schemaVersion !== 2 ||
|
|
122
|
+
!(Array.isArray(manifest.manifests) || (manifest.config && Array.isArray(manifest.layers))))
|
|
123
|
+
throw new Error();
|
|
124
|
+
const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
125
|
+
const reported = response.headers.get('docker-content-digest');
|
|
126
|
+
if (reported && reported !== digest)
|
|
127
|
+
throw new Error();
|
|
128
|
+
return `${name}@${digest}`;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error(`cannot resolve image ${image}; check registry availability and project ${REGISTRY_AUTH_SECRET} credentials`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -8,12 +8,8 @@ services:
|
|
|
8
8
|
TUG_DATA_DIR: /data
|
|
9
9
|
TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
|
|
10
10
|
TUG_MESH: {{MESH}}
|
|
11
|
-
TUG_REGISTRY: {{REGISTRY_HOST}}
|
|
12
|
-
TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
|
|
13
|
-
TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
|
|
14
11
|
ports:
|
|
15
12
|
- "{{PORT}}:8080"
|
|
16
13
|
volumes:
|
|
17
14
|
- /var/lib/tug-control:/data
|
|
18
|
-
- /var/lib/tug-registry-auth:/registry-auth
|
|
19
15
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
@@ -3,6 +3,5 @@ mkdir -p /etc/tug /var/lib/tug
|
|
|
3
3
|
cat > /etc/tug/agent.env <<'ENV'
|
|
4
4
|
{{ENV}}ENV
|
|
5
5
|
chmod 0600 /etc/tug/agent.env
|
|
6
|
-
{{DOCKER_LOGIN}}
|
|
7
6
|
docker rm -f tug-agent >/dev/null 2>&1 || true
|
|
8
7
|
docker run -d --name tug-agent --restart unless-stopped --env-file /etc/tug/agent.env {{RUN_FLAGS}}-v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/tug:/var/lib/tug {{IMAGE_SH}} agent
|
package/dist/tokens.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare const PROJECT_NAME_PATTERN: RegExp;
|
|
|
14
14
|
/**
|
|
15
15
|
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
16
16
|
* register an unlisted route, and a test compares this table with the live router. `public` routes do their own
|
|
17
|
-
* credential check
|
|
17
|
+
* credential check and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
18
18
|
*/
|
|
19
19
|
export declare const ROUTE_ROLES: Record<string, RouteRole>;
|
|
20
20
|
/** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
|
|
@@ -60,14 +60,14 @@ export declare function parseTokenRequest(body: unknown): {
|
|
|
60
60
|
expiresAt: string | null;
|
|
61
61
|
};
|
|
62
62
|
/** Creates a token. The plaintext is returned once; only its hash is stored. */
|
|
63
|
-
export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string):
|
|
63
|
+
export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): TokenInfo & {
|
|
64
64
|
token: string;
|
|
65
|
-
}
|
|
66
|
-
export declare function listTokens(db: Db):
|
|
65
|
+
};
|
|
66
|
+
export declare function listTokens(db: Db): TokenInfo[];
|
|
67
67
|
/** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
|
|
68
|
-
export declare function revokeToken(db: Db, name: string):
|
|
68
|
+
export declare function revokeToken(db: Db, name: string): boolean;
|
|
69
69
|
/** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
|
|
70
|
-
export declare function authenticate(db: Db, raw: string):
|
|
70
|
+
export declare function authenticate(db: Db, raw: string): Actor | null;
|
|
71
71
|
/**
|
|
72
72
|
* Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
|
|
73
73
|
* resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
|
|
@@ -88,4 +88,4 @@ export declare function recordEvent(db: Db, event: {
|
|
|
88
88
|
project?: string | null;
|
|
89
89
|
deploymentId?: string | null;
|
|
90
90
|
metadata?: Record<string, unknown>;
|
|
91
|
-
}):
|
|
91
|
+
}): void;
|
package/dist/tokens.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUniqueViolation } from "./db-driver.js";
|
|
1
2
|
import { hashToken, id, now, token } from "./util.js";
|
|
2
3
|
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
3
4
|
export const ROLES = ['read', 'deploy', 'admin'];
|
|
@@ -14,7 +15,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
|
|
|
14
15
|
/**
|
|
15
16
|
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
16
17
|
* register an unlisted route, and a test compares this table with the live router. `public` routes do their own
|
|
17
|
-
* credential check
|
|
18
|
+
* credential check and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
18
19
|
*/
|
|
19
20
|
export const ROUTE_ROLES = {
|
|
20
21
|
'GET /api/v1/live': 'deploy',
|
|
@@ -38,8 +39,6 @@ export const ROUTE_ROLES = {
|
|
|
38
39
|
'GET /api/v1/nodes': 'admin',
|
|
39
40
|
'POST /api/v1/nodes/register': 'admin',
|
|
40
41
|
'POST /api/v1/nodes/enroll-token': 'admin',
|
|
41
|
-
'POST /api/v1/nodes/:name/rotate-registry-token': 'admin',
|
|
42
|
-
'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
|
|
43
42
|
'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
|
|
44
43
|
'POST /api/v1/nodes/:name/ingress': 'admin',
|
|
45
44
|
'DELETE /api/v1/nodes/:name': 'admin',
|
|
@@ -50,7 +49,6 @@ export const ROUTE_ROLES = {
|
|
|
50
49
|
'POST /api/v1/tokens': 'admin',
|
|
51
50
|
'GET /api/v1/tokens': 'admin',
|
|
52
51
|
'DELETE /api/v1/tokens/:name': 'admin',
|
|
53
|
-
'POST /api/v1/registry/auth': 'public',
|
|
54
52
|
};
|
|
55
53
|
const statusError = (status, message) => Object.assign(new Error(message), { status });
|
|
56
54
|
export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
|
|
@@ -98,46 +96,51 @@ const info = (row) => ({
|
|
|
98
96
|
revokedAt: row.revoked_at,
|
|
99
97
|
});
|
|
100
98
|
/** Creates a token. The plaintext is returned once; only its hash is stored. */
|
|
101
|
-
export
|
|
99
|
+
export function createToken(db, request, createdBy) {
|
|
102
100
|
const value = token(TOKEN_PREFIX);
|
|
103
101
|
try {
|
|
104
|
-
|
|
102
|
+
db.run('INSERT INTO api_tokens(id,name,token_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', id('tok'), request.name, hashToken(value), request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, request.expiresAt);
|
|
105
103
|
}
|
|
106
104
|
catch (e) {
|
|
107
|
-
if (
|
|
105
|
+
if (isUniqueViolation(e))
|
|
108
106
|
throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
|
|
109
107
|
throw e;
|
|
110
108
|
}
|
|
111
109
|
return {
|
|
112
|
-
...info(
|
|
110
|
+
...info(db.get('SELECT * FROM api_tokens WHERE name=?', request.name)),
|
|
113
111
|
token: value,
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
|
-
export
|
|
117
|
-
return
|
|
114
|
+
export function listTokens(db) {
|
|
115
|
+
return db.all('SELECT * FROM api_tokens ORDER BY created_at, name').map(info);
|
|
118
116
|
}
|
|
119
117
|
/** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
|
|
120
|
-
export
|
|
121
|
-
const row =
|
|
118
|
+
export function revokeToken(db, name) {
|
|
119
|
+
const row = db.get('SELECT * FROM api_tokens WHERE name=?', name);
|
|
122
120
|
if (!row)
|
|
123
121
|
return false;
|
|
124
|
-
|
|
122
|
+
db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
|
|
125
123
|
return true;
|
|
126
124
|
}
|
|
127
125
|
/** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
|
|
128
|
-
export
|
|
126
|
+
export function authenticate(db, raw) {
|
|
129
127
|
if (!raw)
|
|
130
128
|
return null;
|
|
131
129
|
const hash = hashToken(raw);
|
|
132
|
-
const operator =
|
|
130
|
+
const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
133
131
|
if (operator && hash === operator.value)
|
|
134
132
|
return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
|
|
135
|
-
const row =
|
|
133
|
+
const row = db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
|
|
136
134
|
if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
|
|
137
135
|
return null;
|
|
138
|
-
// Best effort and throttled:
|
|
136
|
+
// Best effort and throttled: bookkeeping failures must never fail authentication.
|
|
139
137
|
if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
|
|
140
|
-
|
|
138
|
+
try {
|
|
139
|
+
db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* Best-effort bookkeeping. */
|
|
143
|
+
}
|
|
141
144
|
return {
|
|
142
145
|
name: row.name,
|
|
143
146
|
role: row.role,
|
|
@@ -156,7 +159,7 @@ export function assertProject(actor, project) {
|
|
|
156
159
|
export function createGuard(db) {
|
|
157
160
|
return (required, resolve) => async (req, res, next) => {
|
|
158
161
|
try {
|
|
159
|
-
const actor =
|
|
162
|
+
const actor = authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
|
|
160
163
|
if (!actor) {
|
|
161
164
|
res.status(401).json({ error: 'unauthorized' });
|
|
162
165
|
return;
|
|
@@ -186,6 +189,6 @@ export function createGuard(db) {
|
|
|
186
189
|
};
|
|
187
190
|
}
|
|
188
191
|
/** Appends a control-originated audit event naming the acting token. */
|
|
189
|
-
export
|
|
190
|
-
|
|
192
|
+
export function recordEvent(db, event) {
|
|
193
|
+
db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
|
|
191
194
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wpelevator/tug",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Portable VPS deployments with Docker Compose",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,8 +43,6 @@
|
|
|
43
43
|
"express": "^4.21.2",
|
|
44
44
|
"js-yaml": "^4.1.0",
|
|
45
45
|
"multer": "^2.2.0",
|
|
46
|
-
"sqlite": "^5.1.1",
|
|
47
|
-
"sqlite3": "^5.1.7",
|
|
48
46
|
"tar": "^7.4.3",
|
|
49
47
|
"ws": "^8.22.1"
|
|
50
48
|
},
|