Archived
1
0

Merge pull request #1563 from cdr/remove-ssh

Remove SSH server
This commit is contained in:
Anmol Sethi 2020-04-28 14:15:54 -04:00 committed by GitHub
commit f21ba53609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 3 additions and 514 deletions

View File

@ -25,8 +25,6 @@
"@types/safe-compare": "^1.1.0", "@types/safe-compare": "^1.1.0",
"@types/semver": "^7.1.0", "@types/semver": "^7.1.0",
"@types/tar-fs": "^1.16.2", "@types/tar-fs": "^1.16.2",
"@types/ssh2": "0.5.39",
"@types/ssh2-streams": "^0.1.6",
"@types/tar-stream": "^1.6.1", "@types/tar-stream": "^1.6.1",
"@types/ws": "^6.0.4", "@types/ws": "^6.0.4",
"@typescript-eslint/eslint-plugin": "^2.0.0", "@typescript-eslint/eslint-plugin": "^2.0.0",
@ -59,7 +57,6 @@
"pem": "^1.14.2", "pem": "^1.14.2",
"safe-compare": "^1.1.4", "safe-compare": "^1.1.4",
"semver": "^7.1.3", "semver": "^7.1.3",
"ssh2": "^0.8.7",
"tar": "^6.0.1", "tar": "^6.0.1",
"tar-fs": "^2.0.0", "tar-fs": "^2.0.0",
"ws": "^7.2.0" "ws": "^7.2.0"

View File

