@cldmv/gh-broker 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 +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/otp.mjs
|
|
5
|
+
* @Date: 2026-11-01T20:43:03-08:00 (1793594583)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-11-01T20:43:03-08:00 (1793594583)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The installer/CLI half of the OTP gate (#143): the TOTP seed (generation, otpauth URI, the
|
|
18
|
+
* one-time reveal), where it is stored, and the shared state file the daemon writes and the
|
|
19
|
+
* root CLI edits. Everything here is a plain function with its effects passed in, so it is
|
|
20
|
+
* testable without root or systemd. The daemon side lives in src/ and reads the same state
|
|
21
|
+
* file format (version 1, per-user counters under `users`).
|
|
22
|
+
*/
|
|
23
|
+
import { randomBytes } from "node:crypto";
|
|
24
|
+
import { readFileSync, openSync, writeSync, fsyncSync, closeSync, renameSync, rmSync, fchmodSync, fchownSync, statSync, constants } from "node:fs";
|
|
25
|
+
import { dirname, join, basename } from "node:path";
|
|
26
|
+
import QRCode from "qrcode";
|
|
27
|
+
import { validAgentName } from "./agents.mjs";
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SEED_CRED = "otp-seed";
|
|
30
|
+
export const DEFAULT_STATE_PATH = "/var/lib/gh-broker/otp-state.json";
|
|
31
|
+
export const DEFAULT_MAX_FAILURES = 5;
|
|
32
|
+
export const DEFAULT_MAX_LOCKOUTS = 3;
|
|
33
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
35
|
+
|
|
36
|
+
/** RFC 4648 base32 without padding. */
|
|
37
|
+
export function base32Encode(bytes) {
|
|
38
|
+
let bits = 0;
|
|
39
|
+
let value = 0;
|
|
40
|
+
let out = "";
|
|
41
|
+
for (const byte of bytes) {
|
|
42
|
+
value = (value << 8) | byte;
|
|
43
|
+
bits += 8;
|
|
44
|
+
while (bits >= 5) {
|
|
45
|
+
out += BASE32[(value >>> (bits - 5)) & 31];
|
|
46
|
+
bits -= 5;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A new TOTP seed: 20 random bytes as base32 (32 characters). `random` is injectable for tests. */
|
|
54
|
+
export function generateSeed(random = randomBytes) {
|
|
55
|
+
return base32Encode(random(20));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The otpauth URI an authenticator app imports (HMAC-SHA1, 6 digits, 30 s step). */
|
|
59
|
+
export function otpauthUri(seed, host) {
|
|
60
|
+
return `otpauth://totp/gh-broker:${encodeURIComponent(host)}?secret=${seed}&issuer=gh-broker&algorithm=SHA1&digits=6&period=30`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The URI as a terminal QR code. `toString` is the qrcode library's, injectable for tests. */
|
|
64
|
+
export async function renderQr(uri, toString = QRCode.toString) {
|
|
65
|
+
return toString(uri, { type: "terminal", small: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The one-time reveal: QR, the base32 key as text, and the URI. This is the only place the seed
|
|
70
|
+
* is printed; callers hand it a `log` that writes to the operator's terminal and nowhere else.
|
|
71
|
+
*/
|
|
72
|
+
export async function showSeed({ seed, host, log, qr = renderQr }) {
|
|
73
|
+
const uri = otpauthUri(seed, host);
|
|
74
|
+
log("");
|
|
75
|
+
log(" OTP seed (shown once). Scan the QR code with an authenticator app, or enter the key by hand:");
|
|
76
|
+
log("");
|
|
77
|
+
for (const line of (await qr(uri)).split("\n")) log(` ${line}`);
|
|
78
|
+
log(` key: ${seed}`);
|
|
79
|
+
log(` uri: ${uri}`);
|
|
80
|
+
log("");
|
|
81
|
+
log(" Store it in the authenticator now; it is not shown again (`sudo gh-broker otp rotate` makes a new one).");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The cred name the config's `otp.seed` ref points at, or undefined when OTP isn't configured. */
|
|
85
|
+
export function seedCredName(config) {
|
|
86
|
+
const ref = config?.otp?.seed;
|
|
87
|
+
return typeof ref === "string" && ref.startsWith("cred:") ? ref.slice("cred:".length) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a stored seed already exists in the active backend (an existing one is never overwritten on install). */
|
|
91
|
+
export function seedExists({ name, existingFile, credFileExists }) {
|
|
92
|
+
return Boolean(existingFile[name]) || credFileExists(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Store a seed in the backend the install uses. The systemd-creds backend replaces the `.cred`
|
|
97
|
+
* (atomically: the old one stays until the new one is written) and rewrites the unit drop-in so
|
|
98
|
+
* the credential is delivered; the file backend sets the key in
|
|
99
|
+
* the secrets file. `io` carries every effect.
|
|
100
|
+
* @param {object} o
|
|
101
|
+
* @param {{ secrets?: { provider?: string } }} o.config
|
|
102
|
+
* @param {string} o.name credential name
|
|
103
|
+
* @param {string} o.seed
|
|
104
|
+
* @param {{ secrets: string, credsDir: string }} o.paths
|
|
105
|
+
* @param {object} o.io
|
|
106
|
+
*/
|
|
107
|
+
export function storeSeed({ config, name, seed, paths, io }) {
|
|
108
|
+
if (config.secrets?.provider === "file") {
|
|
109
|
+
io.writeSecretsFile(paths.secrets, { ...io.readSecretsFile(paths.secrets), [name]: seed });
|
|
110
|
+
return "file";
|
|
111
|
+
}
|
|
112
|
+
// Encrypt to a side file and rename it over the old one, so a failed encrypt leaves the old
|
|
113
|
+
// seed (and the unit's LoadCredentialEncrypted) intact. systemd-creds won't overwrite a file,
|
|
114
|
+
// so a leftover side file from an earlier failure is removed first.
|
|
115
|
+
const credFile = join(paths.credsDir, `${name}.cred`);
|
|
116
|
+
const next = `${credFile}.new`;
|
|
117
|
+
io.removeFile(next);
|
|
118
|
+
io.encrypt(name, seed, next);
|
|
119
|
+
io.rename(next, credFile);
|
|
120
|
+
const names = new Set(io.listCreds(paths.credsDir));
|
|
121
|
+
names.add(name);
|
|
122
|
+
io.writeDropin([...names].sort(), paths.credsDir);
|
|
123
|
+
return "systemd-creds";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
127
|
+
const isIso = (v) => typeof v === "string" && !Number.isNaN(Date.parse(v));
|
|
128
|
+
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
129
|
+
|
|
130
|
+
/** One user's counters, all zero (what a user with no entry has). */
|
|
131
|
+
export function emptyUser() {
|
|
132
|
+
return { failures: 0, lockedUntil: null, lockouts: [], hardLock: null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** All-zero state, the format the daemon writes (contract v2: version 1, counters per user). */
|
|
136
|
+
export function emptyState() {
|
|
137
|
+
return { version: 1, lastAcceptedStep: 0, users: {} };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether `v` is a well-formed per-user entry. */
|
|
141
|
+
export function validUser(v) {
|
|
142
|
+
return (
|
|
143
|
+
isObject(v) &&
|
|
144
|
+
isCount(v.failures) &&
|
|
145
|
+
(v.lockedUntil === null || isIso(v.lockedUntil)) &&
|
|
146
|
+
Array.isArray(v.lockouts) &&
|
|
147
|
+
v.lockouts.every(isIso) &&
|
|
148
|
+
(v.hardLock === null || (isObject(v.hardLock) && isIso(v.hardLock.since) && isCount(v.hardLock.lockouts)))
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether `v` is a well-formed version 1 state object, by the same rules as the daemon's parseState (src/otp/state.ts). */
|
|
153
|
+
export function validState(v) {
|
|
154
|
+
return isObject(v) && v.version === 1 && isCount(v.lastAcceptedStep) && isObject(v.users) && Object.entries(v.users).every(([name, u]) => validAgentName(name) && validUser(u));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A user's entry, or all-zero when it has none. */
|
|
158
|
+
export function userOf(state, user) {
|
|
159
|
+
return Object.hasOwn(state.users, user) ? state.users[user] : emptyUser();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the state file. A missing file is all-zero; an unparseable or malformed one is "corrupt"
|
|
164
|
+
* (the daemon fails closed on it: every user hard-locked, so status reports that).
|
|
165
|
+
* @returns {{ status: "ok" | "missing" | "corrupt", state: object }}
|
|
166
|
+
*/
|
|
167
|
+
export function readState(path, read = readFileSync) {
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = read(path, "utf8");
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err.code === "ENOENT") return { status: "missing", state: emptyState() };
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(text);
|
|
177
|
+
if (validState(parsed)) return { status: "ok", state: parsed };
|
|
178
|
+
} catch {
|
|
179
|
+
// falls through to corrupt
|
|
180
|
+
}
|
|
181
|
+
return { status: "corrupt", state: emptyState() };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The state with one user's entry cleared (no entry means all-zero). `lastAcceptedStep` is kept so a spent code can't be replayed. */
|
|
185
|
+
export function withoutUser(state, user) {
|
|
186
|
+
const { [user]: _gone, ...users } = state.users;
|
|
187
|
+
return { ...state, users };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `state` ready to be written by the CLI: every code up to one step ahead of `now` counts as
|
|
192
|
+
* spent. The CLI rewrites the whole file while the daemon may be accepting a code (it accepts
|
|
193
|
+
* at most one step ahead), so this keeps a write from handing back a code the daemon just used.
|
|
194
|
+
* The cost is that the code an authenticator shows now, and the one after it, can't open a
|
|
195
|
+
* window; see secondsUntilFreshCode.
|
|
196
|
+
*/
|
|
197
|
+
export function spendRecentCodes(state, now) {
|
|
198
|
+
return { ...state, lastAcceptedStep: Math.max(state.lastAcceptedStep, Math.floor(now / 30000) + 1) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Seconds from `now` until an authenticator shows a code spendRecentCodes(…, now) didn't spend (31 to 60). */
|
|
202
|
+
export function secondsUntilFreshCode(now) {
|
|
203
|
+
return Math.ceil(((Math.floor(now / 30000) + 2) * 30000 - now) / 1000);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The state with every user cleared. */
|
|
207
|
+
export function withoutUsers(state) {
|
|
208
|
+
return { ...state, users: {} };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Write a file atomically: a temp file in the same directory, fsync, rename; the given mode,
|
|
213
|
+
* chowned to uid:gid. The directory can belong to someone else (an agent user's home), so the
|
|
214
|
+
* temp file has a random name and is opened with O_EXCL|O_NOFOLLOW (never an existing file or a
|
|
215
|
+
* planted symlink), and the mode and owner are set on the open descriptor, not the path. The
|
|
216
|
+
* rename replaces a symlink at `path` rather than following it. `beforeRename`, when given, runs
|
|
217
|
+
* just before the rename and can throw to abandon the write. `fchown` (it needs root) and
|
|
218
|
+
* `random` are injectable for tests.
|
|
219
|
+
*/
|
|
220
|
+
export function writeFileAtomic(path, text, { uid, gid, mode = 0o600 }, { fchown = fchownSync, random = randomBytes, beforeRename } = {}) {
|
|
221
|
+
const tmp = join(dirname(path), `.${basename(path)}.${random(8).toString("hex")}.tmp`);
|
|
222
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
223
|
+
try {
|
|
224
|
+
try {
|
|
225
|
+
fchmodSync(fd, mode);
|
|
226
|
+
fchown(fd, uid, gid);
|
|
227
|
+
writeSync(fd, text);
|
|
228
|
+
fsyncSync(fd);
|
|
229
|
+
} finally {
|
|
230
|
+
closeSync(fd);
|
|
231
|
+
}
|
|
232
|
+
beforeRename?.();
|
|
233
|
+
renameSync(tmp, path);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
rmSync(tmp, { force: true });
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Write the state file atomically as 0600, chowned to the service user. */
|
|
241
|
+
export function writeStateAtomic(path, state, owner, io) {
|
|
242
|
+
writeFileAtomic(path, JSON.stringify(state, null, "\t") + "\n", { ...owner, mode: 0o600 }, io);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Identity of one version of a file on disk ("missing" when there is none); the daemon uses the same test. */
|
|
246
|
+
export function fileSignature(path, stat = statSync) {
|
|
247
|
+
try {
|
|
248
|
+
const st = stat(path);
|
|
249
|
+
return `${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (err.code === "ENOENT") return "missing";
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The CLI's state-file reader and writer, as a compare-and-swap: a write is abandoned when the
|
|
258
|
+
* file changed after this store read it (the daemon wrote a lockout or a step meanwhile), so a
|
|
259
|
+
* CLI edit never puts back data the daemon just replaced. The command then fails and is run again.
|
|
260
|
+
* @param {{ uid: number, gid: number }} owner the service user
|
|
261
|
+
*/
|
|
262
|
+
export function stateStore(owner, { read = readFileSync, stat = statSync, ...io } = {}) {
|
|
263
|
+
const seen = new Map();
|
|
264
|
+
return {
|
|
265
|
+
read(path) {
|
|
266
|
+
seen.set(path, fileSignature(path, stat)); // before reading: a change in between fails the write, which is the safe side
|
|
267
|
+
return readState(path, read);
|
|
268
|
+
},
|
|
269
|
+
write(path, state) {
|
|
270
|
+
const expected = seen.get(path);
|
|
271
|
+
const beforeRename = () => {
|
|
272
|
+
if (fileSignature(path, stat) !== expected) throw new Error(`${path} changed while this command ran (the broker wrote to it); nothing was changed, run the command again`);
|
|
273
|
+
};
|
|
274
|
+
writeStateAtomic(path, state, owner, { ...io, beforeRename });
|
|
275
|
+
seen.set(path, fileSignature(path, stat));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One user's facts for `status`, from their entry and the config's limits. */
|
|
281
|
+
export function describeUser(user, { maxFailures = DEFAULT_MAX_FAILURES, maxLockouts = DEFAULT_MAX_LOCKOUTS } = {}, now = Date.now()) {
|
|
282
|
+
const lockedUntil = user.lockedUntil && Date.parse(user.lockedUntil) > now ? user.lockedUntil : null;
|
|
283
|
+
return {
|
|
284
|
+
locked: Boolean(user.hardLock) || lockedUntil !== null,
|
|
285
|
+
failures: user.failures,
|
|
286
|
+
maxFailures,
|
|
287
|
+
lockedUntil,
|
|
288
|
+
lockoutsLast24h: user.lockouts.filter((t) => Date.parse(t) > now - DAY_MS).length,
|
|
289
|
+
maxLockouts,
|
|
290
|
+
hardLock: user.hardLock
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The per-user facts for the given users (registered agents plus any user the file mentions).
|
|
296
|
+
* A corrupt file locks every one of them, as the daemon does.
|
|
297
|
+
* @returns {Array<{ user: string, corrupt?: true } & ReturnType<typeof describeUser>>}
|
|
298
|
+
*/
|
|
299
|
+
export function describeUsers({ status, state }, users, limits = {}, now = Date.now()) {
|
|
300
|
+
const names = [...new Set([...users, ...Object.keys(state.users)])].sort();
|
|
301
|
+
return names.map((user) =>
|
|
302
|
+
status === "corrupt"
|
|
303
|
+
? { user, corrupt: true, locked: true, failures: null, maxFailures: limits.maxFailures ?? DEFAULT_MAX_FAILURES, lockedUntil: null, lockoutsLast24h: null, maxLockouts: limits.maxLockouts ?? DEFAULT_MAX_LOCKOUTS, hardLock: null }
|
|
304
|
+
: { user, ...describeUser(userOf(state, user), limits, now) }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Human-readable lines for `describeUsers`'s result. */
|
|
309
|
+
export function formatStatus(users, { statePath, fileStatus, configured, unlockCommand = "sudo gh-broker otp unlock" }) {
|
|
310
|
+
const lines = [`OTP configured: ${configured ? "yes" : "no (re-run the installer to set it up)"}`, `state file: ${statePath} (${fileStatus})`];
|
|
311
|
+
if (fileStatus === "corrupt") lines.push("LOCKED: the state file is unreadable, so the broker treats every user as hard-locked.", `Clear it with: ${unlockCommand} --all`);
|
|
312
|
+
if (users.length === 0) lines.push("no agents registered");
|
|
313
|
+
for (const d of users) {
|
|
314
|
+
lines.push(`${d.user}:`);
|
|
315
|
+
if (d.corrupt) {
|
|
316
|
+
lines.push(" locked (state file unreadable)");
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
lines.push(` bad codes so far: ${d.failures} of ${d.maxFailures}`, ` short lockouts in the last 24 h: ${d.lockoutsLast24h} of ${d.maxLockouts}`);
|
|
320
|
+
if (d.hardLock) lines.push(` HARD LOCK since ${d.hardLock.since} after ${d.hardLock.lockouts} lockouts. Clear it with: ${unlockCommand} ${d.user}`);
|
|
321
|
+
else if (d.lockedUntil) lines.push(` short lockout until ${d.lockedUntil}`);
|
|
322
|
+
else lines.push(" not locked");
|
|
323
|
+
}
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
@@ -92,6 +92,7 @@ export function referencedCredNames(config) {
|
|
|
92
92
|
}
|
|
93
93
|
if (config.app) add(config.app.privateKey);
|
|
94
94
|
if (config.npm) add(config.npm.token);
|
|
95
|
+
if (config.otp) add(config.otp.seed);
|
|
95
96
|
for (const owner of Object.values(config.ghcr?.owners ?? {})) add(owner.credential);
|
|
96
97
|
return [...names];
|
|
97
98
|
}
|
package/deploy/lib/system.mjs
CHANGED
|
@@ -72,29 +72,39 @@ export function requireUid(name) {
|
|
|
72
72
|
export function ensureGroup(name) {
|
|
73
73
|
if (!groupExists(name)) run("groupadd", ["--system", name]);
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Create the dedicated service user + its group + the socket bridge group (idempotent). */
|
|
80
|
-
export function ensureServiceUser(svcUser, svcGroup, clientsGroup) {
|
|
75
|
+
/** Create the dedicated service user + its group (idempotent). Agents get their own groups (ensureAgentGroup). */
|
|
76
|
+
export function ensureServiceUser(svcUser, svcGroup) {
|
|
81
77
|
ensureGroup(svcGroup);
|
|
82
|
-
ensureGroup(clientsGroup);
|
|
83
78
|
if (!userExists(svcUser)) {
|
|
84
79
|
run("useradd", [
|
|
85
80
|
"--system",
|
|
86
81
|
"--gid", svcGroup,
|
|
87
|
-
"--groups", clientsGroup,
|
|
88
82
|
"--home-dir", "/nonexistent",
|
|
89
83
|
"--shell", "/usr/sbin/nologin",
|
|
90
84
|
"--comment", "CLDMV gh-broker service",
|
|
91
85
|
svcUser
|
|
92
86
|
]);
|
|
93
|
-
} else {
|
|
94
|
-
addUserToGroup(svcUser, clientsGroup);
|
|
95
87
|
}
|
|
96
88
|
}
|
|
97
89
|
|
|
90
|
+
/** Register an agent: its `gh-broker-agent-<user>` group with exactly the service user and the agent as members (idempotent). */
|
|
91
|
+
export function ensureAgentGroup(user, svcUser = "gh-broker") {
|
|
92
|
+
const group = `gh-broker-agent-${user}`;
|
|
93
|
+
ensureGroup(group);
|
|
94
|
+
// Set the member list outright, so an extra member left in the group is removed.
|
|
95
|
+
run("gpasswd", ["--members", `${svcUser},${user}`, group]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unregister an agent: empty its group, which the broker then ignores. The group itself is kept,
|
|
100
|
+
* because processes the agent already has running keep its gid; deleting the group would free
|
|
101
|
+
* that gid for the next agent's group, and those processes could reach the new agent's socket.
|
|
102
|
+
*/
|
|
103
|
+
export function removeAgentGroup(user) {
|
|
104
|
+
const group = `gh-broker-agent-${user}`;
|
|
105
|
+
if (groupExists(group)) run("gpasswd", ["--members", "", group]);
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
/** Create a plain unprivileged agent user with a home + login shell, and no sudo. */
|
|
99
109
|
export function createAgentUser(name) {
|
|
100
110
|
run("useradd", ["--create-home", "--shell", "/bin/bash", name]);
|
|
@@ -180,7 +190,8 @@ function mkdirp(path, mode, uid, gid) {
|
|
|
180
190
|
* Create the GHCR drop directory: `2750 <agentUser>:<svcGroup>`. The agent (owner) writes
|
|
181
191
|
* archives there; the setgid bit gives every new file the service group, so the broker can
|
|
182
192
|
* read a group-readable archive while other users can't see in. A missing parent is created
|
|
183
|
-
* `0755 root:root`; an existing parent is never chmod-ed. Refuses a symlink at the path.
|
|
193
|
+
* `0755 root:root`; an existing parent is never chmod-ed. Refuses a symlink at the path. An
|
|
194
|
+
* existing directory keeps its owner: `agentUser` only owns a directory created now.
|
|
184
195
|
*/
|
|
185
196
|
export function ensureDropDir(path, agentUser, svcGroup) {
|
|
186
197
|
const parent = dirname(path);
|
|
@@ -194,8 +205,9 @@ export function ensureDropDir(path, agentUser, svcGroup) {
|
|
|
194
205
|
if (!st) mkdirSync(path, { mode: 0o750 });
|
|
195
206
|
else if (st.isSymbolicLink()) throw new Error(`gh-broker install: ${path} is a symlink — refusing to use it as the GHCR drop dir`);
|
|
196
207
|
else if (!st.isDirectory()) throw new Error(`gh-broker install: ${path} exists and is not a directory`);
|
|
197
|
-
chownSync(path, requireUid(agentUser), requireGid(svcGroup));
|
|
208
|
+
chownSync(path, st ? st.uid : requireUid(agentUser), requireGid(svcGroup));
|
|
198
209
|
chmodSync(path, 0o2750); // after chown: chown clears the setgid bit
|
|
210
|
+
return { created: !st };
|
|
199
211
|
}
|
|
200
212
|
|
|
201
213
|
/** Create /etc/gh-broker (0750 root:group) and /opt/gh-broker (0755 svcUser:group). */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/real-deps.mjs
|
|
5
|
+
* @Date: 2026-11-01T20:43:03-08:00 (1793594583)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-11-01T20:43:03-08:00 (1793594583)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The real effects behind the `gh-broker` CLI (lib/cli.mjs), also used by the installer for the
|
|
18
|
+
* MCP config and smoke test. Wiring only: the logic and its tests are in lib/. Kept apart from
|
|
19
|
+
* cli.mjs because cli.mjs awaits the installer, which imports this.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
22
|
+
import { hostname } from "node:os";
|
|
23
|
+
import { join, dirname } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { parseGroups, parsePasswd } from "./lib/agents.mjs";
|
|
26
|
+
import { stateStore, writeFileAtomic, storeSeed } from "./lib/otp.mjs";
|
|
27
|
+
import { readOwnedFile } from "./lib/mcp-config.mjs";
|
|
28
|
+
import { readSecretsFile, writeSecretsFile } from "./lib/secret-store.mjs";
|
|
29
|
+
import { smokeTest } from "./lib/smoke.mjs";
|
|
30
|
+
import { run, isRoot, requireUid, requireGid, systemdCredsEncrypt, writeCredsDropin, restartService, ensureAgentGroup, removeAgentGroup } from "./lib/system.mjs";
|
|
31
|
+
|
|
32
|
+
const SVC = "gh-broker";
|
|
33
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
+
const configDir = () => process.env.GH_BROKER_CONFIG_DIR || "/etc/gh-broker";
|
|
35
|
+
const configPath = () => process.env.GH_BROKER_CONFIG || join(configDir(), "config.json");
|
|
36
|
+
const readText = (p) => {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(p, "utf8");
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.code === "ENOENT") return undefined;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The version `install` recorded in /opt/gh-broker (root's own plain file; anything else there is ignored, never followed). */
|
|
46
|
+
const deployedVersion = () => {
|
|
47
|
+
try {
|
|
48
|
+
return readOwnedFile("/opt/gh-broker/version", 0)?.text.trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The real effects for lib/cli.mjs `runCli` (also used by the installer for the MCP config and smoke test). */
|
|
55
|
+
export function realDeps() {
|
|
56
|
+
let store;
|
|
57
|
+
const states = () => (store ??= stateStore({ uid: requireUid(SVC), gid: requireGid(SVC) }));
|
|
58
|
+
return {
|
|
59
|
+
log: (m) => console.log(m),
|
|
60
|
+
error: (m) => console.error(m),
|
|
61
|
+
now: () => Date.now(),
|
|
62
|
+
isRoot,
|
|
63
|
+
host: () => hostname(),
|
|
64
|
+
readConfig: () => JSON.parse(readFileSync(configPath(), "utf8")),
|
|
65
|
+
groups: () => parseGroups(readText("/etc/group") ?? ""),
|
|
66
|
+
userInfo: (user) => {
|
|
67
|
+
const r = run("getent", ["passwd", user], { allowFail: true });
|
|
68
|
+
return r.status === 0 ? parsePasswd(r.stdout) : undefined;
|
|
69
|
+
},
|
|
70
|
+
ensureGroup: (user) => ensureAgentGroup(user, SVC),
|
|
71
|
+
removeGroup: removeAgentGroup,
|
|
72
|
+
mcpIO: { read: readOwnedFile, write: (p, text, owner) => writeFileAtomic(p, text, owner) },
|
|
73
|
+
socketExists: existsSync,
|
|
74
|
+
smoke: smokeTest,
|
|
75
|
+
versions: () => ({ pkg: JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version, deployed: deployedVersion() }),
|
|
76
|
+
readState: (path) => states().read(path),
|
|
77
|
+
writeState: (path, state) => states().write(path, state),
|
|
78
|
+
storeSeed: (cfg, name, seed) => {
|
|
79
|
+
const secrets = cfg.secrets?.path || process.env.GH_BROKER_SECRETS || join(configDir(), "secrets.json");
|
|
80
|
+
const gid = requireGid(SVC);
|
|
81
|
+
return storeSeed({
|
|
82
|
+
config: cfg,
|
|
83
|
+
name,
|
|
84
|
+
seed,
|
|
85
|
+
paths: { secrets, credsDir: join(dirname(configPath()), "creds") },
|
|
86
|
+
io: {
|
|
87
|
+
readSecretsFile,
|
|
88
|
+
writeSecretsFile: (p, v) => writeSecretsFile(p, v, { gid }),
|
|
89
|
+
removeFile: (f) => rmSync(f, { force: true }),
|
|
90
|
+
encrypt: systemdCredsEncrypt,
|
|
91
|
+
rename: renameSync,
|
|
92
|
+
listCreds: (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".cred")).map((f) => f.slice(0, -".cred".length)) : []),
|
|
93
|
+
writeDropin: writeCredsDropin
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
restart: restartService,
|
|
98
|
+
sudoUser: () => process.env.SUDO_USER,
|
|
99
|
+
// Same journal identifier as the daemon (SyslogIdentifier=gh-broker), so `journalctl -t gh-broker` shows both.
|
|
100
|
+
audit: (entry) => run("systemd-cat", ["--identifier=gh-broker", "--priority=notice"], { input: `${JSON.stringify(entry)}\n` })
|
|
101
|
+
};
|
|
102
|
+
}
|