Skip to content

fix: Inject coder ssh configuration between comments #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jan 27, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 2 additions & 20 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,8 @@
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
]
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/runner"
],
"outFiles": [
"${workspaceFolder}/out/**/*.test.js"
],
"preLaunchTask": "tsc: build"
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
]
}
17 changes: 4 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,13 @@
"watch": "webpack --watch",
"package": "webpack --mode production --devtool hidden-source-map",
"package:prerelease": "npx vsce package --pre-release",
"lint": "eslint . --ext ts,md",
"tsc:compile": "tsc",
"tsc:watch": "tsc -w",
"pretest:ci": "npm run tsc:compile",
"test:ci": "node ./out/src/test/runTest.js",
"test": "npm run test:ci",
"posttest": "npm run lint -- --fix",
"test:nocompile": "node ./out/src/test/runTest.js",
"posttest:nocompile": "npm run lint -- --fix",
"test:watch": "tsc-watch --noClear --onFirstSuccess 'npm run test:nocompile' --onSuccess 'npm run test:nocompile'"
"lint": "eslint . --ext ts,md --fix",
"test": "vitest ./src"
},
"devDependencies": {
"@types/eventsource": "^1.1.10",
"@types/fs-extra": "^11.0.0",
"@types/glob": "^7.1.3",
"@types/mocha": "^8.0.4",
"@types/ndjson": "^2.0.1",
"@types/node": "^16.11.21",
"@types/vscode": "^1.73.0",
Expand All @@ -120,13 +111,13 @@
"eslint-plugin-md": "^1.0.19",
"eslint-plugin-prettier": "^4.0.0",
"glob": "^7.1.6",
"mocha": "^8.2.1",
"nyc": "^15.1.0",
"prettier": "^2.2.1",
"ts-loader": "^8.0.14",
"tsc-watch": "^4.5.0",
"typescript": "^4.1.3",
"utf-8-validate": "^5.0.10",
"vitest": "^0.28.3",
"vscode-test": "^1.5.0",
"webpack": "^5.19.0",
"webpack-cli": "^5.0.1"
Expand All @@ -137,10 +128,10 @@
"find-process": "^1.4.7",
"fs-extra": "^11.1.0",
"jsonc-parser": "^3.2.0",
"memfs": "^3.4.13",
"ndjson": "^2.0.0",
"pretty-bytes": "^6.0.0",
"semver": "^7.3.8",
"ssh-config": "4.1.6",
"tar-fs": "^2.1.1",
"which": "^2.0.2",
"ws": "^8.11.0",
Expand Down
169 changes: 169 additions & 0 deletions src/SSHConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { it, afterEach, vi, expect } from "vitest"
import { SSHConfig } from "./SSHConfig"

const sshFilePath = "~/.config/ssh"

const mockFileSystem = {
readFile: vi.fn(),
ensureDir: vi.fn(),
writeFile: vi.fn(),
}

afterEach(() => {
vi.clearAllMocks()
})

it("creates a new file and adds the config", async () => {
mockFileSystem.readFile.mockRejectedValueOnce("No file found")

const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
await sshConfig.load()
await sshConfig.update({
Host: "coder--vscode--*",
ProxyCommand: "some-command-here",
ConnectTimeout: "0",
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
LogLevel: "ERROR",
})

const expectedOutput = `# --- START CODER VSCODE ---
Host coder--vscode--*
ProxyCommand some-command-here
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
# --- END CODER VSCODE ---`

expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
})

it("adds a new coder config in an existent SSH configuration", async () => {
const existentSSHConfig = `Host coder.something
HostName coder.something
ConnectTimeout=0
StrictHostKeyChecking=no
UserKnownHostsFile=/dev/null
LogLevel ERROR
ProxyCommand command`
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)

const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
await sshConfig.load()
await sshConfig.update({
Host: "coder--vscode--*",
ProxyCommand: "some-command-here",
ConnectTimeout: "0",
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
LogLevel: "ERROR",
})

const expectedOutput = `${existentSSHConfig}

# --- START CODER VSCODE ---
Host coder--vscode--*
ProxyCommand some-command-here
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
# --- END CODER VSCODE ---`

expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
encoding: "utf-8",
mode: 384,
})
})

it("updates an existent coder config", async () => {
const existentSSHConfig = `Host coder.something
HostName coder.something
ConnectTimeout=0
StrictHostKeyChecking=no
UserKnownHostsFile=/dev/null
LogLevel ERROR
ProxyCommand command

# --- START CODER VSCODE ---
Host coder--vscode--*
ProxyCommand some-command-here
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
# --- END CODER VSCODE ---`
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)

const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
await sshConfig.load()
await sshConfig.update({
Host: "coder--updated--vscode--*",
ProxyCommand: "some-command-here",
ConnectTimeout: "0",
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
LogLevel: "ERROR",
})

const expectedOutput = `Host coder.something
HostName coder.something
ConnectTimeout=0
StrictHostKeyChecking=no
UserKnownHostsFile=/dev/null
LogLevel ERROR
ProxyCommand command

# --- START CODER VSCODE ---
Host coder--updated--vscode--*
ProxyCommand some-command-here
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
# --- END CODER VSCODE ---`

expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
encoding: "utf-8",
mode: 384,
})
})

