Skip to content

Commit

Permalink
refactor: transport service
Browse files Browse the repository at this point in the history
- Convert to typscript
- Overhaul SSH transport and add missing tests
- Fix long-standing issues with SSH transport
  • Loading branch information
fredriklindberg committed Oct 13, 2023
1 parent b13d2c7 commit dc31a72
Show file tree
Hide file tree
Showing 24 changed files with 1,358 additions and 458 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@
"@types/koa-joi-router": "^8.0.5",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.0",
"@types/port-numbers": "^5.0.0",
"@types/sinon": "^10.0.17",
"@types/ssh2": "^1.11.14",
"@types/sshpk": "^1.17.2",
"@types/ws": "^8.5.5",
"commit-and-tag-version": "^11.2.1",
"mocha": "^10.2.0",
Expand Down
7 changes: 7 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const parse = (canonicalArgv, callback, args = {}) => {
'transport-ssh-port',
'transport-ssh-host',
'transport-ssh-key',
'transport-ssh-allow-insecure-target',
], 'Transport configuration')
.option('transport', {
type: 'array',
Expand Down Expand Up @@ -155,6 +156,12 @@ const parse = (canonicalArgv, callback, args = {}) => {
process.exit(-1);
}
})
.option('transport-ssh-allow-insecure-target', {
type: 'boolean',
default: false,
hidden: true,
description: "Allow self-signed and expired TLS certificates on target",
})
.group([
'admin-enable',
'admin-port',
Expand Down
2 changes: 1 addition & 1 deletion src/controller/admin-api-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class AdminApiController extends KoaController {
}
};

const tunnelProps = (tunnel: Tunnel, baseUrl: String) => {
const tunnelProps = (tunnel: Tunnel, baseUrl: string) => {
return {
tunnel_id: tunnel.id,
account_id: tunnel.account,
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export default async (argv) => {
hostKey: config.get('transport-ssh-key'),
host: config.get('transport-ssh-host'),
port: config.get('transport-ssh-port'),
allowInsecureTarget: config.get('transport-ssh-allow-insecure-target'),
},
});
} catch (e) {
Expand Down
11 changes: 7 additions & 4 deletions src/ingress/http-ingress.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,16 @@ class HttpIngress {
return net.isIP(ip) ? ip : req.socket.remoteAddress;
}

_createAgent(tunnelId) {
_createAgent(tunnelId, req) {
const agent = new Agent({
keepAlive: true,
timeout: this._agent_ttl * 1000,
});

const remoteAddr = this._clientIp(req);
agent.createConnection = (opts, callback) => {
const ctx = {
remoteAddr,
ingress: {
tls: false,
port: this.httpListener.getPort(),
Expand All @@ -175,13 +177,13 @@ class HttpIngress {
return agent;
}

_getAgent(tunnelId) {
_getAgent(tunnelId, req) {
let agent;
try {
agent = this._agentCache.get(tunnelId);
} catch (e) {}
if (agent === undefined) {
agent = this._createAgent(tunnelId);
agent = this._createAgent(tunnelId, req);
this._agentCache.set(tunnelId, agent, this._agent_ttl);
} else {
this._agentCache.ttl(tunnelId, this._agent_ttl);
Expand Down Expand Up @@ -308,7 +310,7 @@ class HttpIngress {
keepAlive: true,
};

const agent = opt.agent = this._getAgent(tunnel.id);
const agent = opt.agent = this._getAgent(tunnel.id, req);
opt.headers = this._requestHeaders(req, tunnel, baseUrl);

this.logger.trace({
Expand Down Expand Up @@ -388,6 +390,7 @@ class HttpIngress {
}

const ctx = {
remoteAddr: this._clientIp(req),
ingress: {
tls: false,
port: this.httpListener.getPort(),
Expand Down
1 change: 1 addition & 0 deletions src/ingress/sni-ingress.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ class SNIIngress {
})

const ctx = {
remoteAddr: socket.remoteAddress,
ingress: {
tls: true,
port: this.port,
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,19 +1,53 @@
import crypto from 'crypto';
import ssh from 'ssh2';
import ssh, { AuthContext } from 'ssh2';
import sshpk from 'sshpk';
import { Logger } from '../../logger.js';
import TunnelService from '../../tunnel/tunnel-service.js';
import Version from '../../version.js';
import SSHTransport from './ssh-transport.js';
import TransportEndpoint, { EndpointResult, TransportEndpointOptions } from '../transport-endpoint.js';
import Tunnel from '../../tunnel/tunnel.js';
import Account from '../../account/account.js';
import Transport from '../transport.js';

const sshBanner = `exposr/${Version.version.version}`;

class SSHEndpoint {
constructor(opts) {
export type SSHEndpointOptions = {
enabled: boolean,
hostKey?: string,
host?: string,
port: number,
allowInsecureTarget: boolean,
}

export type _SSHEndpointOptions = SSHEndpointOptions & TransportEndpointOptions & {
callback?: (err?: Error | undefined) => void,
}

export interface SSHEndpointResult extends EndpointResult {
host: string,
port: number,
username: string,
password: string,
url: string,
fingerprint: string,
}

export default class SSHEndpoint extends TransportEndpoint {
private opts: _SSHEndpointOptions;
private logger: any;
private tunnelService: TunnelService;
private _clients: Array<Transport>;

private _hostkey: string;
private _fingerprint: string;
private _server: ssh.Server;

constructor(opts: _SSHEndpointOptions) {
super(opts)
this.opts = opts;
this.logger = Logger("ssh-transport-endpoint");
this.tunnelService = new TunnelService();
this._clients = new Set();

const generateHostKey = () => {
const keys = crypto.generateKeyPairSync('rsa', {
Expand All @@ -34,10 +68,12 @@ class SSHEndpoint {

this._hostkey = opts.hostKey || generateHostKey();
this._fingerprint = sshpk.parsePrivateKey(this._hostkey).fingerprint().toString();
this._clients = [];

const server = this._server = new ssh.Server({
hostKeys: [this._hostkey],
banner: sshBanner,
ident: sshBanner,
});

server.on('connection', (client, clientInfo) => {
Expand All @@ -46,82 +82,97 @@ class SSHEndpoint {
info: {
ip: clientInfo.ip,
port: clientInfo.port,
ident: clientInfo.identRaw,
header: clientInfo.header,
},
})

this._clients.add(client);
client.once('close', () => {
this._clients.delete(client);
client.removeAllListeners();
});

this._handleClient(client, clientInfo);
});

const connectionError = (err) => {
const connectionError = (err: Error) => {
this.logger.error({
message: `Failed to initialize ssh transport connection endpoint: ${err}`,
});
typeof opts.callback === 'function' && opts.callback(err);
};
server.once('error', connectionError);
server.listen(opts.port, (err) => {
server.listen(opts.port, () => {
server.removeListener('error', connectionError);
this.logger.info({
msg: 'SSH transport endpoint initialized',
fingerprint: this._fingerprint
});

server.on('error', (err: Error) => {
this.logger.error({
message: `SSH transport error: ${err.message}`
});
this.logger.debug({
stack: `${err.stack}`
});
});
typeof opts.callback === 'function' && opts.callback();
});
}

async destroy() {
if (this.destroyed) {
return;
}
this.destroyed = true;
return new Promise((resolve) => {
protected async _destroy(): Promise<void> {
await new Promise(async (resolve) => {
this._server.once('close', async () => {
await this.tunnelService.destroy();
resolve();
this._server.removeAllListeners();
resolve(undefined);
});
for (const transport of this._clients) {
await transport.destroy();
}
this._clients = [];
this._server.close();
this._clients.forEach((client) => client.destroy());
});
}

getEndpoint(tunnel, baseUrl) {
public getEndpoint(tunnel: Tunnel, baseUrl: URL): SSHEndpointResult {
const host = this.opts.host ?? baseUrl.hostname;
const port = this.opts.port;
const username = tunnel.id;
const password = tunnel.config.transport.token;
const password = tunnel.config.transport.token || "";
const fingerprint = this._fingerprint;

let url;
try {
url = new URL(`ssh://${username}:${password}@${host}`);
if (!url.port) {
url.port = port;
url.port = `${port}`;
}
} catch (e) {
return {};
return <any>{};
}

return {
host: url.hostname,
port: url.port,
port: Number.parseInt(url.port),
username,
password,
url: url.href,
fingerprint,
};
}

_handleClient(client, info) {
let tunnel;
let account;
client.on('authentication', async (ctx) => {
private _handleClient(client: ssh.Connection, info: ssh.ClientInfo): void {
let tunnel: Tunnel;
let account: Account;

client.once('authentication', async (ctx: AuthContext) => {
let [tunnelId, token] = ctx.username.split(':');
if (token == undefined) {

if (ctx.method == 'none' && token == undefined) {
return ctx.reject();
}

if (ctx.method == 'password' && token == undefined) {
token = ctx.password;
}

Expand All @@ -131,33 +182,39 @@ class SSHEndpoint {
};

if (token == undefined) {
return ctx.reject();
return reject();
}

const authResult = await this.tunnelService.authorize(tunnelId, token);
if (authResult.authorized == false) {
return reject();
}

tunnel = authResult.tunnel;
account = authResult.account;

if (tunnel.state.connected) {
if (!authResult.tunnel || !authResult.account) {
return reject();
}

tunnel = authResult.tunnel;
account = authResult.account;

ctx.accept();
});

client.on('ready', async (ctx) => {
client.once('ready', async () => {
const transport = new SSHTransport({
tunnelId: tunnel.id,
target: tunnel.config.target.url,
max_connections: this.opts.max_connections,
allowInsecureTarget: this.opts.allowInsecureTarget,
client,
});
const res = await this.tunnelService.connect(tunnel.id, account.id, transport, { peer: info.ip });
if (!res) {
if (res) {
this._clients.push(transport);
transport.once('close', () => {
this._clients = this._clients.filter((t) => t.id != transport.id);
});
} else {
this.logger
.withContext("tunnel", tunnel.id)
.error({
Expand All @@ -166,9 +223,7 @@ class SSHEndpoint {
});
transport.destroy();
}

});
}
}

export default SSHEndpoint;
}
}
Loading

0 comments on commit dc31a72

Please sign in to comment.