Skip to content

Commit

Permalink
feat: Support ExecCredential for Kubeconfigs (#15)
Browse files Browse the repository at this point in the history
* feat: Support ExecCredential for Kubeconfigs

This is the 'exec:' block, commonly used for short-lived access tokens
in offerings such as Google Kubernetes Engine.

* Ooops this file got duplicated.

* Might as well implement one of the TODOs now

* Print installHint when exec plugin throws NotFound

* Run CI with --unstable

We use unstable APIs, and it seems better to get the unstable APIs
type-checked vs. letting them be ignored just to remove --unstable.

* Update docs
  • Loading branch information
danopia authored May 9, 2023
1 parent dd8cc8c commit 05ad69b
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 37 deletions.
8 changes: 3 additions & 5 deletions .github/workflows/deno-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/[email protected]` and run CI on Deno v1.22 thru v1.30.
Now skips interactive permission prompts for InCluster files.
Expand Down
185 changes: 179 additions & 6 deletions lib/kubeconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
if (this.user.username || this.user.password) {
const {username, password} = this.user;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand All @@ -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<string, unknown>;
'preferences'?: {
'colors'?: boolean;
'extensions'?: Array<NamedExtension>;
};
}
function isRawKubeConfig(data: any): data is RawKubeConfig {
return data && data.apiVersion === 'v1' && data.kind === 'Config';
Expand All @@ -234,13 +340,23 @@ export interface ContextConfig {
'cluster'?: string;
'user'?: string;
'namespace'?: string;

'extensions'?: Array<NamedExtension>;
}

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<NamedExtension>;
}

export interface UserConfig {
Expand All @@ -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<string, string[]>;

// external auth (--allow-run)
/** @deprecated Removed in Kubernetes 1.26, in favor of 'exec */
'auth-provider'?: {name: string, config: UserAuthProviderConfig};
'exec'?: UserExecConfig;

'extensions'?: Array<NamedExtension>;
}

/** @deprecated Removed in Kubernetes 1.26, in favor of `UserExecConfig` */
Expand All @@ -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
}
36 changes: 10 additions & 26 deletions transports/via-kubeconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -182,4 +166,4 @@ export class KubeConfigRestClient implements RestClient {
type HttpError = Error & {
httpCode?: number;
status?: JSONValue;
}
}

0 comments on commit 05ad69b

Please sign in to comment.