SSH server & endpoint
This commit is contained in:
parent
5f63d2b822
commit
3463d56114
@ -24,6 +24,10 @@
|
|||||||
"@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-fs": "^1.16.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",
|
||||||
"@typescript-eslint/parser": "^2.0.0",
|
"@typescript-eslint/parser": "^2.0.0",
|
||||||
@ -50,10 +54,12 @@
|
|||||||
"adm-zip": "^0.4.14",
|
"adm-zip": "^0.4.14",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
"httpolyglot": "^0.1.2",
|
"httpolyglot": "^0.1.2",
|
||||||
|
"node-pty": "^0.9.0",
|
||||||
"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",
|
||||||
"tar": "^6.0.1",
|
"tar": "^6.0.1",
|
||||||
|
"ssh2": "^0.8.7",
|
||||||
"tar-fs": "^2.0.0",
|
"tar-fs": "^2.0.0",
|
||||||
"ws": "^7.2.0"
|
"ws": "^7.2.0"
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,10 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;" />
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
|
||||||
|
/>
|
||||||
<title>code-server</title>
|
<title>code-server</title>
|
||||||
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||||
<link
|
<link
|
||||||
|
@ -31,6 +31,8 @@ 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
|
||||||
@ -96,6 +98,9 @@ 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" },
|
||||||
|
"ssh-host-key": { type: "string", path: true },
|
||||||
|
|
||||||
"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 },
|
||||||
|
@ -10,7 +10,8 @@ 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 } from "./http"
|
import { AuthType, HttpServer } from "./http"
|
||||||
import { generateCertificate, generatePassword, hash, open } from "./util"
|
import { SshProvider } from "./ssh/server"
|
||||||
|
import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util"
|
||||||
import { ipcMain, wrap } from "./wrapper"
|
import { ipcMain, wrap } from "./wrapper"
|
||||||
|
|
||||||
const main = async (args: Args): Promise<void> => {
|
const main = async (args: Args): Promise<void> => {
|
||||||
@ -29,6 +30,7 @@ const main = async (args: Args): Promise<void> => {
|
|||||||
auth,
|
auth,
|
||||||
cert: args.cert ? args.cert.value : undefined,
|
cert: args.cert ? args.cert.value : undefined,
|
||||||
certKey: args["cert-key"],
|
certKey: args["cert-key"],
|
||||||
|
sshHostKey: args["ssh-host-key"],
|
||||||
commit: commit || "development",
|
commit: commit || "development",
|
||||||
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
|
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
|
||||||
password: originalPassword ? hash(originalPassword) : undefined,
|
password: originalPassword ? hash(originalPassword) : undefined,
|
||||||
@ -43,6 +45,13 @@ const main = async (args: Args): Promise<void> => {
|
|||||||
} else if (args.cert && !args["cert-key"]) {
|
} else if (args.cert && !args["cert-key"]) {
|
||||||
throw new Error("--cert-key is missing")
|
throw new Error("--cert-key is missing")
|
||||||
}
|
}
|
||||||
|
if (!args["disable-ssh"]) {
|
||||||
|
if (!options.sshHostKey && typeof options.sshHostKey !== "undefined") {
|
||||||
|
throw new Error("--ssh-host-key cannot be blank")
|
||||||
|
} else if (!options.sshHostKey) {
|
||||||
|
options.sshHostKey = await generateSshHostKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const httpServer = new HttpServer(options)
|
const httpServer = new HttpServer(options)
|
||||||
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
|
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
|
||||||
@ -55,6 +64,13 @@ const main = async (args: Args): Promise<void> => {
|
|||||||
ipcMain().onDispose(() => httpServer.dispose())
|
ipcMain().onDispose(() => httpServer.dispose())
|
||||||
|
|
||||||
logger.info(`code-server ${require("../../package.json").version}`)
|
logger.info(`code-server ${require("../../package.json").version}`)
|
||||||
|
|
||||||
|
let sshPort = ""
|
||||||
|
if (!args["disable-ssh"]) {
|
||||||
|
const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, options.sshHostKey as string)
|
||||||
|
sshPort = await sshProvider.listen()
|
||||||
|
}
|
||||||
|
|
||||||
const serverAddress = await httpServer.listen()
|
const serverAddress = await httpServer.listen()
|
||||||
logger.info(`Server listening on ${serverAddress}`)
|
logger.info(`Server listening on ${serverAddress}`)
|
||||||
|
|
||||||
@ -82,6 +98,12 @@ 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"}`)
|
||||||
|
|
||||||
|
if (sshPort) {
|
||||||
|
logger.info(` - SSH Server - Listening :${sshPort}`)
|
||||||
|
} 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")
|
||||||
|
@ -525,7 +525,7 @@ export class HttpServer {
|
|||||||
"Set-Cookie": [
|
"Set-Cookie": [
|
||||||
`${payload.cookie.key}=${payload.cookie.value}`,
|
`${payload.cookie.key}=${payload.cookie.value}`,
|
||||||
`Path=${normalize(payload.cookie.path || "/", true)}`,
|
`Path=${normalize(payload.cookie.path || "/", true)}`,
|
||||||
"HttpOnly",
|
// "HttpOnly",
|
||||||
"SameSite=strict",
|
"SameSite=strict",
|
||||||
].join(";"),
|
].join(";"),
|
||||||
}
|
}
|
||||||
|
110
src/node/ssh/server.ts
Normal file
110
src/node/ssh/server.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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.error(`SSH server error: ${err.stack}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listen(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sshServer.once("error", reject)
|
||||||
|
this.sshServer.listen(() => {
|
||||||
|
resolve(this.sshServer.address().port.toString())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
201
src/node/ssh/sftp.ts
Normal file
201
src/node/ssh/sftp.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
122
src/node/ssh/ssh.ts
Normal file
122
src/node/ssh/ssh.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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())
|
||||||
|
})
|
||||||
|
}
|
@ -44,6 +44,12 @@ 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)
|
||||||
|
53
yarn.lock
53
yarn.lock
@ -937,7 +937,22 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/tar-fs@^1.16.2":
|
"@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.1":
|
||||||
version "1.16.2"
|
version "1.16.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.2.tgz#6f5acea15d3b7777b8bf3f1c6d4e80ce71288f34"
|
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.2.tgz#6f5acea15d3b7777b8bf3f1c6d4e80ce71288f34"
|
||||||
integrity sha512-eds/pbRf0Fe0EKmrHDbs8mRkfbjz2upAdoUfREw14dPboZaHqqZ1Y+uVeoakoPavpZMpj22nhUTAYkX5bz3DXA==
|
integrity sha512-eds/pbRf0Fe0EKmrHDbs8mRkfbjz2upAdoUfREw14dPboZaHqqZ1Y+uVeoakoPavpZMpj22nhUTAYkX5bz3DXA==
|
||||||
@ -945,7 +960,7 @@
|
|||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
"@types/tar-stream" "*"
|
"@types/tar-stream" "*"
|
||||||
|
|
||||||
"@types/tar-stream@*":
|
"@types/tar-stream@*", "@types/tar-stream@^1.6.1":
|
||||||
version "1.6.1"
|
version "1.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-1.6.1.tgz#67d759068ff781d976cad978893bb7a334ec8809"
|
resolved "https://registry.yarnpkg.com/@types/tar-stream/-/tar-stream-1.6.1.tgz#67d759068ff781d976cad978893bb7a334ec8809"
|
||||||
integrity sha512-pYCDOPuRE+4tXFk1rSMYiuI+kSrXiJ4av1bboQbkcEBA2rqwEWfIn9kdMSH+5nYu58WksHuxwx+7kVbtg0Le7w==
|
integrity sha512-pYCDOPuRE+4tXFk1rSMYiuI+kSrXiJ4av1bboQbkcEBA2rqwEWfIn9kdMSH+5nYu58WksHuxwx+7kVbtg0Le7w==
|
||||||
@ -1221,7 +1236,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.3:
|
asn1@~0.2.0, 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==
|
||||||
@ -1356,7 +1371,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.0, bcrypt-pbkdf@^1.0.2:
|
||||||
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=
|
||||||
@ -4404,7 +4419,7 @@ mute-stream@0.0.8:
|
|||||||
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
|
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
|
||||||
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
|
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
|
||||||
|
|
||||||
nan@^2.12.1:
|
nan@^2.12.1, nan@^2.14.0:
|
||||||
version "2.14.0"
|
version "2.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
|
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
|
||||||
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
|
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
|
||||||
@ -4483,6 +4498,13 @@ node-libs-browser@^2.0.0:
|
|||||||
util "^0.11.0"
|
util "^0.11.0"
|
||||||
vm-browserify "^1.0.1"
|
vm-browserify "^1.0.1"
|
||||||
|
|
||||||
|
node-pty@^0.9.0:
|
||||||
|
version "0.9.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.9.0.tgz#8f9bcc0d1c5b970a3184ffd533d862c7eb6590a6"
|
||||||
|
integrity sha512-MBnCQl83FTYOu7B4xWw10AW77AAh7ThCE1VXEv+JeWj8mSpGo+0bwgsV+b23ljBFwEM9OmsOv3kM27iUPPm84g==
|
||||||
|
dependencies:
|
||||||
|
nan "^2.14.0"
|
||||||
|
|
||||||
node-releases@^1.1.49:
|
node-releases@^1.1.49:
|
||||||
version "1.1.49"
|
version "1.1.49"
|
||||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.49.tgz#67ba5a3fac2319262675ef864ed56798bb33b93e"
|
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.49.tgz#67ba5a3fac2319262675ef864ed56798bb33b93e"
|
||||||
@ -6249,6 +6271,22 @@ 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"
|
||||||
@ -6338,6 +6376,11 @@ 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"
|
||||||
|
Reference in New Issue
Block a user