Skip to content

Commit 811673f

Browse files
committed
Add header command setting
This will be called before requests and added to the SSH config.
1 parent 697b30e commit 811673f

File tree

7 files changed

+158
-4
lines changed

7 files changed

+158
-4
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
"markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the extension's global storage directory.",
5858
"type": "string",
5959
"default": ""
60+
},
61+
"coder.headerCommand": {
62+
"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`.",
63+
"type": "string",
64+
"default": ""
6065
}
6166
}
6267
},

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
@@ -59,6 +59,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
5959
const storage = new Storage(output, ctx.globalState, ctx.secrets, ctx.globalStorageUri, ctx.logUri)
6060
await storage.init()
6161

62+
// Add headers from the header command.
63+
axios.interceptors.request.use(async (config) => {
64+
return {
65+
...config,
66+
headers: {
67+
...(await storage.getHeaders()),
68+
...creds.headers,
69+
},
70+
}
71+
})
72+
6273
const myWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.Mine, storage)
6374
const allWorkspacesProvider = new WorkspaceProvider(WorkspaceQuery.All, storage)
6475

@@ -74,8 +85,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
7485
}
7586
}
7687
})
77-
.catch(() => {
78-
// Not authenticated!
88+
.catch((error) => {
89+
// This should be a failure to make the request, like the header command
90+
// errored.
91+
vscodeProposed.window.showErrorMessage("Failed to check user authentication: " + error.message)
7992
})
8093
.finally(() => {
8194
vscode.commands.executeCommand("setContext", "coder.loaded", true)

src/headers.test.ts

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

src/headers.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/)
53+
for (let i = 0; i < lines.length; ++i) {
54+
const [key, value] = lines[i].split(/=(.*)/)
55+
// Header names cannot be blank or contain whitespace and the Coder CLI
56+
// requires that there be an equals sign (the value can be blank though).
57+
if (key.length === 0 || key.indexOf(" ") !== -1 || typeof value === "undefined") {
58+
throw new Error(`Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`)
59+
}
60+
headers[key] = value
61+
}
62+
}
63+
return headers
64+
}

src/remote.ts

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

510510
const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"`
511+
512+
// Add headers from the header command.
513+
let headerArg = ""
514+
const headerCommand = vscode.workspace.getConfiguration().get("coder.headerCommand")
515+
if (typeof headerCommand === "string" && headerCommand.trim().length > 0) {
516+
headerArg = ` --header-command "${escape(headerCommand)}"`
517+
}
518+
511519
const sshValues: SSHValues = {
512520
Host: `${Remote.Prefix}*`,
513-
ProxyCommand: `${escape(binaryPath)} vscodessh --network-info-dir ${escape(
521+
ProxyCommand: `${escape(binaryPath)}${headerArg} vscodessh --network-info-dir ${escape(
514522
this.storage.getNetworkInfoPath(),
515523
)} --session-token-file ${escape(this.storage.getSessionTokenPath())} --url-file ${escape(
516524
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
@@ -391,6 +392,10 @@ export class Storage {
391392
await fs.rm(this.getSessionTokenPath(), { force: true })
392393
}
393394
}
395+
396+
public async getHeaders(url = this.getURL()): Promise<Record<string, string> | undefined> {
397+
return getHeaders(url, vscode.workspace.getConfiguration().get("coder.headerCommand"), this)
398+
}
394399
}
395400

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

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy