@jr2/cli 0.2.1 → 0.4.1
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 +1 -1
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +266 -24
- package/package.json +6 -4
- package/src/assembly.ts +432 -0
- package/src/build.ts +137 -300
- package/src/cli.ts +6 -2
- package/src/client.ts +126 -33
- package/src/commands/down.ts +2 -1
- package/src/commands/init.ts +9 -2
- package/src/commands/kit.ts +24 -12
- package/src/commands/logs.ts +9 -0
- package/src/commands/run.ts +58 -4
- package/src/commands/runs.ts +96 -4
- package/src/commands/send.ts +20 -7
- package/src/commands/status.ts +90 -14
- package/src/commands/up.ts +1174 -183
- package/src/commands/version.ts +18 -13
- package/src/deploy.ts +782 -111
- package/src/effector-image.ts +133 -0
- package/src/held-pki.ts +306 -0
- package/src/index.ts +4 -0
- package/src/kube.ts +115 -9
- package/src/nodes.ts +27 -2
- package/src/sweep.ts +27 -13
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// What a push owes the author's Effector image (ADR-0068). The Sandbox Effector runs in the
|
|
2
|
+
// author's image — the Sandbox Image by default — and runs jr2's own programs from one fixed PATH
|
|
3
|
+
// (`/usr/local/bin:/usr/bin:/bin`, never the image's own). So, per push form the Workspace holds,
|
|
4
|
+
// `jr2 up` reads the image it built — or, for the stock Harness image, which the kit builds, knows
|
|
5
|
+
// what it holds — and refuses, with the fix, an image that lacks what that form needs:
|
|
6
|
+
//
|
|
7
|
+
// - ssh transport (`ssh://`, `git@`): `ssh`, and a passwd entry for the uid the Effector runs as —
|
|
8
|
+
// OpenSSH's client calls `getpwuid(getuid())` before any option, and exits "No user exists for
|
|
9
|
+
// uid N" without one;
|
|
10
|
+
// - ssh signing (`sign: { sshKey }`): `ssh-keygen` (`-Y sign` does no passwd lookup);
|
|
11
|
+
// - GPG signing (`sign: { gpgKey }`): `gpg` and `gpg-agent`;
|
|
12
|
+
// - a token over https: nothing beyond `git`, the Sandbox Image floor (the helper is jr2's binary).
|
|
13
|
+
//
|
|
14
|
+
// The uid is the image's: its `USER` line by name or number, else 1000 (ADR-0037's fallback).
|
|
15
|
+
// `http` (a Worktree file body included) and the credentialed command owe nothing: the first is
|
|
16
|
+
// Go, the last is the author's argv.
|
|
17
|
+
|
|
18
|
+
/** Where the Effector looks for jr2's own programs: effector/internal/gitclean's Path. */
|
|
19
|
+
export const EFFECTOR_TOOL_DIRS = ["/usr/local/bin", "/usr/bin", "/bin"] as const;
|
|
20
|
+
|
|
21
|
+
/** One form a push of a Workspace takes, by what it owes its image. */
|
|
22
|
+
export type PushForm = "ssh-transport" | "ssh-sign" | "gpg-sign";
|
|
23
|
+
|
|
24
|
+
/** The programs each form needs, by name. */
|
|
25
|
+
const TOOLS: Record<PushForm, readonly string[]> = {
|
|
26
|
+
"ssh-transport": ["ssh"],
|
|
27
|
+
"ssh-sign": ["ssh-keygen"],
|
|
28
|
+
"gpg-sign": ["gpg", "gpg-agent"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** What to install, by program: the package name on Debian and on Alpine, where they differ. */
|
|
32
|
+
const PACKAGES: Record<string, string> = {
|
|
33
|
+
ssh: "openssh-client",
|
|
34
|
+
"ssh-keygen": "openssh-client (Alpine: openssh-keygen)",
|
|
35
|
+
gpg: "gnupg",
|
|
36
|
+
"gpg-agent": "gnupg",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the stock Harness image holds of the forms' programs, and its /etc/passwd: the fallback
|
|
41
|
+
* Sandbox Image, which the Sandbox Effector runs in when the Workspace names no image and the
|
|
42
|
+
* instance has no `images/default`. The kit builds it (deploy/harness/Dockerfile, its last stage:
|
|
43
|
+
* `node:24-slim` with `git` and `ca-certificates`, `USER 1000`, node's uid), so `jr2 up` knows it in both worlds
|
|
44
|
+
* without reading it — a published one is never pulled to the host for this. A test holds the
|
|
45
|
+
* Dockerfile to it.
|
|
46
|
+
*/
|
|
47
|
+
export const STOCK_HARNESS_IMAGE = {
|
|
48
|
+
present: ["/usr/bin/git"],
|
|
49
|
+
passwd: "root:x:0:0:root:/root:/bin/bash\nnode:x:1000:1000::/home/node:/bin/bash\n",
|
|
50
|
+
user: "1000",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** Every path the forms' programs may be at: each program in each of the Effector's tool dirs. */
|
|
54
|
+
export function effectorImagePaths(forms: readonly PushForm[]): string[] {
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const form of new Set(forms)) {
|
|
57
|
+
for (const tool of TOOLS[form]) for (const dir of EFFECTOR_TOOL_DIRS) out.push(`${dir}/${tool}`);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The uid the Effector runs as, from the image's recorded `USER`: a name, a number, or — none
|
|
63
|
+
* declared — ADR-0037's 1000. A `user:group` line decides by its user half. */
|
|
64
|
+
export function effectorSeat(user: string): { name: string } | { uid: number } {
|
|
65
|
+
const head = user.split(":")[0]!.trim();
|
|
66
|
+
if (head === "") return { uid: 1000 };
|
|
67
|
+
if (/^\d+$/.test(head)) return { uid: Number(head) };
|
|
68
|
+
return { name: head };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether /etc/passwd's text holds an entry for the seat. */
|
|
72
|
+
export function passwdHolds(passwd: string, seat: { name: string } | { uid: number }): boolean {
|
|
73
|
+
for (const line of passwd.split("\n")) {
|
|
74
|
+
const fields = line.split(":");
|
|
75
|
+
if (fields.length < 3) continue;
|
|
76
|
+
if ("name" in seat ? fields[0] === seat.name : fields[2] === String(seat.uid)) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Why the image cannot run the forms, with the fix, or undefined. `present` is which of
|
|
83
|
+
* {@link effectorImagePaths} the image holds (links followed); `passwd` its `/etc/passwd`, "" when
|
|
84
|
+
* it has none; `user` its recorded `USER`.
|
|
85
|
+
*/
|
|
86
|
+
export function effectorImageRefusal(input: {
|
|
87
|
+
/** For the message: `Workspace of workflow "w"`. */
|
|
88
|
+
label: string;
|
|
89
|
+
ref: string;
|
|
90
|
+
user: string;
|
|
91
|
+
/** Each form, and the push it is for: `the push of Repo Slot "app"`. */
|
|
92
|
+
forms: ReadonlyArray<{ form: PushForm; push: string }>;
|
|
93
|
+
present: ReadonlySet<string>;
|
|
94
|
+
passwd: string;
|
|
95
|
+
/** The image is the stock Harness image: the fix is an image of the author's, not an install. */
|
|
96
|
+
stock?: boolean;
|
|
97
|
+
}): string | undefined {
|
|
98
|
+
const { label, ref } = input;
|
|
99
|
+
for (const { form, push } of input.forms) {
|
|
100
|
+
for (const tool of TOOLS[form]) {
|
|
101
|
+
if (EFFECTOR_TOOL_DIRS.some((dir) => input.present.has(`${dir}/${tool}`))) continue;
|
|
102
|
+
const why =
|
|
103
|
+
form === "ssh-transport" ? "over ssh" : form === "ssh-sign" ? "signed with an ssh key" : "signed with GPG";
|
|
104
|
+
if (input.stock) {
|
|
105
|
+
return (
|
|
106
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector runs in the stock Harness image ${ref}, ` +
|
|
107
|
+
`which holds git alone — the Workspace names no image and the instance has no images/default; give it an ` +
|
|
108
|
+
`image that holds ${tool} (${PACKAGES[tool]}): an images/default Sandbox Image, the workspace()'s image, ` +
|
|
109
|
+
`or its effector: { image } (ADR-0068)`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
`${label}: ${push} ${why} needs ${tool}, and its Sandbox Effector's image ${ref} holds none in ` +
|
|
114
|
+
`${EFFECTOR_TOOL_DIRS.join(", ")} — the Effector runs jr2's programs from there alone; install it in the ` +
|
|
115
|
+
`image (${PACKAGES[tool]}) (ADR-0068)`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (form !== "ssh-transport") continue;
|
|
119
|
+
const seat = effectorSeat(input.user);
|
|
120
|
+
if (passwdHolds(input.passwd, seat)) continue;
|
|
121
|
+
const who = "name" in seat ? `user ${seat.name}` : `uid ${seat.uid}`;
|
|
122
|
+
const fix =
|
|
123
|
+
"name" in seat
|
|
124
|
+
? `a USER line naming a user the image's /etc/passwd holds`
|
|
125
|
+
: `RUN adduser -D -u ${seat.uid} <name> in the image` +
|
|
126
|
+
(input.user === "" ? " (the image declares no USER, so the Effector runs as uid 1000)" : "");
|
|
127
|
+
return (
|
|
128
|
+
`${label}: ${push} over ssh needs a passwd entry for the ${who} the Sandbox Effector runs as, and its image ` +
|
|
129
|
+
`${ref} has none — ssh exits "No user exists for uid" before it connects; add ${fix} (ADR-0068)`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
package/src/held-pki.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// The held-secret PKI (ADR-0059): the per-Instance CA and the leaves the Custodian ends TLS with.
|
|
2
|
+
//
|
|
3
|
+
// `jr2 up` issues every leaf BEFORE a pod starts, so the CA's private key never enters a Harness
|
|
4
|
+
// pod: it lives in the `jr2-held-ca` Secret, which no pod mounts, and `jr2 up` alone reads it over
|
|
5
|
+
// the kube API. A Custodian holds leaves and no key that could mint one.
|
|
6
|
+
//
|
|
7
|
+
// jr2 WRITES two fixed certificate shapes and nothing else, so this is a small DER writer over
|
|
8
|
+
// `node:crypto` rather than a dependency: the CLI's one runtime dependency is `ts-blank-space`, and
|
|
9
|
+
// the libraries that write X.509 bring a dozen packages (`@peculiar/x509`) or an ASN.1 parser and
|
|
10
|
+
// pure-JS RSA (`node-forge`) jr2 has no use for. Nor does it shell to `openssl`: that would add a
|
|
11
|
+
// binary to `jr2 up`'s floor, and macOS ships LibreSSL, whose flags differ. Node VERIFIES what this
|
|
12
|
+
// writes — the tests read every certificate back with `crypto.X509Certificate`, and the Custodian
|
|
13
|
+
// suite ends real TLS handshakes with them.
|
|
14
|
+
//
|
|
15
|
+
// CA CN=jr2 held-secret CA (<instance>) CA:true pathLen:0, keyCertSign+cRLSign, SKI 10 years
|
|
16
|
+
// leaf CN=<host> one DNS or IP SAN, CA:false, digitalSignature, 1 year
|
|
17
|
+
// serverAuth, AKI, SKI
|
|
18
|
+
//
|
|
19
|
+
// Keys are ECDSA P-256. Serials are 16 random bytes, top bit clear. `notBefore` is an hour back,
|
|
20
|
+
// for clock skew between this host and a node.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
createHash,
|
|
24
|
+
createPrivateKey,
|
|
25
|
+
generateKeyPairSync,
|
|
26
|
+
randomBytes,
|
|
27
|
+
sign,
|
|
28
|
+
X509Certificate,
|
|
29
|
+
type KeyObject,
|
|
30
|
+
} from "node:crypto";
|
|
31
|
+
import { isIP } from "node:net";
|
|
32
|
+
import { rootCertificates } from "node:tls";
|
|
33
|
+
|
|
34
|
+
/** One certificate and its key, both PEM. */
|
|
35
|
+
export type Pem = { cert: string; key: string };
|
|
36
|
+
|
|
37
|
+
const HOUR = 3_600_000;
|
|
38
|
+
const DAY = 24 * HOUR;
|
|
39
|
+
/** A leaf is issued again when fewer than this many days remain on it. */
|
|
40
|
+
const RENEW_BEFORE_DAYS = 30;
|
|
41
|
+
|
|
42
|
+
// ---- DER ----------------------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function length(n: number): Buffer {
|
|
45
|
+
if (n < 0x80) return Buffer.from([n]);
|
|
46
|
+
const bytes: number[] = [];
|
|
47
|
+
for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v % 256);
|
|
48
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tlv(tag: number, content: Buffer): Buffer {
|
|
52
|
+
return Buffer.concat([Buffer.from([tag]), length(content.length), content]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const seq = (...parts: Buffer[]): Buffer => tlv(0x30, Buffer.concat(parts));
|
|
56
|
+
const set = (...parts: Buffer[]): Buffer => tlv(0x31, Buffer.concat(parts));
|
|
57
|
+
const octets = (content: Buffer): Buffer => tlv(0x04, content);
|
|
58
|
+
const bits = (content: Buffer, unused = 0): Buffer => tlv(0x03, Buffer.concat([Buffer.from([unused]), content]));
|
|
59
|
+
const bool = (value: boolean): Buffer => tlv(0x01, Buffer.from([value ? 0xff : 0x00]));
|
|
60
|
+
const utf8 = (value: string): Buffer => tlv(0x0c, Buffer.from(value, "utf8"));
|
|
61
|
+
/** A context-specific tag: constructed and EXPLICIT for `[0]`/`[3]` of the TBS, primitive and
|
|
62
|
+
* IMPLICIT for a SAN or a key identifier. */
|
|
63
|
+
const context = (n: number, content: Buffer, constructed: boolean): Buffer =>
|
|
64
|
+
tlv((constructed ? 0xa0 : 0x80) | n, content);
|
|
65
|
+
|
|
66
|
+
/** A DER INTEGER from its big-endian magnitude: minimal, and positive. */
|
|
67
|
+
function integer(magnitude: Buffer): Buffer {
|
|
68
|
+
let start = 0;
|
|
69
|
+
while (start < magnitude.length - 1 && magnitude[start] === 0 && (magnitude[start + 1] as number) < 0x80) start++;
|
|
70
|
+
const trimmed = magnitude.subarray(start);
|
|
71
|
+
return tlv(0x02, (trimmed[0] as number) >= 0x80 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function oid(dotted: string): Buffer {
|
|
75
|
+
const [a, b, ...rest] = dotted.split(".").map(Number) as [number, number, ...number[]];
|
|
76
|
+
const out = [40 * a + b];
|
|
77
|
+
for (const n of rest) {
|
|
78
|
+
const chunk: number[] = [n & 0x7f];
|
|
79
|
+
for (let v = Math.floor(n / 128); v > 0; v = Math.floor(v / 128)) chunk.unshift(0x80 | (v & 0x7f));
|
|
80
|
+
out.push(...chunk);
|
|
81
|
+
}
|
|
82
|
+
return tlv(0x06, Buffer.from(out));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** UTCTime until 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5). */
|
|
86
|
+
function time(date: Date): Buffer {
|
|
87
|
+
const iso = date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
88
|
+
return date.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${iso}Z`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const name = (cn: string): Buffer => seq(set(seq(oid("2.5.4.3"), utf8(cn))));
|
|
92
|
+
const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
|
|
93
|
+
|
|
94
|
+
function extension(id: string, critical: boolean, value: Buffer): Buffer {
|
|
95
|
+
return seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- keys ---------------------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** A P-256 public key's uncompressed point — the subjectPublicKey bits a key identifier hashes. */
|
|
101
|
+
function ecPoint(publicKey: KeyObject): Buffer {
|
|
102
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
103
|
+
return Buffer.concat([
|
|
104
|
+
Buffer.from([0x04]),
|
|
105
|
+
Buffer.from(jwk.x as string, "base64url"),
|
|
106
|
+
Buffer.from(jwk.y as string, "base64url"),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function p256(): { publicKeyDer: Buffer; point: Buffer; keyPem: string } {
|
|
111
|
+
const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
112
|
+
return {
|
|
113
|
+
publicKeyDer: pair.publicKey.export({ type: "spki", format: "der" }),
|
|
114
|
+
point: ecPoint(pair.publicKey),
|
|
115
|
+
keyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }) as string,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** RFC 5280's key identifier, method 1: the SHA-1 of the subjectPublicKey bits. */
|
|
120
|
+
function keyId(point: Buffer): Buffer {
|
|
121
|
+
return createHash("sha1").update(point).digest();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function serial(): Buffer {
|
|
125
|
+
const bytes = randomBytes(16);
|
|
126
|
+
bytes[0] = (bytes[0] as number) & 0x7f || 0x01;
|
|
127
|
+
return bytes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pem(der: Buffer): string {
|
|
131
|
+
const b64 = der
|
|
132
|
+
.toString("base64")
|
|
133
|
+
.replace(/(.{64})/g, "$1\n")
|
|
134
|
+
.replace(/\n$/, "");
|
|
135
|
+
return `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Sign a TBSCertificate into a Certificate. */
|
|
139
|
+
function certificate(tbs: Buffer, signerKeyPem: string): Buffer {
|
|
140
|
+
const signature = sign("sha256", tbs, createPrivateKey(signerKeyPem));
|
|
141
|
+
return seq(tbs, ECDSA_SHA256, bits(signature));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- the two shapes -----------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** The per-Instance CA: `CN=jr2 held-secret CA (<instance>)`, ten years. */
|
|
147
|
+
export function issueCa(instance: string, now: Date = new Date()): Pem {
|
|
148
|
+
const key = p256();
|
|
149
|
+
const subject = name(`jr2 held-secret CA (${instance})`);
|
|
150
|
+
const tbs = seq(
|
|
151
|
+
context(0, integer(Buffer.from([2])), true),
|
|
152
|
+
integer(serial()),
|
|
153
|
+
ECDSA_SHA256,
|
|
154
|
+
subject,
|
|
155
|
+
seq(time(new Date(now.getTime() - HOUR)), time(new Date(now.getTime() + 3650 * DAY))),
|
|
156
|
+
subject,
|
|
157
|
+
key.publicKeyDer,
|
|
158
|
+
context(
|
|
159
|
+
3,
|
|
160
|
+
seq(
|
|
161
|
+
extension("2.5.29.19", true, seq(bool(true), integer(Buffer.from([0])))),
|
|
162
|
+
// keyCertSign (bit 5) + cRLSign (bit 6): 0b0000_0110, one unused bit.
|
|
163
|
+
extension("2.5.29.15", true, bits(Buffer.from([0x06]), 1)),
|
|
164
|
+
extension("2.5.29.14", false, octets(keyId(key.point))),
|
|
165
|
+
),
|
|
166
|
+
true,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
return { cert: pem(certificate(tbs, key.keyPem)), key: key.keyPem };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A leaf for one bound host — a DNS name or an IP literal — signed by `ca`, one year. */
|
|
173
|
+
export function issueLeaf(ca: Pem, host: string, now: Date = new Date()): Pem {
|
|
174
|
+
const key = p256();
|
|
175
|
+
const caCert = new X509Certificate(ca.cert);
|
|
176
|
+
const caKeyId = keyId(ecPoint(caCert.publicKey));
|
|
177
|
+
const tbs = seq(
|
|
178
|
+
context(0, integer(Buffer.from([2])), true),
|
|
179
|
+
integer(serial()),
|
|
180
|
+
ECDSA_SHA256,
|
|
181
|
+
issuerName(caCert),
|
|
182
|
+
seq(time(new Date(now.getTime() - HOUR)), time(new Date(now.getTime() + 365 * DAY))),
|
|
183
|
+
name(host),
|
|
184
|
+
key.publicKeyDer,
|
|
185
|
+
context(
|
|
186
|
+
3,
|
|
187
|
+
seq(
|
|
188
|
+
extension("2.5.29.17", false, seq(subjectAltName(host))),
|
|
189
|
+
extension("2.5.29.19", false, seq()),
|
|
190
|
+
// digitalSignature (bit 0): 0b1000_0000, seven unused bits.
|
|
191
|
+
extension("2.5.29.15", true, bits(Buffer.from([0x80]), 7)),
|
|
192
|
+
extension("2.5.29.37", false, seq(oid("1.3.6.1.5.5.7.3.1"))),
|
|
193
|
+
extension("2.5.29.35", false, seq(context(0, caKeyId, false))),
|
|
194
|
+
extension("2.5.29.14", false, octets(keyId(key.point))),
|
|
195
|
+
),
|
|
196
|
+
true,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
return { cert: pem(certificate(tbs, ca.key)), key: key.keyPem };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The issuer, as the CA's own subject — read off its DER rather than rebuilt from its CN, so a CA
|
|
203
|
+
* this module did not write still chains. */
|
|
204
|
+
function issuerName(ca: X509Certificate): Buffer {
|
|
205
|
+
const der = ca.raw;
|
|
206
|
+
// Certificate → TBSCertificate → [0] version, serial, sigAlg, issuer, validity, SUBJECT.
|
|
207
|
+
const tbs = children(children(der, 0)[0] as Buffer, 0);
|
|
208
|
+
return tbs[5] as Buffer;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The DER elements inside one constructed element, whole (tag and length included). */
|
|
212
|
+
function children(der: Buffer, offset: number): Buffer[] {
|
|
213
|
+
const header = (at: number): { start: number; end: number } => {
|
|
214
|
+
const first = der[at + 1] as number;
|
|
215
|
+
if (first < 0x80) return { start: at + 2, end: at + 2 + first };
|
|
216
|
+
const n = first & 0x7f;
|
|
217
|
+
let len = 0;
|
|
218
|
+
for (let i = 0; i < n; i++) len = len * 256 + (der[at + 2 + i] as number);
|
|
219
|
+
return { start: at + 2 + n, end: at + 2 + n + len };
|
|
220
|
+
};
|
|
221
|
+
const outer = header(offset);
|
|
222
|
+
const out: Buffer[] = [];
|
|
223
|
+
for (let at = outer.start; at < outer.end; ) {
|
|
224
|
+
const inner = header(at);
|
|
225
|
+
out.push(der.subarray(at, inner.end));
|
|
226
|
+
at = inner.end;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function subjectAltName(host: string): Buffer {
|
|
232
|
+
const family = isIP(host);
|
|
233
|
+
if (family === 4) return context(7, Buffer.from(host.split(".").map(Number)), false);
|
|
234
|
+
if (family === 6) return context(7, ipv6Bytes(host), false);
|
|
235
|
+
return context(2, Buffer.from(host, "ascii"), false);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ipv6Bytes(host: string): Buffer {
|
|
239
|
+
const [head = "", tail] = host.split("::");
|
|
240
|
+
const groups = (part: string) => (part ? part.split(":") : []);
|
|
241
|
+
const left = groups(head);
|
|
242
|
+
const right = tail === undefined ? [] : groups(tail);
|
|
243
|
+
const all = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
|
|
244
|
+
return Buffer.from(all.flatMap((g) => [parseInt(g, 16) >> 8, parseInt(g, 16) & 0xff]));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- reuse --------------------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Does a leaf `jr2 up` already holds still serve this host? Only when its one SAN names exactly
|
|
251
|
+
* this host, it verifies against the CURRENT CA, its key is its own, and more than 30 days remain.
|
|
252
|
+
* Anything else — a moved host, a rotated CA, an expiring leaf — issues a new one.
|
|
253
|
+
*/
|
|
254
|
+
export function leafFits(leaf: Pem, ca: Pem, host: string, now: Date = new Date()): boolean {
|
|
255
|
+
try {
|
|
256
|
+
const cert = new X509Certificate(leaf.cert);
|
|
257
|
+
const issuer = new X509Certificate(ca.cert);
|
|
258
|
+
if (!cert.verify(issuer.publicKey)) return false;
|
|
259
|
+
if (!cert.checkPrivateKey(createPrivateKey(leaf.key))) return false;
|
|
260
|
+
const want = isIP(host) === 0 ? `DNS:${host}` : undefined;
|
|
261
|
+
if (want !== undefined ? cert.subjectAltName !== want : cert.checkIP(host) === undefined) return false;
|
|
262
|
+
if (isIP(host) !== 0 && (cert.subjectAltName ?? "").split(",").length !== 1) return false;
|
|
263
|
+
return new Date(cert.validTo).getTime() - now.getTime() > RENEW_BEFORE_DAYS * DAY;
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A CA `jr2 up` found in the cluster is kept when it parses, is a CA, and its key is its own. */
|
|
270
|
+
export function caFits(ca: Pem, now: Date = new Date()): boolean {
|
|
271
|
+
try {
|
|
272
|
+
const cert = new X509Certificate(ca.cert);
|
|
273
|
+
return (
|
|
274
|
+
cert.ca && cert.checkPrivateKey(createPrivateKey(ca.key)) && new Date(cert.validTo).getTime() > now.getTime()
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** SHA-256 of a certificate's DER — what the Instance Harness's held digest covers per leaf. */
|
|
282
|
+
export function fingerprint(certPem: string): string {
|
|
283
|
+
return new X509Certificate(certPem).fingerprint256;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- trust --------------------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The three bundles a held secret needs, beside the user's own `ca.crt` (ADR-0020, ADR-0059).
|
|
290
|
+
* "Roots" are the Mozilla roots of the Node that runs `jr2 up` — no file on the host, no dependency,
|
|
291
|
+
* and one list whatever engine verifies with it.
|
|
292
|
+
*
|
|
293
|
+
* extra.crt caBundle + the jr2 CA the Harness's NODE_EXTRA_CA_CERTS (adds to Node's roots)
|
|
294
|
+
* bundle.crt roots + both SSL_CERT_FILE and friends (these REPLACE a tool's store)
|
|
295
|
+
* upstream.crt roots + caBundle the Custodian's upstream trust — NEVER the jr2 CA, so
|
|
296
|
+
* it can never accept one of its own leaves from upstream
|
|
297
|
+
*/
|
|
298
|
+
export function trustBundles(jr2Ca: string, caBundle?: string): { extra: string; bundle: string; upstream: string } {
|
|
299
|
+
const roots = rootCertificates.map((c) => (c.endsWith("\n") ? c : `${c}\n`)).join("");
|
|
300
|
+
const user = caBundle ? (caBundle.endsWith("\n") ? caBundle : `${caBundle}\n`) : "";
|
|
301
|
+
return {
|
|
302
|
+
extra: `${user}${jr2Ca}`,
|
|
303
|
+
bundle: `${roots}${user}${jr2Ca}`,
|
|
304
|
+
upstream: `${roots}${user}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,3 +10,7 @@ export { assertKitVersion, checkKitVersion, resolvePackage, CLI_VERSION } from "
|
|
|
10
10
|
export type { Target, TargetOptions } from "./instance.ts";
|
|
11
11
|
export { kubectlKube } from "./kube.ts";
|
|
12
12
|
export type { KubePort } from "./kube.ts";
|
|
13
|
+
// The held-secret PKI (ADR-0059): what `jr2 up` issues the Custodian's leaves with — exported so a
|
|
14
|
+
// test can issue its own fixtures the same way (the @kind tier's HTTPS model provider).
|
|
15
|
+
export { issueCa, issueLeaf } from "./held-pki.ts";
|
|
16
|
+
export type { Pem } from "./held-pki.ts";
|
package/src/kube.ts
CHANGED
|
@@ -52,8 +52,8 @@ export type KubePort = {
|
|
|
52
52
|
const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
|
|
53
53
|
|
|
54
54
|
/** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
|
|
55
|
-
* identity
|
|
56
|
-
*
|
|
55
|
+
* identity; annotations carry the converged image map, which is too long for a label value
|
|
56
|
+
* (ADR-0038). */
|
|
57
57
|
export type KubeObject = {
|
|
58
58
|
metadata: {
|
|
59
59
|
name: string;
|
|
@@ -98,8 +98,11 @@ export type KubeAdmin = {
|
|
|
98
98
|
allNamespaces?: boolean;
|
|
99
99
|
context?: string;
|
|
100
100
|
}): Promise<T[]>;
|
|
101
|
-
/** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string.
|
|
102
|
-
|
|
101
|
+
/** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string. `serverSide` is for an
|
|
102
|
+
* object too large for client-side apply's `last-applied-configuration` annotation (256 KiB) —
|
|
103
|
+
* the trust bundles, which carry every root certificate (ADR-0059); jr2 is its only writer, so it
|
|
104
|
+
* takes the fields it owns (`--force-conflicts`). */
|
|
105
|
+
apply(opts: { manifest: string; context?: string; serverSide?: boolean }): Promise<void>;
|
|
103
106
|
/** `kubectl label --overwrite`. */
|
|
104
107
|
label(opts: {
|
|
105
108
|
kind: string;
|
|
@@ -135,6 +138,13 @@ export type KubeAdmin = {
|
|
|
135
138
|
previous?: boolean;
|
|
136
139
|
context?: string;
|
|
137
140
|
}): Promise<string>;
|
|
141
|
+
/** The API server's `gitVersion` (`kubectl get --raw /version`), e.g. `v1.35.0-gke.1200` — what
|
|
142
|
+
* ADR-0037's Kubernetes floor is judged against. THROWS when the server does not answer, like
|
|
143
|
+
* `listJson`: a floor check that passed because it could not look is no check at all. */
|
|
144
|
+
serverVersion(opts: { context?: string }): Promise<string>;
|
|
145
|
+
/** `kubectl create --dry-run=server -o json -f -`: the object as admission would store it,
|
|
146
|
+
* stored nowhere. THROWS on a refusal, with kubectl's reason. */
|
|
147
|
+
dryRunCreate(opts: { manifest: string; context?: string }): Promise<{ spec?: { resources?: unknown } }>;
|
|
138
148
|
/** Run a one-shot node script IN the cluster (`kubectl run --rm`) and return its output — the
|
|
139
149
|
* provider-preflight seam (ADR-0019: reachability must be probed from where pods live).
|
|
140
150
|
* `caPem` makes the probe trust a private CA the same way the Harness does (ADR-0020):
|
|
@@ -151,8 +161,8 @@ export type KubeAdmin = {
|
|
|
151
161
|
|
|
152
162
|
const nsArgs = (namespace?: string): string[] => (namespace ? ["--namespace", namespace] : []);
|
|
153
163
|
|
|
154
|
-
/** The
|
|
155
|
-
export type RolloutKind = "deployment" | "daemonset";
|
|
164
|
+
/** The workload kinds `jr2 up` rolls out and waits on. */
|
|
165
|
+
export type RolloutKind = "deployment" | "daemonset" | "statefulset";
|
|
156
166
|
|
|
157
167
|
/** One rollout to wait for: the object, by kind and name, in its namespace. */
|
|
158
168
|
export type RolloutRequest = {
|
|
@@ -183,6 +193,28 @@ export function rolloutStatusArgs({
|
|
|
183
193
|
];
|
|
184
194
|
}
|
|
185
195
|
|
|
196
|
+
/** ADR-0037's Kubernetes floor: every Sandbox's `/opt/jr2` is an `image` volume of the harness
|
|
197
|
+
* image, and image volumes are on by default from 1.35 (GA in 1.36). There is no fallback below it
|
|
198
|
+
* — a second pod shape kept alive for clusters no Instance runs on — so `jr2 up` refuses there. */
|
|
199
|
+
export const KUBE_FLOOR = { major: 1, minor: 35 } as const;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Why this server version cannot host an Instance, or undefined when it can. Pure, so the floor is
|
|
203
|
+
* checkable without a cluster. Judged on the leading `v<major>.<minor>` alone: distributions append
|
|
204
|
+
* their own suffixes (`-gke.1200`, `+k3s1`, `-eks-…`), and none of them moves the feature gate. A
|
|
205
|
+
* version with no such prefix is refused BY NAME rather than passed — a floor that waves through
|
|
206
|
+
* what it cannot read is the timeout ADR-0046 exists to replace.
|
|
207
|
+
*/
|
|
208
|
+
export function kubeFloorRefusal(gitVersion: string): string | undefined {
|
|
209
|
+
const floor = `${KUBE_FLOOR.major}.${KUBE_FLOOR.minor}`;
|
|
210
|
+
const why = `jr2 needs Kubernetes ${floor} or newer (every Sandbox mounts jr2's runtime as an image volume, ADR-0037)`;
|
|
211
|
+
const m = /^v?(\d+)\.(\d+)/.exec(gitVersion);
|
|
212
|
+
if (!m) return `reports version "${gitVersion}", which names no major.minor; ${why}`;
|
|
213
|
+
const [major, minor] = [Number(m[1]), Number(m[2])];
|
|
214
|
+
if (major > KUBE_FLOOR.major || (major === KUBE_FLOOR.major && minor >= KUBE_FLOOR.minor)) return undefined;
|
|
215
|
+
return `runs Kubernetes ${gitVersion}; ${why}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
186
218
|
/**
|
|
187
219
|
* Did this read fail because the cluster has no such RESOURCE TYPE (`kubectl get sandboxes… ` on a
|
|
188
220
|
* cluster with no jr2 CRD)? kubectl exits 1 with `the server doesn't have a resource type "…"`, and
|
|
@@ -238,8 +270,9 @@ export const kubectlAdmin: KubeAdmin = {
|
|
|
238
270
|
return (JSON.parse(stdout) as { items?: never[] }).items ?? [];
|
|
239
271
|
},
|
|
240
272
|
|
|
241
|
-
async apply({ manifest, context }) {
|
|
242
|
-
|
|
273
|
+
async apply({ manifest, context, serverSide }) {
|
|
274
|
+
const mode = serverSide ? ["--server-side", "--force-conflicts", "--field-manager=jr2"] : [];
|
|
275
|
+
await execStdin(["kubectl", ...ctxArgs(context), "apply", ...mode, "-f", "-"], manifest);
|
|
243
276
|
},
|
|
244
277
|
|
|
245
278
|
async label({ kind, name, namespace, labels, context }) {
|
|
@@ -284,6 +317,28 @@ export const kubectlAdmin: KubeAdmin = {
|
|
|
284
317
|
}
|
|
285
318
|
},
|
|
286
319
|
|
|
320
|
+
async serverVersion({ context }) {
|
|
321
|
+
// `get --raw /version`, not `kubectl version`: that one reports the CLIENT too; this asks the
|
|
322
|
+
// server alone, and answers in the server's own JSON. Bounded like every first contact: it is
|
|
323
|
+
// the first thing `jr2 up` asks, so a dead context fails here, fast, with kubectl's own reason.
|
|
324
|
+
try {
|
|
325
|
+
const { stdout } = await exec("kubectl", [...ctxArgs(context), "get", "--raw", "/version", ...reachArgs], {
|
|
326
|
+
timeout: REACH_BUDGET_MS,
|
|
327
|
+
});
|
|
328
|
+
return (JSON.parse(stdout) as { gitVersion?: string }).gitVersion ?? "";
|
|
329
|
+
} catch (e) {
|
|
330
|
+
throw new Error(unreachable(e));
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async dryRunCreate({ manifest, context }) {
|
|
335
|
+
const out = await execStdinOut(
|
|
336
|
+
["kubectl", ...ctxArgs(context), "create", "--dry-run=server", "-o", "json", "-f", "-"],
|
|
337
|
+
manifest,
|
|
338
|
+
);
|
|
339
|
+
return JSON.parse(out) as { spec?: { resources?: unknown } };
|
|
340
|
+
},
|
|
341
|
+
|
|
287
342
|
async runOneShot({ namespace, name, script, caPem, context }) {
|
|
288
343
|
// With a CA bundle: write the PEM to a file BEFORE node starts, because NODE_EXTRA_CA_CERTS
|
|
289
344
|
// is only honored as process-start env. The PEM rides an env var base64'd (multiline values
|
|
@@ -346,6 +401,8 @@ export type RolloutTarget = {
|
|
|
346
401
|
namespace: string;
|
|
347
402
|
selector: string;
|
|
348
403
|
context?: string;
|
|
404
|
+
/** Default 180s (see `rolloutStatusArgs`). */
|
|
405
|
+
timeoutSeconds?: number;
|
|
349
406
|
};
|
|
350
407
|
|
|
351
408
|
/** One container's state, as the kubelet tells it. `reason`/`message` are its words verbatim — the
|
|
@@ -470,7 +527,12 @@ async function gatherRolloutEvidence(kube: KubeAdmin, target: RolloutTarget): Pr
|
|
|
470
527
|
const live = pods.filter((p) => !p.metadata.deletionTimestamp);
|
|
471
528
|
if (pods.length > 0 && live.length === 0) notes.push(`every pod matching ${selector} is terminating`);
|
|
472
529
|
if (pods.length === 0 && notes.length === 0) {
|
|
473
|
-
const maker =
|
|
530
|
+
const maker =
|
|
531
|
+
target.kind === "daemonset"
|
|
532
|
+
? "the DaemonSet"
|
|
533
|
+
: target.kind === "statefulset"
|
|
534
|
+
? "the StatefulSet"
|
|
535
|
+
: "the ReplicaSet";
|
|
474
536
|
notes.push(`no pod matches ${selector} — ${maker} made none (check quota, node taints, and the selector)`);
|
|
475
537
|
}
|
|
476
538
|
|
|
@@ -728,6 +790,50 @@ function execStdin(cmd: string[], stdin: string): Promise<void> {
|
|
|
728
790
|
});
|
|
729
791
|
}
|
|
730
792
|
|
|
793
|
+
/** `execStdin`, keeping stdout. */
|
|
794
|
+
function execStdinOut(cmd: string[], stdin: string): Promise<string> {
|
|
795
|
+
return new Promise((resolve, reject) => {
|
|
796
|
+
const child = spawn(cmd[0]!, cmd.slice(1));
|
|
797
|
+
let out = "";
|
|
798
|
+
let err = "";
|
|
799
|
+
child.stdout.on("data", (d: Buffer) => (out += d.toString()));
|
|
800
|
+
child.stderr.on("data", (d: Buffer) => (err += d.toString()));
|
|
801
|
+
child.on("error", reject);
|
|
802
|
+
child.on("close", (code) =>
|
|
803
|
+
code === 0 ? resolve(out) : reject(new Error(`${cmd.join(" ")} exited (${code}): ${err.trim()}`)),
|
|
804
|
+
);
|
|
805
|
+
child.stdin.write(stdin);
|
|
806
|
+
child.stdin.end();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The pod ADR-0060's check dry-runs: one that states pod-level resources (KEP-2837). A server
|
|
812
|
+
* whose PodLevelResources gate is off drops `spec.resources` at admission without a word, and
|
|
813
|
+
* every Sandbox then runs Burstable — a User Container with no split unbounded, against "jr2
|
|
814
|
+
* reserves the whole Size". On by default from 1.34, below KUBE_FLOOR, so only an owner who turned
|
|
815
|
+
* it off trips this. Shaped for the `restricted` Pod Security level, so a namespace that enforces
|
|
816
|
+
* it does not refuse the probe for another reason. Never scheduled, never pulled.
|
|
817
|
+
*/
|
|
818
|
+
export function podResourcesProbe(namespace: string): object {
|
|
819
|
+
return {
|
|
820
|
+
apiVersion: "v1",
|
|
821
|
+
kind: "Pod",
|
|
822
|
+
metadata: { name: "jr2-pod-resources-probe", namespace },
|
|
823
|
+
spec: {
|
|
824
|
+
resources: { requests: { cpu: "10m", memory: "16Mi" }, limits: { cpu: "10m", memory: "16Mi" } },
|
|
825
|
+
securityContext: { runAsNonRoot: true, runAsUser: 65532, seccompProfile: { type: "RuntimeDefault" } },
|
|
826
|
+
containers: [
|
|
827
|
+
{
|
|
828
|
+
name: "probe",
|
|
829
|
+
image: "registry.k8s.io/pause:3.10",
|
|
830
|
+
securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
731
837
|
/** The real kube port, over `kubectl` subprocesses. */
|
|
732
838
|
export const kubectlKube: KubePort = {
|
|
733
839
|
async currentContext() {
|