Last active
September 17, 2024 11:01
-
-
Save yurynix/0f48b39a3261109253c2528e127565bc to your computer and use it in GitHub Desktop.
Connect to serveo.net with ssh2 lib in nodejs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | |
}()); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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