it("removes old coder SSH config and adds the new one", async () => {
const existentSSHConfig = `Host coder--vscode--*
HostName coder.something
ConnectTimeout=0
StrictHostKeyChecking=no
UserKnownHostsFile=/dev/null
LogLevel ERROR
ProxyCommand command`
mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig)

const sshConfig = new SSHConfig(sshFilePath, mockFileSystem)
await sshConfig.load()
await sshConfig.update({
Host: "coder--vscode--*",
ProxyCommand: "some-command-here",
ConnectTimeout: "0",
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
LogLevel: "ERROR",
})

const expectedOutput = `# --- START CODER VSCODE ---
Host coder--vscode--*
ProxyCommand some-command-here
ConnectTimeout 0
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERROR
# --- END CODER VSCODE ---`

expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
encoding: "utf-8",
mode: 384,
})
})
142 changes: 142 additions & 0 deletions src/SSHConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ensureDir } from "fs-extra"
import { writeFile, readFile } from "fs/promises"
import path from "path"

class SSHConfigBadFormat extends Error {}

interface Block {
raw: string
}

interface SSHValues {
Host: string
ProxyCommand: string
ConnectTimeout: string
StrictHostKeyChecking: string
UserKnownHostsFile: string
LogLevel: string
}

// Interface for the file system to make it easier to test
export interface FileSystem {
readFile: typeof readFile
ensureDir: typeof ensureDir
writeFile: typeof writeFile
}

const defaultFileSystem: FileSystem = {
readFile,
ensureDir,
writeFile,
}

export class SSHConfig {
private filePath: string
private fileSystem: FileSystem
private raw: string | undefined
private startBlockComment = "# --- START CODER VSCODE ---"
private endBlockComment = "# --- END CODER VSCODE ---"

constructor(filePath: string, fileSystem: FileSystem = defaultFileSystem) {
this.filePath = filePath
this.fileSystem = fileSystem
}

async load() {
try {
this.raw = await this.fileSystem.readFile(this.filePath, "utf-8")
} catch (ex) {
// Probably just doesn't exist!
this.raw = ""
}
}

async update(values: SSHValues) {
// We should remove this in March 2023 because there is not going to have
// old configs
this.cleanUpOldConfig()
const block = this.getBlock()
if (block) {
this.eraseBlock(block)
}
this.appendBlock(values)
await this.save()
}

private async cleanUpOldConfig() {
const raw = this.getRaw()
const oldConfig = raw.split("\n\n").find((config) => config.includes("Host coder--vscode--*"))
if (oldConfig) {
this.raw = raw.replace(oldConfig, "")
}
}

private getBlock(): Block | undefined {
const raw = this.getRaw()
const startBlockIndex = raw.indexOf(this.startBlockComment)
const endBlockIndex = raw.indexOf(this.endBlockComment)
const hasBlock = startBlockIndex > -1 && endBlockIndex > -1

if (!hasBlock) {
return
}

if (startBlockIndex === -1) {
throw new SSHConfigBadFormat("Start block not found")
}

if (startBlockIndex === -1) {
throw new SSHConfigBadFormat("End block not found")
}

if (endBlockIndex < startBlockIndex) {
throw new SSHConfigBadFormat("Malformed config, end block is before start block")
}

return {
raw: raw.substring(startBlockIndex, endBlockIndex + this.endBlockComment.length),
}
}

private eraseBlock(block: Block) {
this.raw = this.getRaw().replace(block.raw, "")
}

private appendBlock({ Host, ...otherValues }: SSHValues) {
const lines = [this.startBlockComment, `Host ${Host}`]
const keys = Object.keys(otherValues) as Array<keyof typeof otherValues>
keys.forEach((key) => {
lines.push(this.withIndentation(`${key} ${otherValues[key]}`))
})
lines.push(this.endBlockComment)
const raw = this.getRaw()

if (this.raw === "") {
this.raw = lines.join("\n")
} else {
this.raw = `${raw.trimEnd()}\n\n${lines.join("\n")}`
}
}

private withIndentation(text: string) {
return ` ${text}`
}

private async save() {
await this.fileSystem.ensureDir(path.dirname(this.filePath), {
mode: 0o700, // only owner has rwx permission, not group or everyone.
})
return this.fileSystem.writeFile(this.filePath, this.getRaw(), {
mode: 0o600, // owner rw
encoding: "utf-8",
})
}

private getRaw() {
if (this.raw === undefined) {
throw new Error("SSHConfig is not loaded. Try sshConfig.load()")
}

return this.raw
}
}
Loading