Skip to content

Commit 534be09

Browse files
committed
Add header command setting
This will be called before requests and added to the SSH config.
1 parent 423360c commit 534be09

File tree

8 files changed

+180
-4
lines changed

8 files changed

+180
-4
lines changed

flake.nix

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
description = "vscode-coder";
3+
4+
inputs.flake-utils.url = "github:numtide/flake-utils";
5+
6+
outputs = { self, nixpkgs, flake-utils }:
7+
flake-utils.lib.eachDefaultSystem
8+
(system:
9+
let pkgs = nixpkgs.legacyPackages.${system};
10+
nodejs = pkgs.nodejs-18_x;
11+
yarn' = pkgs.yarn.override { inherit nodejs; };
12+
in {
13+
devShells.default = pkgs.mkShell {
14+
nativeBuildInputs = with pkgs; [
15+
nodejs yarn' python3 pkg-config git rsync jq moreutils quilt bats openssl
16+
];
17+
buildInputs = with pkgs; (lib.optionals (!stdenv.isDarwin) [ libsecret ]
18+
++ (with xorg; [ libX11 libxkbfile ])
19+
++ lib.optionals stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [
20+
AppKit Cocoa CoreServices Security xcbuild
21+
]));
22+
};
23+
}
24+
);
25+
}

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
"markdownDescription": "If true, the extension will not verify the authenticity of the remote host. This is useful for self-signed certificates.",
4848
"type": "boolean",
4949
"default": false
50+
},
51+
"coder.headerCommand": {
52+
"markdownDescription": "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line. The following environment variables will be available to the process: `CODER_URL`.",
53+
"type": "string",
54+
"default": ""
5055
}
5156
}
5257
},

src/commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ export class Commands {
7070
severity: vscode.InputBoxValidationSeverity.Error,
7171
}
7272
}
73+
// This could be something like the header command erroring or an
74+
// invalid session token.
7375
return {
74-
message: "Invalid session token! (" + message + ")",
76+
message: "Failed to authenticate: " + message,
7577
severity: vscode.InputBoxValidationSeverity.Error,
7678
}
7779
})

src/extension.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
6666
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
6767
await storage.init()
6868

69+
// Add headers from the header command.
70+
axios.interceptors.request.use(async (config) => {
71+
return {
72+
...config,
73+
headers: {
74+
...(await storage.getHeaders()),
75+
...creds.headers,
76+
},
77+
}
78+
})
79+
6980
const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage)
7081
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage)
7182

