Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
111 changes: 111 additions & 0 deletions src/SSHConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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("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()
this.raw = `${raw.trimEnd()}\n${lines.join("\n")}`
}

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

private async save() {
await ensureDir(path.dirname(this.filePath), {
mode: 0o700, // only owner has rwx permission, not group or everyone.
})
return fs.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
}
}
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==

ssh-config@4.1.6:
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