@knowttl/kubernetes-axi 0.0.0-stage → 0.1.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 +306 -2
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/axi-credential.schema.json +31 -0
- package/bin/kubernetes-axi.js +10 -0
- package/command-metadata.json +36947 -0
- package/dist/byte-process.d.ts +9 -0
- package/dist/byte-process.js +56 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +68 -0
- package/dist/credentials.d.ts +28 -0
- package/dist/credentials.js +113 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +11 -0
- package/dist/exec-shim.d.ts +1 -0
- package/dist/exec-shim.js +16 -0
- package/dist/grammar.d.ts +50 -0
- package/dist/grammar.js +248 -0
- package/dist/hosted-session.d.ts +1 -0
- package/dist/hosted-session.js +201 -0
- package/dist/metadata-pin.d.ts +3 -0
- package/dist/metadata-pin.js +4 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +526 -0
- package/dist/native.d.ts +26 -0
- package/dist/native.js +143 -0
- package/dist/private-files.d.ts +9 -0
- package/dist/private-files.js +137 -0
- package/dist/protobuf-schema.d.ts +2 -0
- package/dist/protobuf-schema.js +1 -0
- package/dist/protobuf.d.ts +1 -0
- package/dist/protobuf.js +67 -0
- package/dist/redaction.d.ts +10 -0
- package/dist/redaction.js +50 -0
- package/dist/remote-session.d.ts +22 -0
- package/dist/remote-session.js +90 -0
- package/dist/route-policy.d.ts +1 -0
- package/dist/route-policy.js +19 -0
- package/dist/runner.d.ts +11 -0
- package/dist/runner.js +367 -0
- package/dist/workspace-tar.d.ts +6 -0
- package/dist/workspace-tar.js +109 -0
- package/docs/hooks.md +35 -0
- package/kubectl-pins.json +18 -0
- package/package.json +21 -4
- package/skills/kubernetes-axi/SKILL.md +32 -0
- package/third-party/kubectl-LICENSE.txt +202 -0
- package/vendor/kubectl-linux-amd64 +4 -0
- package/vendor/kubectl-windows-amd64.exe +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function decodeKubernetes(bytes: Buffer): unknown;
|
package/dist/protobuf.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import protobuf from 'protobufjs';
|
|
2
|
+
import { protobufSchema } from './protobuf-schema.js';
|
|
3
|
+
import { Failure } from './errors.js';
|
|
4
|
+
const root = protobuf.Root.fromJSON(protobufSchema).resolveAll();
|
|
5
|
+
const envelopeType = root.lookupType('k8s.io.apimachinery.pkg.runtime.Unknown');
|
|
6
|
+
const groups = { v1: 'core.v1', 'apps/v1': 'apps.v1', 'certificates.k8s.io/v1': 'certificates.v1', 'authorization.k8s.io/v1': 'authorization.v1',
|
|
7
|
+
'authentication.k8s.io/v1': 'authentication.v1', 'autoscaling/v1': 'autoscaling.v1', 'autoscaling/v2': 'autoscaling.v2', 'policy/v1': 'policy.v1',
|
|
8
|
+
'rbac.authorization.k8s.io/v1': 'rbac.v1', 'batch/v1': 'batch.v1', 'networking.k8s.io/v1': 'networking.v1', 'scheduling.k8s.io/v1': 'scheduling.v1' };
|
|
9
|
+
function normalize(type, message) {
|
|
10
|
+
const value = type.toObject(message, { longs: Number, bytes: String });
|
|
11
|
+
const name = type.fullName.split('.').at(-1);
|
|
12
|
+
if (name === 'Quantity' || name === 'QuantityValue')
|
|
13
|
+
return value.string ?? '0';
|
|
14
|
+
if (name === 'IntOrString')
|
|
15
|
+
return Number(value.type ?? 0) === 0 ? value.intVal ?? 0 : value.strVal ?? '';
|
|
16
|
+
if (name === 'Time' || name === 'MicroTime') {
|
|
17
|
+
if (!Object.hasOwn(value, 'seconds'))
|
|
18
|
+
return null;
|
|
19
|
+
const seconds = Number(value.seconds);
|
|
20
|
+
const nanos = Number(value.nanos ?? 0).toString().padStart(9, '0').replace(/0+$/, '');
|
|
21
|
+
return new Date(seconds * 1000).toISOString().replace('.000Z', `${nanos ? `.${nanos}` : ''}Z`);
|
|
22
|
+
}
|
|
23
|
+
if (name === 'RawExtension' || name === 'FieldsV1') {
|
|
24
|
+
const raw = name === 'FieldsV1' ? value.Raw : value.raw;
|
|
25
|
+
return raw ? JSON.parse(Buffer.from(String(raw), 'base64').toString()) : {};
|
|
26
|
+
}
|
|
27
|
+
if (name === 'ExtraValue')
|
|
28
|
+
return value.items ?? [];
|
|
29
|
+
const result = {};
|
|
30
|
+
for (const field of type.fieldsArray) {
|
|
31
|
+
if (!Object.hasOwn(value, field.name))
|
|
32
|
+
continue;
|
|
33
|
+
const original = message[field.name];
|
|
34
|
+
if (field.resolvedType instanceof protobuf.Type) {
|
|
35
|
+
const nested = field.resolvedType;
|
|
36
|
+
value[field.name] = field.map ? Object.fromEntries(Object.entries(original).map(([key, item]) => [key, normalize(nested, item)])) :
|
|
37
|
+
field.repeated ? original.map(item => normalize(nested, item)) : normalize(nested, original);
|
|
38
|
+
}
|
|
39
|
+
else if (typeof value[field.name] === 'number' && !Number.isSafeInteger(value[field.name]))
|
|
40
|
+
throw new Failure('UPSTREAM_INVALID', 'The protobuf body contains an integer outside the exact JSON range.');
|
|
41
|
+
const jsonName = field.getOption('kubernetesJsonName');
|
|
42
|
+
if (jsonName === undefined)
|
|
43
|
+
throw new Failure('PROTOBUF_UNSUPPORTED', 'The protobuf field has no pinned JSON contract.');
|
|
44
|
+
if (jsonName === '')
|
|
45
|
+
Object.assign(result, value[field.name]);
|
|
46
|
+
else if (jsonName !== '-')
|
|
47
|
+
result[jsonName] = value[field.name];
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export function decodeKubernetes(bytes) {
|
|
52
|
+
if (!bytes.subarray(0, 4).equals(Buffer.from('k8s\0')))
|
|
53
|
+
throw new Failure('PROTOBUF_INVALID', 'Expected the Kubernetes protobuf envelope.');
|
|
54
|
+
const envelope = envelopeType.decode(bytes.subarray(4));
|
|
55
|
+
const version = envelope.typeMeta?.apiVersion ?? '';
|
|
56
|
+
const kind = envelope.typeMeta?.kind ?? '';
|
|
57
|
+
if (!groups[version] || !/^[A-Z][A-Za-z0-9]+$/.test(kind) || !envelope.raw || envelope.contentEncoding || envelope.contentType && envelope.contentType !== 'application/vnd.kubernetes.protobuf')
|
|
58
|
+
throw new Failure('PROTOBUF_UNSUPPORTED', 'The protobuf type or encoding is outside the pinned schemas; no request was forwarded.');
|
|
59
|
+
let type;
|
|
60
|
+
try {
|
|
61
|
+
type = root.lookupType(`k8s.io.api.${groups[version]}.${kind}`);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Failure('PROTOBUF_UNSUPPORTED', 'Unknown Kubernetes protobuf type; no request was forwarded.');
|
|
65
|
+
}
|
|
66
|
+
return { ...normalize(type, type.decode(envelope.raw)), apiVersion: version, kind };
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class Redactor {
|
|
2
|
+
forms = new Set();
|
|
3
|
+
register(...secrets) {
|
|
4
|
+
for (const secret of secrets)
|
|
5
|
+
if (secret) {
|
|
6
|
+
const json = JSON.stringify(secret).slice(1, -1);
|
|
7
|
+
const goJson = json.replace(/[<>&\u2028\u2029]/g, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
8
|
+
const formEncoded = new URLSearchParams({ value: secret }).toString().slice('value='.length);
|
|
9
|
+
for (const form of [secret, json, goJson, encodeURIComponent(secret), formEncoded, Buffer.from(secret).toString('base64')]) {
|
|
10
|
+
this.forms.add(form);
|
|
11
|
+
this.forms.add(JSON.stringify(form).slice(1, -1));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
text(value) {
|
|
16
|
+
for (const form of [...this.forms].sort((a, b) => b.length - a.length))
|
|
17
|
+
value = value.split(form).join('[REDACTED]');
|
|
18
|
+
return value.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, '[REDACTED]')
|
|
19
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
20
|
+
.replace(/("(?:token|password|clientSecret|privateKey|client-key-data|authorization)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[REDACTED]"')
|
|
21
|
+
.replace(/^(\s*(?:token|password|clientSecret|privateKey|client-key-data|authorization)\s*:\s*).+$/gmi, '$1[REDACTED]')
|
|
22
|
+
.replace(/(?:AccountKey=|sig=)[^&\s]+/gi, '[REDACTED]');
|
|
23
|
+
}
|
|
24
|
+
value(data) {
|
|
25
|
+
if (typeof data === 'string')
|
|
26
|
+
return this.text(data);
|
|
27
|
+
if (Array.isArray(data))
|
|
28
|
+
return data.map(value => this.value(value));
|
|
29
|
+
if (data !== null && typeof data === 'object')
|
|
30
|
+
return Object.fromEntries(Object.entries(data).map(([key, value]) => [this.text(key), /^(token|password|clientSecret|privateKey|client-key-data|authorization)$/i.test(key) ? '[REDACTED]' : this.value(value)]));
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
byteStream() {
|
|
34
|
+
const patterns = [...this.forms].map(form => Buffer.from(form));
|
|
35
|
+
const hold = Math.max(0, ...patterns.map(pattern => pattern.length - 1));
|
|
36
|
+
let pending = Buffer.alloc(0);
|
|
37
|
+
return {
|
|
38
|
+
push: bytes => {
|
|
39
|
+
pending = Buffer.concat([pending, bytes]);
|
|
40
|
+
if (patterns.some(pattern => pending.includes(pattern)))
|
|
41
|
+
throw new Error('protected binary output');
|
|
42
|
+
const count = Math.max(0, pending.length - hold);
|
|
43
|
+
const safe = Buffer.from(pending.subarray(0, count));
|
|
44
|
+
pending = Buffer.from(pending.subarray(count));
|
|
45
|
+
return safe;
|
|
46
|
+
},
|
|
47
|
+
end: () => { const safe = pending; pending = Buffer.alloc(0); return safe; },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RemoteConfig {
|
|
2
|
+
'current-context'?: string;
|
|
3
|
+
clusters: {
|
|
4
|
+
cluster: {
|
|
5
|
+
server: string;
|
|
6
|
+
'certificate-authority-data': string;
|
|
7
|
+
'tls-server-name'?: string;
|
|
8
|
+
};
|
|
9
|
+
}[];
|
|
10
|
+
users: {
|
|
11
|
+
user: {
|
|
12
|
+
token?: string;
|
|
13
|
+
'client-certificate-data'?: string;
|
|
14
|
+
'client-key-data'?: string;
|
|
15
|
+
exec?: object;
|
|
16
|
+
};
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
export declare function remoteSession(config: RemoteConfig, namespace: string, target: string, route: string, query: string, signal: AbortSignal): Promise<{
|
|
20
|
+
config: object;
|
|
21
|
+
close: () => Promise<void>;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { request } from 'node:https';
|
|
4
|
+
import { Failure } from './errors.js';
|
|
5
|
+
// Only the frozen pod and command can be reached, with credentials held by the parent.
|
|
6
|
+
export async function remoteSession(config, namespace, target, route, query, signal) {
|
|
7
|
+
const cluster = config.clusters[0].cluster;
|
|
8
|
+
const user = config.users[0].user;
|
|
9
|
+
if (user.exec)
|
|
10
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', 'Helper-backed execution requires a reviewed containment boundary.');
|
|
11
|
+
const capability = randomBytes(32).toString('hex');
|
|
12
|
+
const sockets = new Set();
|
|
13
|
+
const server = createServer();
|
|
14
|
+
server.on('upgrade', (incoming, downstream, head) => {
|
|
15
|
+
const url = new URL(incoming.url, 'http://local');
|
|
16
|
+
const expected = new URLSearchParams(query);
|
|
17
|
+
const actual = new URLSearchParams(url.search);
|
|
18
|
+
expected.sort();
|
|
19
|
+
actual.sort();
|
|
20
|
+
if (!['GET', 'POST'].includes(incoming.method) || url.pathname !== `/${capability}${route}` || expected.toString() !== actual.toString()) {
|
|
21
|
+
downstream.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
sockets.add(downstream);
|
|
25
|
+
downstream.once('close', () => sockets.delete(downstream));
|
|
26
|
+
const call = request(cluster.server.replace(/\/$/, '') + route + url.search, {
|
|
27
|
+
method: incoming.method, signal, ca: Buffer.from(cluster['certificate-authority-data'], 'base64'),
|
|
28
|
+
...(cluster['tls-server-name'] ? { servername: cluster['tls-server-name'] } : {}),
|
|
29
|
+
...(user['client-certificate-data'] ? { cert: Buffer.from(user['client-certificate-data'], 'base64') } : {}),
|
|
30
|
+
...(user['client-key-data'] ? { key: Buffer.from(user['client-key-data'], 'base64') } : {}),
|
|
31
|
+
headers: { ...incoming.headers, host: new URL(cluster.server).host, ...(user.token ? { authorization: `Bearer ${user.token}` } : {}) },
|
|
32
|
+
});
|
|
33
|
+
call.on('upgrade', (reply, upstream, upstreamHead) => {
|
|
34
|
+
sockets.add(upstream);
|
|
35
|
+
upstream.once('close', () => sockets.delete(upstream));
|
|
36
|
+
upstream.once('error', () => downstream.destroy());
|
|
37
|
+
downstream.once('close', () => upstream.destroy());
|
|
38
|
+
downstream.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(reply.headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`).join('\r\n')}\r\n\r\n`);
|
|
39
|
+
if (upstreamHead.length)
|
|
40
|
+
downstream.write(upstreamHead);
|
|
41
|
+
if (head.length)
|
|
42
|
+
upstream.write(head);
|
|
43
|
+
downstream.pipe(upstream).pipe(downstream);
|
|
44
|
+
});
|
|
45
|
+
call.on('response', reply => {
|
|
46
|
+
// A rejected WebSocket handshake must reach kubectl so it can try SPDY.
|
|
47
|
+
downstream.end(`HTTP/1.1 ${reply.statusCode} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
48
|
+
reply.resume();
|
|
49
|
+
});
|
|
50
|
+
call.on('error', () => downstream.destroy());
|
|
51
|
+
downstream.once('close', () => call.destroy());
|
|
52
|
+
call.setTimeout(5000, () => call.destroy());
|
|
53
|
+
call.end();
|
|
54
|
+
});
|
|
55
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
56
|
+
const address = server.address();
|
|
57
|
+
if (!address || typeof address === 'string')
|
|
58
|
+
throw new Failure('CONFIG_INVALID', 'Cannot open the bounded execution channel.');
|
|
59
|
+
const close = async () => {
|
|
60
|
+
for (const socket of sockets)
|
|
61
|
+
socket.destroy();
|
|
62
|
+
server.closeAllConnections();
|
|
63
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
64
|
+
};
|
|
65
|
+
signal.addEventListener('abort', () => { for (const socket of sockets)
|
|
66
|
+
socket.destroy(); }, { once: true });
|
|
67
|
+
// Native kubectl still reads its target pod before opening the stream.
|
|
68
|
+
server.removeAllListeners('request');
|
|
69
|
+
server.on('request', (incoming, response) => {
|
|
70
|
+
const path = incoming.url?.startsWith(`/${capability}/`) ? incoming.url.slice(capability.length + 1) : '';
|
|
71
|
+
const pathname = path.split('?')[0];
|
|
72
|
+
const discovery = /^\/(?:api(?:\/v[0-9]+)?|apis(?:\/[a-z0-9.-]+\/v[0-9]+(?:(?:alpha|beta)[0-9]+)?)?)$/.test(pathname);
|
|
73
|
+
if (incoming.method !== 'GET' || !(pathname === target || discovery)) {
|
|
74
|
+
response.writeHead(403).end();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const call = request(cluster.server.replace(/\/$/, '') + path, {
|
|
78
|
+
ca: Buffer.from(cluster['certificate-authority-data'], 'base64'), signal,
|
|
79
|
+
...(cluster['tls-server-name'] ? { servername: cluster['tls-server-name'] } : {}),
|
|
80
|
+
...(user['client-certificate-data'] ? { cert: Buffer.from(user['client-certificate-data'], 'base64') } : {}),
|
|
81
|
+
...(user['client-key-data'] ? { key: Buffer.from(user['client-key-data'], 'base64') } : {}),
|
|
82
|
+
headers: { accept: 'application/json', ...(user.token ? { authorization: `Bearer ${user.token}` } : {}) },
|
|
83
|
+
}, reply => { response.writeHead(reply.statusCode ?? 502, { 'content-type': 'application/json' }); reply.pipe(response); });
|
|
84
|
+
call.on('error', () => response.destroy());
|
|
85
|
+
call.setTimeout(5000, () => call.destroy());
|
|
86
|
+
call.end();
|
|
87
|
+
});
|
|
88
|
+
return { config: { apiVersion: 'v1', kind: 'Config', clusters: [{ name: 'selected', cluster: { server: `http://127.0.0.1:${address.port}/${capability}` } }],
|
|
89
|
+
users: [{ name: 'selected', user: {} }], contexts: [{ name: 'selected', context: { cluster: 'selected', user: 'selected', namespace } }], 'current-context': 'selected' }, close };
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function routeEffect(method: string, path: string): 'read' | 'clusterWrite' | 'execution' | 'connection' | 'credentials';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Failure } from './errors.js';
|
|
2
|
+
export function routeEffect(method, path) {
|
|
3
|
+
if (!path.startsWith('/') || /\.\.|\/\/|%2f|%2e|%5c|\\/i.test(path))
|
|
4
|
+
throw new Failure('EFFECT_UNCLASSIFIED', 'Use an unambiguous absolute Kubernetes API route.');
|
|
5
|
+
const url = new URL(path, 'http://local');
|
|
6
|
+
if (/\/(?:proxy|portforward)(?:\/|$)/.test(url.pathname))
|
|
7
|
+
return 'connection';
|
|
8
|
+
if (/\/(?:exec|attach)(?:\/|$)/.test(url.pathname))
|
|
9
|
+
return 'execution';
|
|
10
|
+
if (/\/token(?:\/|$)/.test(url.pathname))
|
|
11
|
+
return 'credentials';
|
|
12
|
+
if (['watch', 'follow'].some(name => url.searchParams.has(name) && !/^(false|0)$/i.test(url.searchParams.get(name))) || /^\/api(?:s)?\/[^?]*\/watch\//.test(url.pathname))
|
|
13
|
+
return 'connection';
|
|
14
|
+
if (!['GET', 'HEAD'].includes(method))
|
|
15
|
+
return 'clusterWrite';
|
|
16
|
+
if (/^\/(?:version|api|apis)$/.test(url.pathname) || /^\/(?:api\/v[0-9]+|apis\/[a-z0-9.-]+\/v[0-9]+(?:(?:alpha|beta)[0-9]+)?)(?:\/(?:namespaces\/[a-z0-9.-]+\/)?[a-z0-9.-]+(?:\/[a-z0-9.-]+(?:\/(?:log|status|scale))?)?)?$/.test(url.pathname))
|
|
17
|
+
return 'read';
|
|
18
|
+
throw new Failure('EFFECT_UNCLASSIFIED', 'The raw route has no reviewed effect classification.');
|
|
19
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Identity } from './credentials.js';
|
|
2
|
+
import type { Invocation } from './grammar.js';
|
|
3
|
+
import type { Redactor } from './redaction.js';
|
|
4
|
+
import { type ByteExecution } from './byte-process.js';
|
|
5
|
+
export interface NativeOutput {
|
|
6
|
+
native: true;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function readCluster(identity: Identity | undefined, invocation: Invocation, redactor: Redactor, byteExecution?: ByteExecution): Promise<object | NativeOutput>;
|