Skip to content

Commit 3463d56

Browse files
Will O'Beirnecode-asher
Will O'Beirne
authored andcommitted
SSH server & endpoint
1 parent 5f63d2b commit 3463d56

File tree

10 files changed

+526
-8
lines changed

10 files changed

+526
-8
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
"@types/safe-compare": "^1.1.0",
2525
"@types/semver": "^7.1.0",
2626
"@types/tar-fs": "^1.16.2",
27+
"@types/ssh2": "0.5.39",
28+
"@types/ssh2-streams": "^0.1.6",
29+
"@types/tar-fs": "^1.16.1",
30+
"@types/tar-stream": "^1.6.1",
2731
"@types/ws": "^6.0.4",
2832
"@typescript-eslint/eslint-plugin": "^2.0.0",
2933
"@typescript-eslint/parser": "^2.0.0",
@@ -50,10 +54,12 @@
5054
"adm-zip": "^0.4.14",
5155
"fs-extra": "^8.1.0",
5256
"httpolyglot": "^0.1.2",
57+
"node-pty": "^0.9.0",
5358
"pem": "^1.14.2",
5459
"safe-compare": "^1.1.4",
5560
"semver": "^7.1.3",
5661
"tar": "^6.0.1",
62+
"ssh2": "^0.8.7",
5763
"tar-fs": "^2.0.0",
5864
"ws": "^7.2.0"
5965
}

src/browser/pages/home.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
name="viewport"
77
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
88
/>
9-
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;" />
9+
<meta
10+
http-equiv="Content-Security-Policy"
11+
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
12+
/>
1013
<title>code-server</title>
1114
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
1215
<link

