Files
Netcatty/electron/puttyCommandLine.cjs
T
Cursor Agentand陈大猫 59ba1b3917 fix(cli): show the final server on PuTTY bastion tab titles
PuTTY jump launches kept the bastion as the TCP host and labeled the tab
with the opaque login string. Reuse the SecureCRT tabName path so an
embedded or leftover target address is the session title, without changing
where the session connects.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 11:30:48 +00:00

332 lines
9.7 KiB
JavaScript

const SSH_PROTOCOL = "ssh";
const TELNET_PROTOCOL = "telnet";
const PROTOCOL_FLAGS = new Map([
["-ssh", SSH_PROTOCOL],
["-telnet", TELNET_PROTOCOL],
]);
const UNSUPPORTED_PROTOCOL_FLAGS = new Set([
"-raw", "-rlogin", "-serial", "-ssh-connection", "-supdup",
]);
const VALUE_FLAGS = new Set([
"-P", "-p", "-l", "-pw", "-pwfile",
"-load", "-i", "-m", "-cmd",
"-L", "-R", "-D", "-nc",
"-hostkey", "-loghost",
"-sercfg", "-sessionlog", "-sshlog", "-sshrawlog", "-proxycmd",
"-newtab", "-title", "-url",
]);
const REJECT_VALUE_FLAGS = new Set(["-pwfile", "-i", "-hostkey", "-loghost"]);
const PORT_FLAGS = new Set(["-P", "-p"]);
const SKIP_FLAGS = new Set([
"-1", "-2", "-4", "-6", "-ipv4", "-ipv6",
"-A", "-a",
"-C",
"-N",
"-T", "-t",
"-X", "-x",
"-v",
"-agent", "-pagent", "-pageant",
"-noagent", "-nopagent", "-nopageant",
"-share", "-noshare",
"-batch",
"-nethack",
"-pgpfp",
"-cleanup",
"-help",
"-no-antispoof",
"-sanitise-stderr", "-sanitise-stdout",
"-no-sanitise-stderr", "-no-sanitise-stdout",
"-restrict-acl", "-restrict_acl", "-restrictacl",
]);
function parsePort(raw) {
if (typeof raw !== "string" || !raw.trim()) return null;
const port = Number(raw.trim());
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
return port;
}
function looksLikeIpv4(value) {
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value);
}
function looksLikeIpv6(value) {
return (value.match(/:/g) || []).length >= 2;
}
function isElectronExecutableName(arg) {
if (typeof arg !== "string") return false;
const base = arg.replace(/^.*[/\\]/, "").toLowerCase();
return base === "electron" || base === "electron.exe";
}
function isElectronNoiseArg(arg, index, argv) {
if (typeof arg !== "string") return true;
if (index === 0) return true;
if (arg === "." || arg === "--") return true;
if (arg.startsWith("--")) return true;
// Dev argv is [electron, main.cjs|app.asar, ...]; packaged argv is [Netcatty.exe, ...user args].
if (
index === 1
&& isElectronExecutableName(argv?.[0])
&& /\.(?:js|cjs|mjs|asar)$/i.test(arg)
&& !arg.includes("@")
) {
return true;
}
if ((arg.includes("/") || arg.includes("\\")) && !arg.includes("@")) return true;
return false;
}
function parseHostSpec(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("-")) return null;
let username;
let hostPart = trimmed;
const at = trimmed.lastIndexOf("@");
if (at >= 0) {
username = trimmed.slice(0, at).trim();
hostPart = trimmed.slice(at + 1).trim();
if (!username) username = undefined;
}
if (!hostPart) return null;
let hostname;
let port;
if (hostPart.startsWith("[")) {
const end = hostPart.indexOf("]");
if (end < 0) return null;
hostname = hostPart.slice(1, end).trim();
const rest = hostPart.slice(end + 1);
if (rest) {
if (!rest.startsWith(":")) return null;
port = parsePort(rest.slice(1));
if (port === null) return null;
}
} else if (!looksLikeIpv6(hostPart) && hostPart.includes(":")) {
const colon = hostPart.lastIndexOf(":");
hostname = hostPart.slice(0, colon).trim();
port = parsePort(hostPart.slice(colon + 1));
if (port === null) return null;
} else {
hostname = hostPart.replace(/^\[(.*)\]$/, "$1").trim();
}
if (!hostname) return null;
return {
...(username ? { username } : {}),
hostname,
...(port ? { port } : {}),
};
}
function hostCandidateScore(spec) {
if (!spec) return 0;
let score = 1;
if (spec.username) score += 4;
if (spec.port) score += 2;
if (looksLikeIpv4(spec.hostname) || looksLikeIpv6(spec.hostname)) score += 3;
else if (spec.hostname.includes(".")) score += 2;
return score;
}
function isDisplayIpv4(value) {
if (!looksLikeIpv4(value)) return false;
return value.split(".").every((part) => {
if (!/^\d{1,3}$/.test(part)) return false;
const octet = Number(part);
return octet <= 255;
});
}
function isDisplayIpv6(value) {
if (!looksLikeIpv6(value)) return false;
if (!/^[0-9a-f:]+$/i.test(value)) return false;
const compressed = value.match(/::/g);
return !compressed || compressed.length === 1;
}
// A leftover IP positional is the PuTTY analogue of SecureCRT /N: the TCP
// destination stays the higher-scored bastion login, and the other address
// is only the tab label. Multiple leftovers are ambiguous, so leave the
// default user@host label alone.
function positionalTargetDisplayHost(positionals, chosen) {
if (!chosen?.username) return undefined;
const leftovers = positionals.filter((spec) =>
spec !== chosen && spec.hostname !== chosen.hostname
&& (isDisplayIpv4(spec.hostname) || isDisplayIpv6(spec.hostname)));
return leftovers.length === 1 ? leftovers[0].hostname : undefined;
}
// 4A / jump launchers keep the TCP destination on the bastion and stash the
// final server in the login name (user@203.0.113.10@198.51.100.20, or
// user#203.0.113.10#ssh#22#root@bastion). SecureCRT already surfaces that
// server through /N as tabName. Reuse the same field so the PuTTY tab shows
// the target address instead of the opaque login string plus the bastion.
function embeddedTargetDisplayHost(username, connectionHostname) {
if (typeof username !== "string" || !/[@#/|]/.test(username)) return undefined;
let displayHost;
for (const segment of username.split(/[@#/|]/)) {
const host = parseHostSpec(segment.trim())?.hostname;
if (!host || host === connectionHostname) continue;
if (!isDisplayIpv4(host) && !isDisplayIpv6(host)) continue;
displayHost = host;
}
return displayHost;
}
function encodeUserInfo(username, password) {
if (username) {
const user = encodeURIComponent(username);
return password !== undefined ? `${user}:${encodeURIComponent(password)}` : user;
}
if (password !== undefined) return `:${encodeURIComponent(password)}`;
return "";
}
function formatHostnameForUrl(hostname) {
return hostname.includes(":") ? `[${hostname}]` : hostname;
}
function toDeepLinkUrl({ protocol, username, password, hostname, port }) {
const auth = encodeUserInfo(username, password);
const host = formatHostnameForUrl(hostname);
const portPart = port ? `:${port}` : "";
return `${protocol}://${auth ? `${auth}@` : ""}${host}${portPart}`;
}
function hasPuttyLaunchSignal(argv) {
if (!Array.isArray(argv)) return false;
return argv.some((arg) => {
if (typeof arg !== "string") return false;
return PROTOCOL_FLAGS.has(arg) || arg === "-pw" || arg === "-pwfile" || arg === "-l" || PORT_FLAGS.has(arg);
});
}
function parsePuttyCommandLine(argv) {
if (!Array.isArray(argv) || !hasPuttyLaunchSignal(argv)) return null;
let protocol = SSH_PROTOCOL;
let username;
let password;
let port;
let tabName;
const positionals = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (typeof arg !== "string" || !arg) continue;
if (isElectronNoiseArg(arg, index, argv)) continue;
const protocolFromFlag = PROTOCOL_FLAGS.get(arg);
if (protocolFromFlag) {
protocol = protocolFromFlag;
continue;
}
if (UNSUPPORTED_PROTOCOL_FLAGS.has(arg)) return null;
if (SKIP_FLAGS.has(arg)) continue;
if (VALUE_FLAGS.has(arg)) {
const value = argv[index + 1];
if (typeof value !== "string") return null;
index += 1;
if (REJECT_VALUE_FLAGS.has(arg)) return null;
if (PORT_FLAGS.has(arg)) {
const parsedPort = parsePort(value);
if (parsedPort === null) return null;
port = parsedPort;
continue;
}
if (arg === "-l") {
const nextUser = value.trim();
if (!nextUser) return null;
username = nextUser;
continue;
}
if (arg === "-pw") {
if (value === "") return null;
password = value;
continue;
}
if (arg === "-newtab") {
tabName = value.trim();
}
if (!value.trim()) return null;
continue;
}
if (arg.startsWith("-")) return null;
const positionalPort = parsePort(arg);
if (positionalPort !== null && /^\d+$/.test(arg.trim())) {
if (port === undefined) port = positionalPort;
continue;
}
const spec = parseHostSpec(arg);
if (spec) {
positionals.push(spec);
}
}
if (positionals.length === 0) return null;
const hostSpec = positionals.reduce((best, candidate) => (
hostCandidateScore(candidate) > hostCandidateScore(best) ? candidate : best
));
const resolvedUsername = (username || hostSpec.username || "").trim() || undefined;
const resolvedPort = port ?? hostSpec.port;
const hostname = hostSpec.hostname;
if (!hostname) return null;
const resolvedTabName = tabName
|| positionalTargetDisplayHost(positionals, hostSpec)
|| embeddedTargetDisplayHost(resolvedUsername, hostname);
const url = toDeepLinkUrl({
protocol,
username: resolvedUsername,
password,
hostname,
port: resolvedPort,
});
return {
protocol,
url,
hostname,
...(resolvedUsername ? { username: resolvedUsername } : {}),
...(password !== undefined ? { password } : {}),
...(resolvedPort ? { port: resolvedPort } : {}),
...(resolvedTabName ? { tabName: resolvedTabName } : {}),
};
}
function redactPuttyCommandLinePasswords(argv) {
if (!Array.isArray(argv)) return argv;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] !== "-pw") continue;
const next = argv[index + 1];
if (typeof next !== "string") continue;
argv[index + 1] = "*".repeat(Math.min(next.length, 8)) || "********";
}
return argv;
}
module.exports = {
PUTTY_VALUE_FLAGS: VALUE_FLAGS,
parseHostSpec,
isElectronNoiseArg,
parsePort,
parsePuttyCommandLine,
redactPuttyCommandLinePasswords,
toDeepLinkUrl,
};