@@ -81,8 +92,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
8192
}
8293
}
8394
})
84-
.catch(() => {
85-
// Not authenticated!
95+
.catch((error) => {
96+
// This should be a failure to make the request, like the header command
97+
// errored.
98+
vscodeProposed.window.showErrorMessage("Failed to check user authentication: " + error.message)
8699
})
87100
.finally(() => {
88101
vscode.commands.executeCommand("setContext", "coder.loaded", true)

src/headers.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as os from "os"
2+
import { it, expect } from "vitest"
3+
import { getHeaders } from "./headers"
4+
5+
const logger = {
6+
writeToCoderOutputChannel() {
7+
// no-op
8+
},
9+
}
10+
11+
it("should return undefined", async () => {
12+
await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual({})
13+
await expect(getHeaders("foo", undefined, logger)).resolves.toStrictEqual({})
14+
await expect(getHeaders(undefined, "foo", logger)).resolves.toStrictEqual({})
15+
})
16+
17+
it("should return headers", async () => {
18+
await expect(getHeaders("foo", "printf foo=bar'\n'baz=qux", logger)).resolves.toStrictEqual({
19+
foo: "bar",
20+
baz: "qux",
21+
})
22+
await expect(getHeaders("foo", "printf foo=bar'\r\n'baz=qux", logger)).resolves.toStrictEqual({
23+
foo: "bar",
24+
baz: "qux",
25+
})
26+
await expect(getHeaders("foo", "printf foo=bar'\r\n'", logger)).resolves.toStrictEqual({ foo: "bar" })
27+
await expect(getHeaders("foo", "printf foo=bar", logger)).resolves.toStrictEqual({ foo: "bar" })
28+
await expect(getHeaders("foo", "printf foo=bar=", logger)).resolves.toStrictEqual({ foo: "bar=" })
29+
await expect(getHeaders("foo", "printf foo=bar=baz", logger)).resolves.toStrictEqual({ foo: "bar=baz" })
30+
await expect(getHeaders("foo", "printf foo=", logger)).resolves.toStrictEqual({ foo: "" })
31+
})
32+
33+
it("should error on malformed or empty lines", async () => {
34+
await expect(getHeaders("foo", "printf foo=bar'\r\n\r\n'", logger)).rejects.toMatch(/Malformed/)
35+
await expect(getHeaders("foo", "printf '\r\n'foo=bar", logger)).rejects.toMatch(/Malformed/)
36+
await expect(getHeaders("foo", "printf =foo", logger)).rejects.toMatch(/Malformed/)
37+
await expect(getHeaders("foo", "printf foo", logger)).rejects.toMatch(/Malformed/)
38+
await expect(getHeaders("foo", "printf ''", logger)).rejects.toMatch(/Malformed/)
39+
})
40+
41+
it("should have access to environment variables", async () => {
42+
const coderUrl = "dev.coder.com"
43+
await expect(
44+
getHeaders(
45+
coderUrl,
46+
os.platform() === "win32" ? "printf url=%CODER_URL" : "printf url=$CODER_URL",
47+
logger,
48+
),
49+
).resolves.toStrictEqual({ url: coderUrl })
50+
})
51+
52+
it("should error on non-zero exit", async () => {
53+
await expect(getHeaders("foo", "exit 10", logger)).rejects.toMatch(
54+
/exited unexpectedly with code 10/,
55+
)
56+
})

src/headers.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as cp from "child_process"
2+
import * as util from "util"
3+
4+
export interface Logger {
5+
writeToCoderOutputChannel(message: string): void
6+
}
7+
8+
interface ExecException {
9+
code?: number
10+
stderr?: string
11+
stdout?: string
12+
}
13+
14+
function isExecException(err: unknown): err is ExecException {
15+
return typeof (err as ExecException).code !== "undefined"
16+
}
17+
18+
// TODO: getHeaders might make more sense to directly implement on Storage
19+
// but it is difficult to test Storage right now since we use vitest instead of
20+
// the standard extension testing framework which would give us access to vscode
21+
// APIs. We should revert the testing framework then consider moving this.
22+
23+
// getHeaders executes the header command and parses the headers from stdout.
24+
// Both stdout and stderr are logged on error but stderr is otherwise ignored.
25+
// Throws an error if the process exits with non-zero or the JSON is invalid.
26+
// Returns undefined if there is no header command set. No effort is made to
27+
// validate the JSON other than making sure it can be parsed.
28+
export async function getHeaders(
29+
url: string | undefined,
30+
command: string | undefined,
31+
logger: Logger,
32+
): Promise<Record<string, string>> {
33+
const headers: Record<string, string> = {}
34+
if (typeof url === "string" && url.trim().length > 0 && typeof command === "string" && command.trim().length > 0) {
35+
let result: {stdout: string, stderr: string}
36+
try {
37+
result = await util.promisify(cp.exec)(command, {
38+
env: {
39+
...process.env,
40+
CODER_URL: url,
41+
},
42+
})
43+
} catch (error) {
44+
if (isExecException(error)) {
45+
logger.writeToCoderOutputChannel(`Header command exited unexpectedly with code ${error.code}`)
46+
logger.writeToCoderOutputChannel(`stdout: ${error.stdout}`)
47+
logger.writeToCoderOutputChannel(`stderr: ${error.stderr}`)
48+
throw new Error(`Header command exited unexpectedly with code ${error.code}`)
49+
}
50+
throw new Error(`Header command exited unexpectedly: ${error}`)
51+
}
52+
// This should imitate or be a subset of the Coder CLI's behavior.
53+
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/)
54+
for (let i = 0; i < lines.length; ++i) {
55+
const [key, value] = lines[i].split(/=(.*)/)
56+
if (key.length === 0 || typeof value === "undefined") {
57+
throw new Error(`Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`)
58+
}
59+
headers[key] = value
60+
}
61+
}
62+
return headers
63+
}

src/remote.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,16 @@ export class Remote {
508508
}
509509

510510
const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"`
511+
512+
// Add headers from the header command.
513+
let headerCommand = vscode.workspace.getConfiguration().get("coder.headerCommand")
514+
if (headerCommand) {
515+
headerCommand = ` --header-command ${headerCommand}`
516+
}
517+
511518
const sshValues: SSHValues = {
512519
Host: `${Remote.Prefix}*`,
513-
ProxyCommand: `${escape(binaryPath)} vscodessh --network-info-dir ${escape(
520+
ProxyCommand: `${escape(binaryPath)}${headerCommand} vscodessh --network-info-dir ${escape(
514521
this.storage.getNetworkInfoPath(),
515522
)} --session-token-file ${escape(this.storage.getSessionTokenPath())} --url-file ${escape(
516523
this.storage.getURLPath(),

src/storage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import os from "os"
1111
import path from "path"
1212
import prettyBytes from "pretty-bytes"
1313
import * as vscode from "vscode"
14+
import { getHeaders } from "./headers"
1415

1516
export class Storage {
1617
public workspace?: Workspace
@@ -382,6 +383,10 @@ export class Storage {
382383
await fs.rm(this.getSessionTokenPath(), { force: true })
383384
}
384385
}
386+
387+
public async getHeaders(url = this.getURL()): Promise<Record<string, string> | undefined> {
388+
return getHeaders(url, vscode.workspace.getConfiguration().get("coder.headerCommand"), this)
389+
}
385390
}
386391

387392
// goos returns the Go format for the current platform.

0 commit comments

Comments
 (0)