@ -31,8 +31,6 @@ export interface Args extends VsArgs {
readonly open?: boolean readonly open?: boolean
readonly port?: number readonly port?: number
readonly socket?: string readonly socket?: string
readonly "ssh-host-key"?: string
readonly "disable-ssh"?: boolean
readonly version?: boolean readonly version?: boolean
readonly force?: boolean readonly force?: boolean
readonly "list-extensions"?: boolean readonly "list-extensions"?: boolean
@ -99,9 +97,6 @@ const options: Options<Required<Args>> = {
version: { type: "boolean", short: "v", description: "Display version information." }, version: { type: "boolean", short: "v", description: "Display version information." },
_: { type: "string[]" }, _: { type: "string[]" },
"disable-ssh": { type: "boolean", description: "Disable the SSH server." },
"ssh-host-key": { type: "string", path: true, description: "SSH server host key." },
"user-data-dir": { type: "string", path: true, description: "Path to the user data directory." }, "user-data-dir": { type: "string", path: true, description: "Path to the user data directory." },
"extensions-dir": { type: "string", path: true, description: "Path to the extensions directory." }, "extensions-dir": { type: "string", path: true, description: "Path to the extensions directory." },
"builtin-extensions-dir": { type: "string", path: true }, "builtin-extensions-dir": { type: "string", path: true },

View File

@ -11,8 +11,7 @@ import { UpdateHttpProvider } from "./app/update"
import { VscodeHttpProvider } from "./app/vscode" import { VscodeHttpProvider } from "./app/vscode"
import { Args, optionDescriptions, parse } from "./cli" import { Args, optionDescriptions, parse } from "./cli"
import { AuthType, HttpServer, HttpServerOptions } from "./http" import { AuthType, HttpServer, HttpServerOptions } from "./http"
import { SshProvider } from "./ssh/server" import { generateCertificate, generatePassword, hash, open } from "./util"
import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util"
import { ipcMain, wrap } from "./wrapper" import { ipcMain, wrap } from "./wrapper"
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
@ -101,32 +100,6 @@ const main = async (args: Args): Promise<void> => {
logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`)
let sshHostKey = args["ssh-host-key"]
if (!args["disable-ssh"] && !sshHostKey) {
try {
sshHostKey = await generateSshHostKey()
} catch (error) {
logger.error("Unable to start SSH server", field("error", error.message))
}
}
let sshPort: number | undefined
if (!args["disable-ssh"] && sshHostKey) {
const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, sshHostKey)
try {
sshPort = await sshProvider.listen()
} catch (error) {
logger.warn(`SSH server: ${error.message}`)
}
}
if (typeof sshPort !== "undefined") {
logger.info(`SSH server listening on localhost:${sshPort}`)
logger.info(" - To disable use `--disable-ssh`")
} else {
logger.info("SSH server disabled")
}
if (serverAddress && !options.socket && args.open) { if (serverAddress && !options.socket && args.open) {
// The web socket doesn't seem to work if browsing with 0.0.0.0. // The web socket doesn't seem to work if browsing with 0.0.0.0.
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost") const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")

View File

@ -1,110 +0,0 @@
import * as http from "http"
import * as net from "net"
import * as ssh from "ssh2"
import * as ws from "ws"
import * as fs from "fs"
import { logger } from "@coder/logger"
import safeCompare from "safe-compare"
import { HttpProvider, HttpResponse, HttpProviderOptions, Route } from "../http"
import { HttpCode } from "../../common/http"
import { forwardSshPort, fillSshSession } from "./ssh"
import { hash } from "../util"
export class SshProvider extends HttpProvider {
private readonly wss = new ws.Server({ noServer: true })
private sshServer: ssh.Server
public constructor(options: HttpProviderOptions, hostKeyPath: string) {
super(options)
const hostKey = fs.readFileSync(hostKeyPath)
this.sshServer = new ssh.Server({ hostKeys: [hostKey] }, this.handleSsh)
this.sshServer.on("error", (err) => {
logger.trace(`SSH server error: ${err.stack}`)
})
}
public async listen(): Promise<number> {
return new Promise((resolve, reject) => {
this.sshServer.once("error", reject)
this.sshServer.listen(() => {
resolve(this.sshServer.address().port)
})
})
}
public async handleRequest(): Promise<HttpResponse> {
// SSH has no HTTP endpoints
return { code: HttpCode.NotFound }
}
public handleWebSocket(
_route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
// Create a fake websocket to the sshServer
const sshSocket = net.connect(this.sshServer.address().port, "localhost")
return new Promise((resolve) => {
this.wss.handleUpgrade(request, socket, head, (ws) => {
// Send SSH data to WS as compressed binary
sshSocket.on("data", (data) => {
ws.send(data, {
binary: true,
compress: true,
fin: true,
})
})
// Send WS data to SSH as buffer
ws.on("message", (msg) => {
// Buffer.from is cool with all types, but casting as string keeps typing simple
sshSocket.write(Buffer.from(msg as string))
})
ws.on("error", (err) => {
logger.error(`SSH websocket error: ${err.stack}`)
})
resolve()
})
})
}
/**
* Determine how to handle incoming SSH connections.
*/
private handleSsh = (client: ssh.Connection, info: ssh.ClientInfo): void => {
logger.debug(`Incoming SSH connection from ${info.ip}`)
client.on("authentication", (ctx) => {
// Allow any auth to go through if we have no password
if (!this.options.password) {
return ctx.accept()
}
// Otherwise require the same password as code-server
if (ctx.method === "password") {
if (
safeCompare(this.options.password, hash(ctx.password)) ||
safeCompare(this.options.password, ctx.password)
) {
return ctx.accept()
}
}
// Reject, letting them know that password is the only method we allow
ctx.reject(["password"])
})
client.on("tcpip", forwardSshPort)
client.on("session", fillSshSession)
client.on("error", (err) => {
// Don't bother logging Keepalive errors, they probably just disconnected
if (err.message === "Keepalive timeout") {
return logger.debug("SSH client keepalive timeout")
}
logger.error(`SSH client error: ${err.stack}`)
})
}
}

View File

@ -1,201 +0,0 @@
/**
* Provides utilities for handling SSH connections
*/
import * as fs from "fs"
import * as path from "path"
import * as ssh from "ssh2"
import { FileEntry, SFTPStream } from "ssh2-streams"
/**
* Fills out all the functionality of SFTP using fs.
*/
export function fillSftpStream(accept: () => SFTPStream): void {
const sftp = accept()
let oid = 0
const fds: { [key: number]: boolean } = {}
const ods: {
[key: number]: {
path: string
read: boolean
}
} = {}
const sftpStatus = (reqID: number, err?: NodeJS.ErrnoException | null): boolean => {
let code = ssh.SFTP_STATUS_CODE.OK
if (err) {
if (err.code === "EACCES") {
code = ssh.SFTP_STATUS_CODE.PERMISSION_DENIED
} else if (err.code === "ENOENT") {
code = ssh.SFTP_STATUS_CODE.NO_SUCH_FILE
} else {
code = ssh.SFTP_STATUS_CODE.FAILURE
}
}
return sftp.status(reqID, code)
}
sftp.on("OPEN", (reqID, filename) => {
fs.open(filename, "w", (err, fd) => {
if (err) {
return sftpStatus(reqID, err)
}
fds[fd] = true
const buf = Buffer.alloc(4)
buf.writeUInt32BE(fd, 0)
return sftp.handle(reqID, buf)
})
})
sftp.on("OPENDIR", (reqID, path) => {
const buf = Buffer.alloc(4)
const id = oid++
buf.writeUInt32BE(id, 0)
ods[id] = {
path,
read: false,
}
sftp.handle(reqID, buf)
})
sftp.on("READDIR", (reqID, handle) => {
const od = handle.readUInt32BE(0)
if (!ods[od]) {
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
if (ods[od].read) {
sftp.status(reqID, ssh.SFTP_STATUS_CODE.EOF)
return
}
return fs.readdir(ods[od].path, (err, files) => {
if (err) {
return sftpStatus(reqID, err)
}
return Promise.all(
files.map((f) => {
return new Promise<FileEntry>((resolve, reject) => {
const fullPath = path.join(ods[od].path, f)
fs.stat(fullPath, (err, stats) => {
if (err) {
return reject(err)
}
resolve({
filename: f,
longname: fullPath,
attrs: {
atime: stats.atimeMs,
gid: stats.gid,
mode: stats.mode,
size: stats.size,
mtime: stats.mtimeMs,
uid: stats.uid,
},
})
})
})
}),
)
.then((files) => {
sftp.name(reqID, files)
ods[od].read = true
})
.catch(() => {
sftp.status(reqID, ssh.SFTP_STATUS_CODE.FAILURE)
})
})
})
sftp.on("WRITE", (reqID, handle, offset, data) => {
const fd = handle.readUInt32BE(0)
if (!fds[fd]) {
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
return fs.write(fd, data, offset, (err) => sftpStatus(reqID, err))
})
sftp.on("CLOSE", (reqID, handle) => {
const fd = handle.readUInt32BE(0)
if (!fds[fd]) {
if (ods[fd]) {
delete ods[fd]
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.OK)
}
return sftp.status(reqID, ssh.SFTP_STATUS_CODE.NO_SUCH_FILE)
}
return fs.close(fd, (err) => sftpStatus(reqID, err))
})
sftp.on("STAT", (reqID, path) => {
fs.stat(path, (err, stats) => {
if (err) {
return sftpStatus(reqID, err)
}
return sftp.attrs(reqID, {
atime: stats.atime.getTime(),
gid: stats.gid,
mode: stats.mode,
mtime: stats.mtime.getTime(),
size: stats.size,
uid: stats.uid,
})
})
})
sftp.on("MKDIR", (reqID, path) => {
fs.mkdir(path, (err) => sftpStatus(reqID, err))
})
sftp.on("LSTAT", (reqID, path) => {
fs.lstat(path, (err, stats) => {
if (err) {
return sftpStatus(reqID, err)
}
return sftp.attrs(reqID, {
atime: stats.atimeMs,
gid: stats.gid,
mode: stats.mode,
mtime: stats.mtimeMs,
size: stats.size,
uid: stats.uid,
})
})
})
sftp.on("REMOVE", (reqID, path) => {
fs.unlink(path, (err) => sftpStatus(reqID, err))
})
sftp.on("RMDIR", (reqID, path) => {
fs.rmdir(path, (err) => sftpStatus(reqID, err))
})
sftp.on("REALPATH", (reqID, path) => {
fs.realpath(path, (pathErr, resolved) => {
if (pathErr) {
return sftpStatus(reqID, pathErr)
}
fs.stat(path, (statErr, stat) => {
if (statErr) {
return sftpStatus(reqID, statErr)
}
sftp.name(reqID, [
{
filename: resolved,
longname: resolved,
attrs: {
mode: stat.mode,
uid: stat.uid,
gid: stat.gid,
size: stat.size,
atime: stat.atime.getTime(),
mtime: stat.mtime.getTime(),
},
},
])
return
})
return
})
})
}

View File

@ -1,122 +0,0 @@
/**
* Provides utilities for handling SSH connections
*/
import * as net from "net"
import * as cp from "child_process"
import * as ssh from "ssh2"
import * as nodePty from "node-pty"
import { fillSftpStream } from "./sftp"
/**
* Fills out all of the functionality of SSH using node equivalents.
*/
export function fillSshSession(accept: () => ssh.Session): void {
let pty: nodePty.IPty | undefined
let activeProcess: cp.ChildProcess
let ptyInfo: ssh.PseudoTtyInfo | undefined
const env: { [key: string]: string } = {}
const session = accept()
// Run a command, stream back the data
const cmd = (command: string, channel: ssh.ServerChannel): void => {
if (ptyInfo) {
// Remove undefined and project env vars
// keysToRemove taken from sanitizeProcessEnvironment
const keysToRemove = [/^ELECTRON_.+$/, /^GOOGLE_API_KEY$/, /^VSCODE_.+$/, /^SNAP(|_.*)$/]
const env = Object.keys(process.env).reduce((prev, k) => {
if (process.env[k] === undefined) {
return prev
}
const val = process.env[k] as string
if (keysToRemove.find((rx) => val.search(rx))) {
return prev
}
prev[k] = val
return prev
}, {} as { [key: string]: string })
pty = nodePty.spawn(command, [], {
cols: ptyInfo.cols,
rows: ptyInfo.rows,
env,
})
pty.onData((d) => channel.write(d))
pty.on("exit", (exitCode) => {
channel.exit(exitCode)
channel.close()
})
channel.on("data", (d: string) => pty && pty.write(d))
return
}
const proc = cp.spawn(command, { shell: true })
proc.stdout.on("data", (d) => channel.stdout.write(d))
proc.stderr.on("data", (d) => channel.stderr.write(d))
proc.on("exit", (exitCode) => {
channel.exit(exitCode || 0)
channel.close()
})
channel.stdin.on("data", (d: unknown) => proc.stdin.write(d))
channel.stdin.on("close", () => proc.stdin.end())
}
session.on("pty", (accept, _, info) => {
ptyInfo = info
accept && accept()
})
session.on("shell", (accept) => {
cmd(process.env.SHELL || "/usr/bin/env bash", accept())
})
session.on("exec", (accept, _, info) => {
cmd(info.command, accept())
})
session.on("sftp", fillSftpStream)
session.on("signal", (accept, _, info) => {
accept && accept()
process.kill((pty || activeProcess).pid, info.name)
})
session.on("env", (accept, _reject, info) => {
accept && accept()
env[info.key] = info.value
})
session.on("auth-agent", (accept) => {
accept()
})
session.on("window-change", (accept, reject, info) => {
if (pty) {
pty.resize(info.cols, info.rows)
accept && accept()
} else {
reject()
}
})
}
/**
* Pipes a requested port over SSH
*/
export function forwardSshPort(
accept: () => ssh.ServerChannel,
reject: () => boolean,
info: ssh.TcpipRequestInfo,
): void {
const fwdSocket = net.createConnection(info.destPort, info.destIP)
fwdSocket.on("error", () => reject())
fwdSocket.on("connect", () => {
const channel = accept()
channel.pipe(fwdSocket)
channel.on("close", () => fwdSocket.end())
fwdSocket.pipe(channel)
fwdSocket.on("close", () => channel.close())
fwdSocket.on("error", () => channel.end())
fwdSocket.on("end", () => channel.end())
})
}

View File

@ -44,12 +44,6 @@ export const generateCertificate = async (): Promise<{ cert: string; certKey: st
return paths return paths
} }
export const generateSshHostKey = async (): Promise<string> => {
// Just reuse the SSL cert as the SSH host key
const { certKey } = await generateCertificate()
return certKey
}
export const generatePassword = async (length = 24): Promise<string> => { export const generatePassword = async (length = 24): Promise<string> => {
const buffer = Buffer.alloc(Math.ceil(length / 2)) const buffer = Buffer.alloc(Math.ceil(length / 2))
await util.promisify(crypto.randomFill)(buffer) await util.promisify(crypto.randomFill)(buffer)

View File

@ -117,7 +117,6 @@ describe("cli", () => {
assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/) assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/)
assert.throws(() => parse(["--auth", "--log"]), /--auth requires a value/) assert.throws(() => parse(["--auth", "--log"]), /--auth requires a value/)
assert.throws(() => parse(["--auth", "--invalid"]), /--auth requires a value/) assert.throws(() => parse(["--auth", "--invalid"]), /--auth requires a value/)
assert.throws(() => parse(["--ssh-host-key"]), /--ssh-host-key requires a value/)
}) })
it("should error if value is invalid", () => { it("should error if value is invalid", () => {

View File

@ -944,21 +944,6 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/ssh2-streams@*", "@types/ssh2-streams@^0.1.6":
version "0.1.6"
resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.6.tgz#1ff5b1fe50c15f282efe9fd092c68494f8702bc2"
integrity sha512-NB+SYftagfHTDPdgVyvSZCeg5MURyHECd/PycGIW9hwhnEbTFxIdDbFtf3jxUO3rRnfr0lfdtkZFiv28T1cAsg==
dependencies:
"@types/node" "*"
"@types/ssh2@0.5.39":
version "0.5.39"
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.39.tgz#e39ab7e38fc01337f66237ed6c42237ef3e58e3b"
integrity sha512-MtX4tr6Jtdn/JPVsQytjB/NBeOd7JfCyf/l79KOdkUYL+r4GXUXcySX1n8W4O61fnQdljTBXEPJJ2dhnGhi2xg==
dependencies:
"@types/node" "*"
"@types/ssh2-streams" "*"
"@types/tar-fs@^1.16.2": "@types/tar-fs@^1.16.2":
version "1.16.3" version "1.16.3"
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.3.tgz#425b2b817c405d13d051f36ec6ec6ebd25e31069" resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.3.tgz#425b2b817c405d13d051f36ec6ec6ebd25e31069"
@ -1243,7 +1228,7 @@ asn1.js@^4.0.0:
inherits "^2.0.1" inherits "^2.0.1"
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
asn1@~0.2.0, asn1@~0.2.3: asn1@~0.2.3:
version "0.2.4" version "0.2.4"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
@ -1378,7 +1363,7 @@ base@^0.11.1:
mixin-deep "^1.2.0" mixin-deep "^1.2.0"
pascalcase "^0.1.1" pascalcase "^0.1.1"
bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: bcrypt-pbkdf@^1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
@ -6304,22 +6289,6 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
ssh2-streams@~0.4.9:
version "0.4.9"
resolved "https://registry.yarnpkg.com/ssh2-streams/-/ssh2-streams-0.4.9.tgz#d3d92155410001437d27119d9c023b303cbe2309"
integrity sha512-glMQKeYKuA+rLaH16fJC3nZMV1BWklbxuYCR4C5/LlBSf2yaoNRpPU7Ul702xsi5nvYjIx9XDkKBJwrBjkDynw==
dependencies:
asn1 "~0.2.0"
bcrypt-pbkdf "^1.0.2"
streamsearch "~0.1.2"
ssh2@^0.8.7:
version "0.8.8"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-0.8.8.tgz#1d9815e287faef623ae2b7db32e674dadbef4664"
integrity sha512-egJVQkf3sbjECTY6rCeg8rgV/fab6S/7E5kpYqHT3Fe/YpfJbLYeA1qTcB2d+LRUUAjqKi7rlbfWkaP66YdpAQ==
dependencies:
ssh2-streams "~0.4.9"
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@ -6409,11 +6378,6 @@ stream-http@^2.7.2:
to-arraybuffer "^1.0.0" to-arraybuffer "^1.0.0"
xtend "^4.0.0" xtend "^4.0.0"
streamsearch@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
"string-width@^1.0.2 || 2": "string-width@^1.0.2 || 2":
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"