The runtime beside the forge served 0 tools: GiteaClient.fromEnv required a token
(settings or MESH_GITEA_TOKEN), nobody had one to give — the mesh raised the forge —
and putting one in settings would store a secret in plaintext in the inventory. So
the fifteen tools registered nothing and the watcher logged "not watching".
What the mesh does deliver is the admin account: a login the manifest names and a
password the vault minted and the host unsealed into a file (ADR 0086). That is
enough to mint a token, so the module does (hq issue 100, the forge's tools):
POST /users/{admin}/tokens over basic auth, scoped to write:repository and
write:issue — the least the tools and the repo watcher need — kept at 0600 in the
module's own state (/var/lib/mesh/gitea/state, a new directory resource the runtime
mounts writable), read back on the next start, and minted afresh when the forge
answers 401 to it or the kept file is gone. A forge whose data came from the
predecessor has no mesh-admin: that is reported in plain words on every poll until
it clears, once per reason, not crash-looped. A configured token still wins and is
never minted over.
The mint happens on the first call, not at registration: a contributor is
synchronous, and a forge not yet answering must not keep the runtime from serving.
One source per kept file in a process, or the watcher and the tools would each
renew on a 401 and drop the other's token by name.
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
// The token the forge's tools and watcher authenticate with — and where it comes from.
|
|
//
|
|
// Nobody configures it. The forge is raised by the mesh, so there is no operator holding a token to
|
|
// paste in, and pasting one into settings would put a secret in the inventory in plaintext. What
|
|
// the mesh does deliver is the admin account: a login the manifest names and a password the vault
|
|
// minted and the host unsealed into a file (novox/hq ADR 0086). That account is enough to mint a
|
|
// token, so the module mints its own (hq issue 100, the forge's tools):
|
|
//
|
|
// - at first use, when none is kept: POST /users/{admin}/tokens over basic auth, with the two
|
|
// scopes the tools and the watcher need, and no more;
|
|
// - kept in the module's own state, a 0600 file, and read back on the next start — the forge
|
|
// hands a token's value out exactly once, so a token not kept is a token lost;
|
|
// - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not
|
|
// a fault: the forge's data was restored from a predecessor and the token the file names never
|
|
// existed there.
|
|
//
|
|
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
|
|
// reported, not repaired — somebody chose it.
|
|
//
|
|
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
|
|
// kept; never what it is.
|
|
|
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
|
|
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
|
|
export const TOKEN_NAME = "mesh-tools";
|
|
|
|
/**
|
|
* The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens):
|
|
* write:repository — list/create/delete repositories, pull requests (list/get/open/merge), and
|
|
* the watcher's /user/repos poll;
|
|
* write:issue — issues, comments, labels.
|
|
* Nothing under /admin, /orgs or /users — the escape-hatch tool reaches only what these two cover.
|
|
*/
|
|
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue"];
|
|
|
|
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
|
|
export interface TokenSource {
|
|
/** The token to authenticate with now; minted, read or configured. */
|
|
current(): Promise<string>;
|
|
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */
|
|
renew(rejected: string): Promise<string>;
|
|
}
|
|
|
|
/** A token somebody set — in settings or the environment. Never minted over. */
|
|
export class ConfiguredToken implements TokenSource {
|
|
constructor(private readonly token: string) {}
|
|
|
|
async current(): Promise<string> {
|
|
return this.token;
|
|
}
|
|
|
|
async renew(): Promise<string> {
|
|
throw new Error(
|
|
"the forge rejected the configured Gitea token (401). It was set explicitly (settings or MESH_GITEA_TOKEN), " +
|
|
"so the module does not mint over it — fix it, or unset it and the module mints its own",
|
|
);
|
|
}
|
|
}
|
|
|
|
/** The forge would not take the admin account: it is missing, or its password is not the one the mesh holds. */
|
|
export class AdminRefused extends Error {
|
|
constructor(admin: string, status: number) {
|
|
super(
|
|
`the forge refused the admin account "${admin}" (${status}) — it does not exist there, or its password is not ` +
|
|
`the one the vault delivered. The admin-bootstrap step creates it on a forge the mesh raised; a forge whose data ` +
|
|
`came from a predecessor does not have it. Create "${admin}" on the forge with the delivered password and the ` +
|
|
`token is minted on the next call — the tools stay registered and the watcher keeps trying`,
|
|
);
|
|
this.name = "AdminRefused";
|
|
}
|
|
}
|
|
|
|
export interface MintedTokenOptions {
|
|
/** The forge, e.g. http://127.0.0.1:3000. */
|
|
readonly url: string;
|
|
/** The admin login the manifest names. */
|
|
readonly admin: string;
|
|
/** The file the host unsealed the admin password into (ADR 0086). Read at mint time, so a rotation takes. */
|
|
readonly passwordFile: string;
|
|
/** Where the token is kept: a 0600 file in the module's own state. */
|
|
readonly file: string;
|
|
readonly name?: string;
|
|
readonly scopes?: readonly string[];
|
|
readonly log?: (line: string) => void;
|
|
readonly fetch?: typeof fetch;
|
|
}
|
|
|
|
/** The token the module mints for itself, kept in its state and renewed when the forge rejects it. */
|
|
export class MintedToken implements TokenSource {
|
|
private held: string | null = null;
|
|
private readFile = false;
|
|
private inflight: Promise<string> | null = null;
|
|
private readonly name: string;
|
|
private readonly scopes: readonly string[];
|
|
private readonly log: (line: string) => void;
|
|
private readonly fetchImpl: typeof fetch;
|
|
|
|
constructor(private readonly opts: MintedTokenOptions) {
|
|
this.name = opts.name ?? TOKEN_NAME;
|
|
this.scopes = opts.scopes ?? TOKEN_SCOPES;
|
|
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
|
|
this.fetchImpl = opts.fetch ?? fetch;
|
|
}
|
|
|
|
/**
|
|
* Build from the runtime's environment: the forge's URL, the admin login and password file the
|
|
* manifest hands the runtime, and the module's state directory (MESH_GITEA_STATE_DIR, a directory
|
|
* the runtime mounts writable). Throws, naming what is missing, rather than hand back a source
|
|
* that cannot mint.
|
|
*/
|
|
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
|
|
const opts = MintedToken.optionsFromEnv(url, env);
|
|
// One source per kept file in a process. The watcher and the tools entrypoint both build a
|
|
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
|
|
// other's token by name, forever. Shared, a renewal is one renewal.
|
|
const shared = MintedToken.shared.get(opts.file);
|
|
if (shared) return shared;
|
|
const source = new MintedToken(opts);
|
|
MintedToken.shared.set(opts.file, source);
|
|
return source;
|
|
}
|
|
|
|
private static readonly shared = new Map<string, MintedToken>();
|
|
|
|
private static optionsFromEnv(url: string, env: NodeJS.ProcessEnv): MintedTokenOptions {
|
|
const admin = env.MESH_GITEA_ADMIN_USER;
|
|
const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
|
|
const stateDir = env.MESH_GITEA_STATE_DIR;
|
|
const missing = [
|
|
admin ? null : "MESH_GITEA_ADMIN_USER",
|
|
passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE",
|
|
stateDir ? null : "MESH_GITEA_STATE_DIR",
|
|
].filter((v): v is string => v !== null);
|
|
if (missing.length) {
|
|
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
|
|
}
|
|
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
|
|
}
|
|
|
|
async current(): Promise<string> {
|
|
if (this.held !== null) return this.held;
|
|
if (!this.readFile) {
|
|
this.readFile = true;
|
|
const kept = this.read();
|
|
if (kept !== null) {
|
|
this.held = kept;
|
|
this.log(`reusing the token kept at ${this.opts.file}`);
|
|
return kept;
|
|
}
|
|
}
|
|
return this.mint("no token kept — minting one");
|
|
}
|
|
|
|
async renew(rejected: string): Promise<string> {
|
|
// Another caller already renewed while this one was in flight with the old token.
|
|
if (this.held !== null && this.held !== rejected) return this.held;
|
|
// Or another process did, and kept it: use what is kept before minting over it.
|
|
const kept = this.read();
|
|
if (kept !== null && kept !== rejected) {
|
|
this.held = kept;
|
|
this.log(`the forge rejected the token held; the kept one at ${this.opts.file} is newer — reusing it`);
|
|
return kept;
|
|
}
|
|
this.held = null;
|
|
return this.mint("the forge rejected the kept token — minting a fresh one");
|
|
}
|
|
|
|
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
|
|
private mint(why: string): Promise<string> {
|
|
if (this.inflight === null) {
|
|
this.log(why);
|
|
this.inflight = this.doMint().finally(() => {
|
|
this.inflight = null;
|
|
});
|
|
}
|
|
return this.inflight;
|
|
}
|
|
|
|
private read(): string | null {
|
|
try {
|
|
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
|
|
return token.length ? token : null;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
throw new Error(`cannot read the kept Gitea token at ${this.opts.file}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
|
|
private keep(token: string): void {
|
|
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
|
|
const tmp = `${this.opts.file}.tmp`;
|
|
writeFileSync(tmp, token + "\n", { mode: 0o600 });
|
|
chmodSync(tmp, 0o600);
|
|
renameSync(tmp, this.opts.file);
|
|
}
|
|
|
|
private async doMint(): Promise<string> {
|
|
let password: string;
|
|
try {
|
|
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
|
} catch (err) {
|
|
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
|
|
}
|
|
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
|
|
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
|
|
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
|
|
const res = await this.fetchImpl(tokens + path, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", Authorization: authorization },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
const text = await res.text();
|
|
let parsed: any = null;
|
|
if (text) {
|
|
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
|
}
|
|
return { status: res.status, body: parsed };
|
|
};
|
|
|
|
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
|
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
|
|
if (res.status === 400 || res.status === 422) {
|
|
// The forge still holds a token by this name whose value we no longer have — the kept file
|
|
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
|
|
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
|
|
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
|
|
if (dropped.status !== 204 && dropped.status !== 404) {
|
|
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
|
|
}
|
|
res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
|
}
|
|
if (res.status !== 201 && res.status !== 200) {
|
|
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
|
|
}
|
|
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
|
|
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
|
|
|
|
this.keep(token);
|
|
this.held = token;
|
|
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
|
return token;
|
|
}
|
|
}
|
|
|
|
function detail(body: unknown): string {
|
|
return typeof body === "string" ? body : JSON.stringify(body);
|
|
}
|