src/node/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface Args extends VsArgs {
3131
readonly open?: boolean
3232
readonly port?: number
3333
readonly socket?: string
34+
readonly "ssh-host-key"?: string
35+
readonly "disable-ssh"?: boolean
3436
readonly version?: boolean
3537
readonly force?: boolean
3638
readonly "list-extensions"?: boolean
@@ -96,6 +98,9 @@ const options: Options<Required<Args>> = {
9698
version: { type: "boolean", short: "v", description: "Display version information." },
9799
_: { type: "string[]" },
98100

101+
"disable-ssh": { type: "boolean" },
102+
"ssh-host-key": { type: "string", path: true },
103+
99104
"user-data-dir": { type: "string", path: true, description: "Path to the user data directory." },
100105
"extensions-dir": { type: "string", path: true, description: "Path to the extensions directory." },
101106
"builtin-extensions-dir": { type: "string", path: true },

src/node/entry.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { UpdateHttpProvider } from "./app/update"
1010
import { VscodeHttpProvider } from "./app/vscode"
1111
import { Args, optionDescriptions, parse } from "./cli"
1212
import { AuthType, HttpServer } from "./http"
13-
import { generateCertificate, generatePassword, hash, open } from "./util"
13+
import { SshProvider } from "./ssh/server"
14+
import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util"
1415
import { ipcMain, wrap } from "./wrapper"
1516

1617
const main = async (args: Args): Promise<void> => {
@@ -29,6 +30,7 @@ const main = async (args: Args): Promise<void> => {
2930
auth,
3031
cert: args.cert ? args.cert.value : undefined,
3132
certKey: args["cert-key"],
33+
sshHostKey: args["ssh-host-key"],
3234
commit: commit || "development",
3335
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
3436
password: originalPassword ? hash(originalPassword) : undefined,
@@ -43,6 +45,13 @@ const main = async (args: Args): Promise<void> => {
4345
} else if (args.cert && !args["cert-key"]) {
4446
throw new Error("--cert-key is missing")
4547
}
48+
if (!args["disable-ssh"]) {
49+
if (!options.sshHostKey && typeof options.sshHostKey !== "undefined") {
50+
throw new Error("--ssh-host-key cannot be blank")
51+
} else if (!options.sshHostKey) {
52+
options.sshHostKey = await generateSshHostKey()
53+
}
54+
}
4655

4756
const httpServer = new HttpServer(options)
4857
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
@@ -55,6 +64,13 @@ const main = async (args: Args): Promise<void> => {
5564
ipcMain().onDispose(() => httpServer.dispose())
5665

5766
logger.info(`code-server ${require("../../package.json").version}`)
67+
68+
let sshPort = ""
69+
if (!args["disable-ssh"]) {
70+
const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, options.sshHostKey as string)
71+
sshPort = await sshProvider.listen()
72+
}
73+
5874
const serverAddress = await httpServer.listen()
5975
logger.info(`Server listening on ${serverAddress}`)
6076

@@ -82,6 +98,12 @@ const main = async (args: Args): Promise<void> => {
8298

8399
logger.info(` - Automatic updates are ${update.enabled ? "enabled" : "disabled"}`)
84100

101+
if (sshPort) {
102+
logger.info(` - SSH Server - Listening :${sshPort}`)
103+
} else {
104+
logger.info(" - SSH Server - Disabled")
105+
}
106+
85107
if (serverAddress && !options.socket && args.open) {
86108
// The web socket doesn't seem to work if browsing with 0.0.0.0.
87109
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")

src/node/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ export class HttpServer {
525525
"Set-Cookie": [
526526
`${payload.cookie.key}=${payload.cookie.value}`,
527527
`Path=${normalize(payload.cookie.path || "/", true)}`,
528-
"HttpOnly",
528+
// "HttpOnly",
529529
"SameSite=strict",
530530
].join(";"),
531531
}

src/node/ssh/server.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import * as http from "http"
2+
import * as net from "net"
3+
import * as ssh from "ssh2"
4+
import * as ws from "ws"
5+
import * as fs from "fs"
6+
import { logger } from "@coder/logger"
7+
import safeCompare from "safe-compare"
8+
import { HttpProvider, HttpResponse, HttpProviderOptions, Route } from "../http"
9+
import { HttpCode } from "../../common/http"
10+
import { forwardSshPort, fillSshSession } from "./ssh"
11+
import { hash } from "../util"
12+
13+
export class SshProvider extends HttpProvider {
14+
private readonly wss = new ws.Server({ noServer: true })
15+
private sshServer: ssh.Server
16+
17+
public constructor(options: HttpProviderOptions, hostKeyPath: string) {
18+
super(options)
19+
const hostKey = fs.readFileSync(hostKeyPath)
20+
this.sshServer = new ssh.Server({ hostKeys: [hostKey] }, this.handleSsh)
21+
22+
this.sshServer.on("error", (err) => {
23+
logger.error(`SSH server error: ${err.stack}`)
24+
})
25+
}
26+
27+
public async listen(): Promise<string> {
28+
return new Promise((resolve, reject) => {
29+
this.sshServer.once("error", reject)
30+
this.sshServer.listen(() => {
31+
resolve(this.sshServer.address().port.toString())
32+
})
33+
})
34+
}
35+
36+
public async handleRequest(): Promise<HttpResponse> {
37+
// SSH has no HTTP endpoints
38+
return { code: HttpCode.NotFound }
39+
}
40+
41+
public handleWebSocket(
42+
_route: Route,
43+
request: http.IncomingMessage,
44+
socket: net.Socket,
45+
head: Buffer,
46+
): Promise<void> {
47+
// Create a fake websocket to the sshServer
48+
const sshSocket = net.connect(this.sshServer.address().port, "localhost")
49+
50+
return new Promise((resolve) => {
51+
this.wss.handleUpgrade(request, socket, head, (ws) => {
52+
// Send SSH data to WS as compressed binary
53+
sshSocket.on("data", (data) => {
54+
ws.send(data, {
55+
binary: true,
56+
compress: true,
57+
fin: true,
58+
})
59+
})
60+
61+
// Send WS data to SSH as buffer
62+
ws.on("message", (msg) => {
63+
// Buffer.from is cool with all types, but casting as string keeps typing simple
64+
sshSocket.write(Buffer.from(msg as string))
65+
})
66+
67+
ws.on("error", (err) => {
68+
logger.error(`SSH websocket error: ${err.stack}`)
69+
})
70+
71+
resolve()
72+
})
73+
})
74+
}
75+
76+
/**
77+
* Determine how to handle incoming SSH connections.
78+
*/
79+
private handleSsh = (client: ssh.Connection, info: ssh.ClientInfo): void => {
80+
logger.debug(`Incoming SSH connection from ${info.ip}`)
81+
client.on("authentication", (ctx) => {
82+
// Allow any auth to go through if we have no password
83+
if (!this.options.password) {
84+
return ctx.accept()
85+
}
86+
87+
// Otherwise require the same password as code-server
88+
if (ctx.method === "password") {
89+
if (
90+
safeCompare(this.options.password, hash(ctx.password)) ||
91+
safeCompare(this.options.password, ctx.password)
92+
) {
93+
return ctx.accept()
94+
}
95+
}
96+
97+
// Reject, letting them know that password is the only method we allow
98+
ctx.reject(["password"])
99+
})
100+
client.on("tcpip", forwardSshPort)
101+
client.on("session", fillSshSession)
102+
client.on("error", (err) => {
103+
// Don't bother logging Keepalive errors, they probably just disconnected
104+
if (err.message === "Keepalive timeout") {
105+
return logger.debug("SSH client keepalive timeout")
106+
}
107+
logger.error(`SSH client error: ${err.stack}`)
108+
})
109+
}
110+
}

0 commit comments

Comments
 (0)