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 4 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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@
"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
105 changes: 105 additions & 0 deletions src/SSHConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { ensureDir } from "fs-extra"
import * as fs 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
}

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

constructor(filePath: string) {
this.filePath = filePath
}

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

async update(values: SSHValues) {
const block = this.getBlock()
if (block) {
this.eraseBlock(block)
}
this.appendBlock(values)
await this.save()
}

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("End block is starting before the 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)
this.raw = `${this.raw}\n${lines.join("\n")}`
}

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

private async save() {
await ensureDir(path.dirname(this.filePath))
return fs.writeFile(this.filePath, this.getRaw(), "utf-8")
}

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

return this.raw
}
}
81 changes: 35 additions & 46 deletions src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,20 @@ import {
import { ProvisionerJobLog, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
import EventSource from "eventsource"
import find from "find-process"
import { ensureDir } from "fs-extra"
import * as fs from "fs/promises"
import * as jsonc from "jsonc-parser"
import * as os from "os"
import * as path from "path"
import prettyBytes from "pretty-bytes"
import * as semver from "semver"
import SSHConfig from "ssh-config"
import * as vscode from "vscode"
import * as ws from "ws"
import { SSHConfig } from "./SSHConfig"
import { Storage } from "./storage"

export class Remote {
// Prefix is a magic string that is prepended to SSH
// hosts to indicate that they should be handled by
// this extension.
// Prefix is a magic string that is prepended to SSH hosts to indicate that
// they should be handled by this extension.
public static readonly Prefix = "coder-vscode--"

public constructor(
Expand All @@ -35,16 +33,17 @@ export class Remote {

public async setup(remoteAuthority: string): Promise<vscode.Disposable | undefined> {
const authorityParts = remoteAuthority.split("+")
// If the URI passed doesn't have the proper prefix
// ignore it. We don't need to do anything special,
// because this isn't trying to open a Coder workspace.
// If the URI passed doesn't have the proper prefix ignore it. We don't need
// to do anything special, because this isn't trying to open a Coder
// workspace.
if (!authorityParts[1].startsWith(Remote.Prefix)) {
return
}
const sshAuthority = authorityParts[1].substring(Remote.Prefix.length)

// Authorities are in the format: coder-vscode--<username>--<workspace>--<agent>
// Agent can be omitted then will be prompted for instead.
// Authorities are in the format:
// coder-vscode--<username>--<workspace>--<agent> Agent can be omitted then
// will be prompted for instead.
const parts = sshAuthority.split("--")
if (parts.length < 2 || parts.length > 3) {
throw new Error(`Invalid Coder SSH authority. Must be: <username>--<workspace>--<agent?>`)
Expand Down Expand Up @@ -142,12 +141,12 @@ export class Remote {
}
}

// If a build is running we should stream the logs to the user so
// they can watch what's going on!
// If a build is running we should stream the logs to the user so they can
// watch what's going on!
if (workspace.latest_build.status === "pending" || workspace.latest_build.status === "starting") {
const writeEmitter = new vscode.EventEmitter<string>()
// We use a terminal instead of an output channel because it feels
// more familiar to a user!
// We use a terminal instead of an output channel because it feels more
// familiar to a user!
const terminal = vscode.window.createTerminal({
name: "Build Log",
location: vscode.TerminalLocation.Panel,
Expand Down Expand Up @@ -218,8 +217,8 @@ export class Remote {
agent = agents[0]
}

// If there are multiple agents, we should select one here!
// TODO: Support multiple agents!
// If there are multiple agents, we should select one here! TODO: Support
// multiple agents!
}

if (!agent) {
Expand Down Expand Up @@ -337,12 +336,12 @@ export class Remote {
return
}

// This ensures the Remote SSH extension resolves
// the host to execute the Coder binary properly.
// This ensures the Remote SSH extension resolves the host to execute the
// Coder binary properly.
//
// If we didn't write to the SSH config file,
// connecting would fail with "Host not found".
await this.updateSSHConfig(authorityParts[1])
// If we didn't write to the SSH config file, connecting would fail with
// "Host not found".
await this.updateSSHConfig()

this.findSSHProcessID().then((pid) => {
if (!pid) {
Expand Down Expand Up @@ -372,22 +371,15 @@ export class Remote {
}
}

// updateSSHConfig updates the SSH configuration with a wildcard
// that handles all Coder entries.
private async updateSSHConfig(sshHost: string) {
// updateSSHConfig updates the SSH configuration with a wildcard that handles
// all Coder entries.
private async updateSSHConfig() {
let sshConfigFile = vscode.workspace.getConfiguration().get<string>("remote.SSH.configFile")
if (!sshConfigFile) {
sshConfigFile = path.join(os.homedir(), ".ssh", "config")
}
let sshConfigRaw: string
try {
sshConfigRaw = await fs.readFile(sshConfigFile, "utf8")
} catch (ex) {
// Probably just doesn't exist!
sshConfigRaw = ""
}
const parsedConfig = SSHConfig.parse(sshConfigRaw)
const computedHost = parsedConfig.compute(sshHost)
const sshConfig = new SSHConfig(sshConfigFile)
await sshConfig.load()

let binaryPath: string | undefined
if (this.mode === vscode.ExtensionMode.Production) {
Expand All @@ -399,9 +391,8 @@ export class Remote {
throw new Error("Failed to fetch the Coder binary!")
}

parsedConfig.remove({ Host: computedHost.Host })
const escape = (str: string): string => `"${str.replace(/"/g, '\\"')}"`
parsedConfig.append({
const sshValues = {
Host: `${Remote.Prefix}*`,
ProxyCommand: `${escape(binaryPath)} vscodessh --network-info-dir ${escape(
this.storage.getNetworkInfoPath(),
Expand All @@ -412,14 +403,13 @@ export class Remote {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
LogLevel: "ERROR",
})
}

await ensureDir(path.dirname(sshConfigFile))
await fs.writeFile(sshConfigFile, parsedConfig.toString())
await sshConfig.update(sshValues)
}

// showNetworkUpdates finds the SSH process ID that is being used by
// this workspace and reads the file being created by the Coder CLI.
// showNetworkUpdates finds the SSH process ID that is being used by this
// workspace and reads the file being created by the Coder CLI.
private showNetworkUpdates(sshPid: number): vscode.Disposable {
const networkStatus = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1000)
const networkInfoFile = path.join(this.storage.getNetworkInfoPath(), `${sshPid}.json`)
Expand Down Expand Up @@ -510,14 +500,13 @@ export class Remote {
}
}

// findSSHProcessID returns the currently active SSH process ID
// that is powering the remote SSH connection.
// findSSHProcessID returns the currently active SSH process ID that is
// powering the remote SSH connection.
private async findSSHProcessID(timeout = 15000): Promise<number | undefined> {
const search = async (logPath: string): Promise<number | undefined> => {
// This searches for the socksPort that Remote SSH is connecting to.
// We do this to find the SSH process that is powering this connection.
// That SSH process will be logging network information periodically to
// a file.
// This searches for the socksPort that Remote SSH is connecting to. We do
// this to find the SSH process that is powering this connection. That SSH
// process will be logging network information periodically to a file.
const text = await fs.readFile(logPath, "utf8")
const matches = text.match(/-> socksPort (\d+) ->/)
if (!matches) {
Expand Down
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4557,11 +4557,6 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==

[email protected]:
version "4.1.6"
resolved "https://registry.yarnpkg.com/ssh-config/-/ssh-config-4.1.6.tgz#008eee24f5e5029dc64d50de4a5a7a12342db8b1"
integrity sha512-YdPYn/2afoBonSFoMSvC1FraA/LKKrvy8UvbvAFGJ8gdlKuANvufLLkf8ynF2uq7Tl5+DQBIFyN37//09nAgNQ==

state-toggle@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe"
Expand Down