diff --git a/.github/workflows/deno-ci.yaml b/.github/workflows/deno-ci.yaml index 3f4fcf5..d8b455d 100644 --- a/.github/workflows/deno-ci.yaml +++ b/.github/workflows/deno-ci.yaml @@ -13,11 +13,9 @@ jobs: strategy: matrix: deno-version: - - v1.22 - - v1.24 - - v1.26 - v1.28 - v1.30 + - v1.32 - canary fail-fast: false # run each branch to completion @@ -40,7 +38,7 @@ jobs: restore-keys: deno-https/v1- - name: Check mod.ts - run: time deno check mod.ts + run: time deno check --unstable mod.ts - name: Check demo.ts - run: time deno check demo.ts + run: time deno check --unstable demo.ts diff --git a/README.md b/README.md index 90dbdf7..65c8230 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ Check out `lib/contract.ts` to see the type/API contract. ## Changelog +* `v0.5.1` on `2023-05-09`: + Run CI on Deno v1.26 thru v1.32. + Now supports 'exec' plugins in kubeconfigs to load temporary credentials. + This new feature requires Deno v1.31 or later (or Deno v1.28 with --unstable). + * `v0.5.0` on `2023-02-09`: Updated deps to `/std@0.177.0` and run CI on Deno v1.22 thru v1.30. Now skips interactive permission prompts for InCluster files. diff --git a/lib/kubeconfig.ts b/lib/kubeconfig.ts index f213a73..1cdd82b 100644 --- a/lib/kubeconfig.ts +++ b/lib/kubeconfig.ts @@ -124,11 +124,53 @@ export class KubeConfigContext { public readonly cluster: ClusterConfig, public readonly user: UserConfig, ) {} + private execCred: ExecCredentialStatus | null = null; get defaultNamespace() { return this.context.namespace ?? null; } + async getServerTls() { + let serverCert = atob(this.cluster["certificate-authority-data"] ?? '') || null; + if (!serverCert && this.cluster["certificate-authority"]) { + serverCert = await Deno.readTextFile(this.cluster["certificate-authority"]); + } + + if (serverCert) { + return { serverCert }; + } + return null; + } + + async getClientTls() { + let userCert = atob(this.user["client-certificate-data"] ?? '') || null; + if (!userCert && this.user["client-certificate"]) { + userCert = await Deno.readTextFile(this.user["client-certificate"]); + } + + let userKey = atob(this.user["client-key-data"] ?? '') || null; + if (!userKey && this.user["client-key"]) { + userKey = await Deno.readTextFile(this.user["client-key"]); + } + + if (!userKey && !userCert && this.user.exec) { + const cred = await this.getExecCredential(); + if (cred.clientKeyData) { + return { + userKey: cred.clientKeyData, + userCert: cred.clientCertificateData, + }; + } + } + + if (userKey && userCert) { + return { userKey, userCert }; + } + if (userKey || userCert) throw new Error( + `Within the KubeConfig, client key and certificate must both be provided if either is provided.`); + return null; + } + async getAuthHeader(): Promise { if (this.user.username || this.user.password) { const {username, password} = this.user; @@ -160,12 +202,73 @@ export class KubeConfigContext { } } else if (this.user['exec']) { - throw new Error( - `TODO: kubeconfig "exec:" blocks aren't supported yet`); + const cred = await this.getExecCredential(); + if (cred.token) { + return `Bearer ${cred.token}`; + } + return null; } else return null; } + private async getExecCredential() { + if (this.execCred && ( + !this.execCred.expirationTimestamp || + new Date(this.execCred.expirationTimestamp) > new Date())) { + return this.execCred; + } + + const execConfig = this.user['exec']; + if (!execConfig) throw new Error(`BUG: execConfig disappeared`); + + const isTTY = Deno.isatty(Deno.stdin.rid); + const stdinPolicy = execConfig.interactiveMode ?? 'IfAvailable'; + if (stdinPolicy == 'Always' && !isTTY) { + throw new Error(`KubeConfig exec plugin wants a TTY, but stdin is not a TTY`); + } + + const req: ExecCredential = { + 'apiVersion': execConfig.apiVersion, + 'kind': 'ExecCredential', + 'spec': { + 'interactive': isTTY && stdinPolicy != 'Never', + }, + }; + if (execConfig.provideClusterInfo) { + const serverTls = await this.getServerTls(); + req.spec.cluster = { + 'config': this.cluster.extensions?.find(x => x.name == ExecAuthExtensionName)?.extension, + 'server': this.cluster.server, + 'certificate-authority-data': serverTls ? btoa(serverTls.serverCert) : undefined, + }; + } + + const proc = new Deno.Command(execConfig.command, { + args: execConfig.args, + stdin: req.spec.interactive ? 'inherit' : 'null', + stdout: 'piped', + stderr: 'inherit', + env: { + ...Object.fromEntries(execConfig.env?.map(x => [x.name, x.value]) ?? []), + KUBERNETES_EXEC_INFO: JSON.stringify(req), + }, + }); + try { + const output = await proc.output(); + if (!output.success) throw new Error( + `Exec plugin ${execConfig.command} exited with code ${output.code}`); + const stdout = JSON.parse(new TextDecoder().decode(output.stdout)); + if (!isExecCredential(stdout) || !stdout.status) throw new Error( + `Exec plugin ${execConfig.command} did not output an ExecCredential`); + + this.execCred = stdout.status; + return stdout.status; + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new Error(execConfig.installHint + ?? `Exec plugin ${execConfig.command} not found (${err}). Maybe you need to install it.`); + throw err; + } + } } export function mergeKubeConfigs(configs: (RawKubeConfig | KubeConfig)[]) : RawKubeConfig { @@ -211,6 +314,8 @@ export function mergeKubeConfigs(configs: (RawKubeConfig | KubeConfig)[]) : RawK } +// TODO: can't we codegen this API from kubernetes definitions? +// there's api docs here https://kubernetes.io/docs/reference/config-api/kubeconfig.v1/ export interface RawKubeConfig { 'apiVersion': "v1"; @@ -222,9 +327,10 @@ export interface RawKubeConfig { 'current-context'?: string; - // this actually has a sort of schema, used for CLI stuff - // we just ignore it though - 'preferences'?: Record; + 'preferences'?: { + 'colors'?: boolean; + 'extensions'?: Array; + }; } function isRawKubeConfig(data: any): data is RawKubeConfig { return data && data.apiVersion === 'v1' && data.kind === 'Config'; @@ -234,13 +340,23 @@ export interface ContextConfig { 'cluster'?: string; 'user'?: string; 'namespace'?: string; + + 'extensions'?: Array; } export interface ClusterConfig { 'server'?: string; // URL + // // TODO: determine what we can/should/will do about these networking things: + // 'tls-server-name'?: string; + // 'insecure-skip-tls-verify'?: boolean; + // 'proxy-url'?: string; + // 'disable-compression'?: boolean; + 'certificate-authority'?: string; // path 'certificate-authority-data'?: string; // base64 + + 'extensions'?: Array; } export interface UserConfig { @@ -257,10 +373,18 @@ export interface UserConfig { 'client-certificate'?: string; // path 'client-certificate-data'?: string; // base64 + // // TODO: impersonation + // 'as'?: string; + // 'as-uid'?: string; + // 'as-groups'?: string[]; + // 'as-user-extra'?: Record; + // external auth (--allow-run) /** @deprecated Removed in Kubernetes 1.26, in favor of 'exec */ 'auth-provider'?: {name: string, config: UserAuthProviderConfig}; 'exec'?: UserExecConfig; + + 'extensions'?: Array; } /** @deprecated Removed in Kubernetes 1.26, in favor of `UserExecConfig` */ @@ -281,7 +405,56 @@ export interface UserExecConfig { | "client.authentication.k8s.io/v1"; 'command': string; 'args'?: string[]; - 'env'?: { name: string; value: string; }[]; + 'env'?: Array<{ + 'name': string; + 'value': string; + }>; 'installHint'?: string; 'provideClusterInfo'?: boolean; + 'interactiveMode'?: 'Never' | 'IfAvailable' | 'Always'; +} + +export interface NamedExtension { + 'name': string; + 'extension'?: unknown; +} +export const ExecAuthExtensionName = "client.authentication.k8s.io/exec"; + + +// https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/ + +interface ExecCredential { + 'apiVersion': UserExecConfig['apiVersion']; + 'kind': 'ExecCredential'; + 'spec': ExecCredentialSpec; + 'status'?: ExecCredentialStatus; +} +function isExecCredential(data: any): data is ExecCredential { + return data + && (data.apiVersion === 'client.authentication.k8s.io/v1alpha1' + || data.apiVersion === 'client.authentication.k8s.io/v1beta1' + || data.apiVersion === 'client.authentication.k8s.io/v1') + && data.kind === 'ExecCredential'; +} + +interface ExecCredentialSpec { + 'cluster'?: Cluster; + 'interactive'?: boolean; +} + +interface ExecCredentialStatus { + 'expirationTimestamp': string; + 'token': string; + 'clientCertificateData': string; + 'clientKeyData': string; +} + +interface Cluster { + 'server'?: string; + 'tls-server-name'?: string; + 'insecure-skip-tls-verify'?: boolean; + 'certificate-authority-data'?: string; + 'proxy-url'?: string; + 'disable-compression'?: boolean; + 'config'?: unknown; // comes from the "client.authentication.k8s.io/exec" extension } diff --git a/transports/via-kubeconfig.ts b/transports/via-kubeconfig.ts index 65302df..c4410ff 100644 --- a/transports/via-kubeconfig.ts +++ b/transports/via-kubeconfig.ts @@ -79,34 +79,18 @@ export class KubeConfigRestClient implements RestClient { `Deno cannot access bare IP addresses over HTTPS. See deno#7660.`); } - let userCert = atob(ctx.user["client-certificate-data"] ?? '') || null; - if (!userCert && ctx.user["client-certificate"]) { - userCert = await Deno.readTextFile(ctx.user["client-certificate"]); - } - - let userKey = atob(ctx.user["client-key-data"] ?? '') || null; - if (!userKey && ctx.user["client-key"]) { - userKey = await Deno.readTextFile(ctx.user["client-key"]); - } - - if ((userKey && !userCert) || (!userKey && userCert)) throw new Error( - `Within the KubeConfig, client key and certificate must both be provided if either is provided.`); + const serverTls = await ctx.getServerTls(); + const tlsAuth = await ctx.getClientTls(); - let serverCert = atob(ctx.cluster["certificate-authority-data"] ?? '') || null; - if (!serverCert && ctx.cluster["certificate-authority"]) { - serverCert = await Deno.readTextFile(ctx.cluster["certificate-authority"]); - } - - // do a little dance to allow running with or without --unstable let httpClient: unknown; - if (serverCert || userKey) { - if ('createHttpClient' in Deno) { - httpClient = (Deno as any).createHttpClient({ - caCerts: serverCert ? [serverCert] : [], - certChain: userCert, - privateKey: userKey, + if (serverTls || tlsAuth) { + if (Deno.createHttpClient) { + httpClient = Deno.createHttpClient({ + caCerts: serverTls ? [serverTls.serverCert] : [], + certChain: tlsAuth?.userCert, + privateKey: tlsAuth?.userKey, }); - } else if (userKey) { + } else if (tlsAuth) { console.error('WARN: cannot use certificate-based auth without --unstable'); } else if (isVerbose) { console.error('WARN: cannot have Deno trust the server CA without --unstable'); @@ -182,4 +166,4 @@ export class KubeConfigRestClient implements RestClient { type HttpError = Error & { httpCode?: number; status?: JSONValue; -} \ No newline at end of file +}