192 lines
5.4 KiB
JavaScript
192 lines
5.4 KiB
JavaScript
/**
|
|
* anisail-tunnel: Bare-compatible Holesail client that binds a local port and
|
|
* prints it to stdout for the Ansible connection plugin. Keeps running until
|
|
* stdin closes or SIGTERM/SIGINT.
|
|
*
|
|
* Usage: anisail-tunnel --key <hs://...> [--remote-port 22] [--local-port 0] [--timeout 30000]
|
|
* Output: exactly one JSON line to stdout: {"local_port": <number>, "ready": true}
|
|
*/
|
|
|
|
import 'bare-process/global';
|
|
|
|
const DEFAULT_REMOTE_PORT = 22;
|
|
const DEFAULT_LOCAL_PORT = 0;
|
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
const LOCAL_HOST = '127.0.0.1';
|
|
|
|
function parseArgs() {
|
|
// slice(1): when run as a bundled binary, argv is [binaryPath, ...cliArgs]; slice(2) would drop --key
|
|
const args = process.argv.slice(1);
|
|
let key = null;
|
|
let remotePort = DEFAULT_REMOTE_PORT;
|
|
let localPort = DEFAULT_LOCAL_PORT;
|
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--key' && args[i + 1]) {
|
|
key = args[++i].trim();
|
|
} else if (args[i] === '--remote-port' && args[i + 1]) {
|
|
remotePort = parseInt(args[++i], 10) || DEFAULT_REMOTE_PORT;
|
|
} else if (args[i] === '--local-port' && args[i + 1]) {
|
|
localPort = parseInt(args[++i], 10);
|
|
if (isNaN(localPort) || localPort < 0) localPort = DEFAULT_LOCAL_PORT;
|
|
} else if (args[i] === '--timeout' && args[i + 1]) {
|
|
timeoutMs = parseInt(args[++i], 10) || DEFAULT_TIMEOUT_MS;
|
|
}
|
|
}
|
|
|
|
return { key, remotePort, localPort, timeoutMs };
|
|
}
|
|
|
|
function emit(line) {
|
|
try {
|
|
process.stdout.write(line + '\n');
|
|
} catch (_) {}
|
|
}
|
|
|
|
/**
|
|
* Verify that the port is accepting TCP connections before we report it.
|
|
* Tries to connect to host:port; resolves when connect succeeds (then we close the socket).
|
|
* Rejects on error or after timeoutMs.
|
|
*/
|
|
function waitUntilPortAccepting(host, port, timeoutMs = 10000, intervalMs = 200) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
return new Promise((resolve, reject) => {
|
|
function tryConnect() {
|
|
if (Date.now() >= deadline) {
|
|
reject(new Error('Port did not accept connections within ' + timeoutMs + 'ms'));
|
|
return;
|
|
}
|
|
import('net').then((net) => {
|
|
const socket = net.createConnection(
|
|
{ port, host, allowHalfOpen: false },
|
|
() => {
|
|
socket.setTimeout(0);
|
|
socket.destroy();
|
|
resolve();
|
|
}
|
|
);
|
|
socket.on('error', () => {
|
|
socket.destroy();
|
|
setTimeout(tryConnect, intervalMs);
|
|
});
|
|
socket.setTimeout(intervalMs, () => {
|
|
socket.destroy();
|
|
setTimeout(tryConnect, intervalMs);
|
|
});
|
|
}).catch(reject);
|
|
}
|
|
tryConnect();
|
|
});
|
|
}
|
|
|
|
async function run() {
|
|
const mod = await import('holesail');
|
|
const Holesail = mod.default || mod;
|
|
|
|
const { key, remotePort, localPort, timeoutMs } = parseArgs();
|
|
|
|
if (!key || key.length === 0) {
|
|
const msg = 'anisail-tunnel: --key <hs://...> is required';
|
|
try {
|
|
process.stderr.write(msg + '\n');
|
|
} catch (_) {}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const portToUse = (localPort > 0 ? localPort : 19200 + Math.floor(Math.random() * 1000)) || 19200;
|
|
|
|
const readyPromise = new Promise((resolve, reject) => {
|
|
const t = timeoutMs > 0
|
|
? setTimeout(() => reject(new Error('Tunnel ready timeout after ' + timeoutMs + 'ms')), timeoutMs)
|
|
: null;
|
|
const done = (err, port) => {
|
|
if (t) clearTimeout(t);
|
|
if (err) reject(err);
|
|
else resolve(port);
|
|
};
|
|
|
|
const opts = {
|
|
client: true,
|
|
key,
|
|
host: LOCAL_HOST,
|
|
port: portToUse
|
|
};
|
|
// If the Holesail API supports remotePort, tell it which port on the peer to use (e.g. 22 for SSH).
|
|
if (remotePort > 0 && remotePort <= 65535) {
|
|
opts.remotePort = remotePort;
|
|
}
|
|
const hs = new Holesail(opts);
|
|
|
|
if (typeof hs.on === 'function') {
|
|
hs.on('error', (err) => {
|
|
done(err || new Error('Tunnel error'));
|
|
});
|
|
}
|
|
|
|
hs.ready()
|
|
.then(() => {
|
|
const info = typeof hs.info === 'function' ? hs.info() : (hs.info || {});
|
|
const boundPort = (info && typeof info.port === 'number')
|
|
? info.port
|
|
: portToUse;
|
|
if (boundPort == null || boundPort < 1 || boundPort > 65535) {
|
|
done(new Error('Could not determine local tunnel port'));
|
|
return;
|
|
}
|
|
done(null, boundPort);
|
|
return hs;
|
|
})
|
|
.catch(done);
|
|
|
|
run._hs = hs;
|
|
});
|
|
|
|
let hs;
|
|
try {
|
|
const port = await readyPromise;
|
|
hs = run._hs;
|
|
// Verify the port is actually accepting connections before reporting it
|
|
await waitUntilPortAccepting(LOCAL_HOST, port, 10000, 200);
|
|
emit(JSON.stringify({ local_port: port, ready: true }));
|
|
} catch (err) {
|
|
const msg = (err && err.message) ? err.message : String(err);
|
|
try {
|
|
process.stderr.write('anisail-tunnel: ' + msg + '\n');
|
|
} catch (_) {}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const close = () => {
|
|
if (hs && typeof hs.close === 'function') {
|
|
try {
|
|
hs.close();
|
|
} catch (_) {}
|
|
hs = null;
|
|
}
|
|
process.exitCode = 0;
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGTERM', close);
|
|
process.on('SIGINT', close);
|
|
|
|
process.stdin.on('close', () => {
|
|
close();
|
|
});
|
|
process.stdin.on('end', () => {
|
|
close();
|
|
});
|
|
|
|
process.stdin.resume();
|
|
}
|
|
|
|
run().catch((err) => {
|
|
try {
|
|
process.stderr.write('anisail-tunnel: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
} catch (_) {}
|
|
process.exitCode = 1;
|
|
});
|