Skip to content

Instantly share code, notes, and snippets.

@yurynix
Last active September 17, 2024 11:01
Show Gist options
  • Save yurynix/0f48b39a3261109253c2528e127565bc to your computer and use it in GitHub Desktop.
Save yurynix/0f48b39a3261109253c2528e127565bc to your computer and use it in GitHub Desktop.
Connect to serveo.net with ssh2 lib in nodejs
const net = require('net');
const { Client } = require('ssh2');
function tunnel(localPort) {
const sshClient = new Client();
let resolvePromise = null;
let rejectPromise = null;
const resultPromise = new Promise((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
sshClient.on('ready', () => {
console.log('SSH Client :: ready');
sshClient.forwardIn('localhost', 80, (err, port) => {
if (err) return rejectPromise(err);
console.log(`Forwarding ready ${port}!`);
});
sshClient.shell((err, stream) => {
if (err) return rejectPromise(err);
stream.on('close', () => {
console.log('sshClient shell stream :: close');
sshClient.end();
}).on('data', (data) => {
if (data) console.log(`ssh (${data.length}) >> ` + data);
// Extract URL if it appears in the data
const urlMatch = data.toString().match(/https:\/\/\S+/);
if (urlMatch) {
resolvePromise({
destroy: () => sshClient.destroy(),
url: urlMatch[0]
});
}
});
});
}).on('tcp connection', (info, accept, reject) => {
const remoteConnection = accept();
const localSocket = new net.Socket();
localSocket.connect(localPort, '127.0.0.1', (err) => {
if (err) throw err;
console.log(`Local socket connected to ${localPort} <- ${info.srcIP}:${info.srcPort}`, err);
localSocket.pipe(remoteConnection).pipe(localSocket);
});
}).connect({
host: 'serveo.net',
port: 22,
username: "johndoe",
tryKeyboard: true
});
return resultPromise;
}
(async function main() {
const { destroy, url } = await tunnel(8080);
setTimeout(() => {
console.log('DESTROYING!');
destroy();
}, 30 * 1000);
console.log(`Tunnel at ${url}`);
}());
const fs = require("fs"),
ssh2 = require("ssh2"),
crypto = require("crypto"),
tls = require("tls"),
humanId = require("human-id");
const TUNNNEL_DOMAIN = "tunnl.icu";
const HTTP_SERVER_PORT = 443;
const SSL_PRIVATE_KEY_PATH = "/etc/letsencrypt/live/tunnl.icu/privkey.pem";
const SSL_CERTIFICATE_PATH = "/etc/letsencrypt/live/tunnl.icu/fullchain.pem";
const SSH_KEY_PATH = "private.key";
const clientsMap = new Map();
function renderHomepage(socket) {
try {
const content = `Hello ${socket.remoteAddress}!\nYou reached serveo.net wannabe clone.\nYou can create a public tunnel to your local machine via the command:\n\nssh -R 80:localhost:3000 ${TUNNNEL_DOMAIN}\n\nWhere 3000 is your local port you want to expose.`;
socket.write(
`HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-size: ${content.length}\r\n\r\n`,
);
socket.write(content);
} catch (ex) {
console.log("renderHomepage failed", ex);
}
}
const MAX_REQUEST_SIZE = 1024 * 1024; // 1 MB
const REQUEST_TIMEOUT = 5000; // 5 seconds
function extractHostFromRequestData(requestData) {
const match = requestData.match(/host: (.*)\r\n/i);
if (match && match.length === 2) {
return match[1];
}
return null;
}
function extractTokenParamFromRequestData(requestData) {
/*
GET /.tunnel-token/?token=124234 HTTP/1.1
Host: habibi.tunnl.icu
User-Agent: curl/8.7.1
*/
const match = requestData.match(/GET \/\.tunnel-token\/\?token=(.*) HTTP\/1.1/i);
if (match && match.length === 2) {
return match[1];
}
return null;
}
function extractTokenHeaderFromRequestData(requestData) {
// in requestData we have a Cookie header that contains a token value, extract it
// token cookie my not be the only cookie or even the first one.
const match = requestData.match(/Cookie:.*token=(.*);?/i);
if (match && match.length === 2) {
return match[1];
}
return null;
}
function writeHttpResponseToSocketAndHangup(socket, responseCode, responseMessage) {
socket.write(`HTTP/1.1 ${responseCode} ${responseMessage}\r\nConnection: close\r\nContent-Length: ${responseMessage.length}\r\nContent-Type: plain/text\r\n\r\n`);
socket.write(`${responseMessage}\r\n`);
socket.destroy();
}
const server = tls.createServer(
{
key: fs.readFileSync(SSL_PRIVATE_KEY_PATH),
cert: fs.readFileSync(SSL_CERTIFICATE_PATH),
},
(socket) => {
let requestData = "";
let requestSize = 0;
let requestTimeout;
socket.on("error", (err) => {
console.log(`TLS Socket for ${socket.remoteAddress} error`, err);
});
socket.on("data", (data) => {
requestData += data.toString();
requestSize += data.length;
// Check if request size exceeds the maximum limit
if (requestSize > MAX_REQUEST_SIZE) {
console.log("Request size limit exceeded. Closing connection.");
writeHttpResponseToSocketAndHangup(socket, 413, "Request entity too large");
return;
}
const host = extractHostFromRequestData(requestData);
if (!host) {
console.log(`Unable to get host header: ${host}`);
writeHttpResponseToSocketAndHangup(socket, 400, "Can't serve this host");
return;
}
socket.pause();
// Stop listening for more data
socket.removeAllListeners("data");
// Clear request timeout
clearTimeout(requestTimeout);
if (host === TUNNNEL_DOMAIN) {
renderHomepage(socket);
socket.end();
return;
}
const domainParts = host.split(`.${TUNNNEL_DOMAIN}`);
if (domainParts.length !== 2) {
console.log(`Don't know what to do with host: ${host}`);
writeHttpResponseToSocketAndHangup(socket, 404, "No tunnel");
return;
}
const clientId = domainParts[0];
const client = clientsMap.get(clientId);
if (!client) {
console.log(`No tunnel for ${clientId}`);
writeHttpResponseToSocketAndHangup(socket, 404, "No tunnel");
return;
}
if (client.privateToken) {
const requestSetTokenCandidate = extractTokenParamFromRequestData(requestData);
if (client.privateToken === requestSetTokenCandidate) {
console.log(`Token matched for ${clientId}`);
socket.write(`HTTP/1.1 301 Moved\r\nLocation: /\r\nSet-Cookie: token=${client.privateToken}; Path=/; SameSite=None; Max-Age: 2592000; Secure; HttpOnly\r\nConnection: close\r\n\r\n`);
socket.destroy();
return;
}
const requestTokenCookie = extractTokenHeaderFromRequestData(requestData);
if (client.privateToken !== requestTokenCookie) {
console.log(`Token mismatch for ${clientId} provided '${requestTokenCookie}'`);
writeHttpResponseToSocketAndHangup(socket, 401, "Missing or invalid authroization token");
return;
}
}
if (!client.tcpForwardRequest) {
console.log(`Client ${clientId} tcpForwardRequest is missing!`);
writeHttpResponseToSocketAndHangup(socket, 503, "Origin port unavailable");
return;
}
console.log(
`Proxy request for ${clientId} ${socket.remoteAddress}:${socket.remotePort} -> ${client.tcpForwardRequest.bindAddr} ${client.tcpForwardRequest.bindPort}`,
);
if (client.shellStream) {
client.shellStream.write(
`${socket.remoteAddress}:${socket.remotePort} connected\r\n`,
);
} else {
console.warn(`client ${clientId} shellStream is missing!`);
}
if (client.sshClient) {
client.sshClient.forwardOut(
client.tcpForwardRequest.bindAddr,
client.tcpForwardRequest.bindPort,
socket.remoteAddress,
socket.remotePort,
(err, upstream) => {
if (err) {
writeHttpResponseToSocketAndHangup(socket, 503, "Failed to tunnel");
if (client.shellStream) {
client.shellStream.write(
`Failed to establish tunnel ${err}\r\n`,
);
}
return console.error(
`Failed to establish tunnel for ${clientId}: ` + err,
);
}
// Re-transmit the data received until "Host" header
upstream.write(requestData);
socket.pipe(upstream).pipe(socket);
socket.resume();
},
);
} else {
console.warn(`client ${clientId} ssh client is mssing!`);
}
});
// Set request timeout
requestTimeout = setTimeout(() => {
console.log("Request timeout. Closing connection.");
writeHttpResponseToSocketAndHangup(socket, 408, "Timeout");
}, REQUEST_TIMEOUT);
},
);
server.listen(HTTP_SERVER_PORT, (err) => {
if (err) {
return console.log("something bad happened", err);
}
console.log(`HTTP server is listening on ${server.address().port}`);
});
server.on("error", (err) => {
console.log("TLS server error", err);
});
const fingerprint = (key) =>
crypto.createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
const vipClients = {
"PQ2OcQd55ipa3jMZJFh08d3hHnMtrFxR+BadRn9+/xY": "yury",
OMSS4STYIp8AHHxWD1apmQwY00PrBDIl06MRbL4IUDs: "ayal",
};
new ssh2.Server(
{
hostKeys: [fs.readFileSync(SSH_KEY_PATH)],
},
(client, info) => {
let clientId = humanId.humanId({
separator: "-",
capitalize: false,
});
let privateToken = undefined;
client
.on("authentication", async (ctx) => {
switch (ctx.method) {
case "none":
return ctx.reject();
case "keyboard-interactive":
return ctx.accept();
case "password":
console.log(`Client ${clientId} password auth username: ${ctx.username} pass: ${ctx.password}`);
privateToken = ctx.password;
ctx.accept();
break;
case "publickey":
const keyFingerprint = fingerprint(ctx.key.data);
if (vipClients[keyFingerprint]) {
console.log(
`We have a VIP, changing clientID ${clientId} to: ${vipClients[keyFingerprint]} of fingerprint: ${keyFingerprint}`,
);
clientId = vipClients[keyFingerprint];
} else {
console.log(`No VIP for ${keyFingerprint}`);
}
return ctx.accept();
}
})
.on("ready", () => {
console.log(`Client ${clientId} authenticated ${info.ip}:${info.port}!`);
clientsMap.set(clientId, {
sshClient: client,
shellStream: null,
tcpForwardRequest: null,
privateToken,
});
client
.on("session", (accept, reject) => {
let session = accept();
session
.on("shell", function (accept, reject) {
console.log(`client ${clientId} session start shell`);
const stream = accept();
stream.write("Hello\r\n");
stream.write(
`I'm a clone of serveo.net, your tunnel will be at: https://${clientId}.${TUNNNEL_DOMAIN}\r\n`,
);
clientsMap.get(clientId).shellStream = stream;
stream.on('data', function(data) {
if (data.includes(0x3) || data.includes(0x4)) {
console.log(`client ${clientId} shell data ctrl-c or ctrl-d`, data);
stream.write('Bye Bye!\r\n');
stream.close();
clientsMap.delete(clientId);
return;
}
console.log(`client ${clientId} shell data ${data.constructor.name} ${typeof data}`, Buffer.from(data).toString('hex'), data.length);
});
})
.on("pty", function (accept, reject) {
accept();
})
.on("exec", function (accept, reject, info) {
console.log(
"Client wants to execute: " + JSON.stringify(info.command),
);
const stream = accept();
stream.stderr.write(
"Your client wants to execute stuff, please verify you`re not using some wrapper (like warp terminal) that playing tricks on you.\n",
);
stream.exit(1);
stream.end();
});
})
.on("request", (accept, reject, name, info) => {
console.log(`${clientId} request`, name, info);
if (name === "tcpip-forward") {
accept();
clientsMap.get(clientId).tcpForwardRequest = info;
} else {
reject();
}
});
})
.on("end", () => {
if (clientsMap.has(clientId)) {
console.log(`Client ${clientId} disconnected`);
clientsMap.delete(clientId);
}
})
.on("error", (err) => {
console.log(`Client ${clientId} error`, err);
clientsMap.delete(clientId);
});
},
).listen(22, "0.0.0.0", function () {
console.log("Listening on port " + this.address().port);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment