Files
NetMesh/domain/quickConnect.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

305 lines
7.8 KiB
TypeScript

export interface QuickConnectTarget {
hostname: string;
username?: string;
port?: number;
}
interface QuickConnectParseResult {
target: QuickConnectTarget | null;
warnings: string[];
}
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
* Must have only hex digits and colons, with either:
* - A "::" shorthand (unambiguously IPv6), or
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
const isBareIPv6 = (s: string): boolean => {
if (!BARE_IPV6_RE.test(s)) return false;
if (s.includes('::')) return true;
return (s.match(/:/g) || []).length === 7;
};
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
// Pattern: [user@]hostname[:port]
// Hostname can be IP (v4 or v6 in brackets) or domain name
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
const match = trimmed.match(regex);
// If the main regex fails, try bare IPv6: [user@]ipv6_address
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
// Port must be specified via brackets: [ipv6]:port
if (!match) {
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
const bareMatch = trimmed.match(bareIpv6Regex);
if (bareMatch) {
const [, bareUser, bareHost] = bareMatch;
if (isBareIPv6(bareHost)) {
return {
hostname: bareHost,
username: bareUser || undefined,
port: undefined,
};
}
}
return null;
}
const [, username, hostname, portStr] = match;
// Validate hostname looks like an IP or domain
const ipv4Regex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
const ipv6Regex = /^\[?[a-fA-F0-9:]+\]?$/;
const domainRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
if (
!ipv4Regex.test(hostname) &&
!ipv6Regex.test(hostname) &&
!domainRegex.test(hostname)
) {
return null;
}
const port = portStr ? parseInt(portStr, 10) : undefined;
if (port !== undefined && (isNaN(port) || port < 1 || port > 65535)) {
return null;
}
return {
hostname: hostname.replace(/^\[|\]$/g, ""), // Remove IPv6 brackets
username: username || undefined,
port,
};
};
const sshArgOptions = new Set([
"-b",
"-c",
"-D",
"-E",
"-F",
"-i",
"-I",
"-J",
"-L",
"-m",
"-O",
"-P",
"-R",
"-S",
"-W",
"-w",
]);
const parseSshOption = (
raw: string,
nextToken?: string,
): { key: string; value: string; consumedNext: boolean } | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split("=");
if (parts.length >= 2) {
const key = parts[0]?.trim();
const value = parts.slice(1).join("=").trim();
if (key && value) {
return { key, value, consumedNext: false };
}
}
if (nextToken && !nextToken.startsWith("-")) {
return { key: trimmed, value: nextToken, consumedNext: true };
}
return null;
};
const parseSshCommand = (input: string): QuickConnectParseResult | null => {
const trimmed = input.trim();
if (!/^ssh(\s|$)/i.test(trimmed)) return null;
const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) return null;
const warnings: string[] = [];
let username: string | undefined;
let optionUsername: string | undefined;
let port: number | undefined;
let optionPort: number | undefined;
let portInvalid = false;
let optionHostname: string | undefined;
let hostToken: string | undefined;
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (token === "-p") {
const value = tokens[i + 1];
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
i++;
}
continue;
}
if (token.startsWith("-p") && token.length > 2) {
const value = token.replace(/^-p=?/, "");
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
}
continue;
}
if (token === "-l") {
const value = tokens[i + 1];
if (value) {
username = value;
i++;
}
continue;
}
if (token.startsWith("-l") && token.length > 2) {
const value = token.replace(/^-l=?/, "");
if (value) username = value;
continue;
}
if (token === "-o") {
const optionToken = tokens[i + 1];
if (optionToken) {
const nextToken = tokens[i + 2];
const parsed = parseSshOption(optionToken, nextToken);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
i += parsed.consumedNext ? 2 : 1;
continue;
}
warnings.push("-o");
i++;
}
continue;
}
if (token.startsWith("-o") && token.length > 2) {
const parsed = parseSshOption(token.slice(2), tokens[i + 1]);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
if (parsed.consumedNext) i++;
continue;
}
warnings.push("-o");
}
if (sshArgOptions.has(token)) {
warnings.push(token);
const next = tokens[i + 1];
if (next) i++;
continue;
}
if (token.startsWith("-")) {
warnings.push(token);
continue;
}
if (!hostToken) {
hostToken = token;
} else {
warnings.push(token);
}
}
if (!hostToken) return null;
const base = optionHostname
? parseDirectTarget(optionHostname)
: parseDirectTarget(hostToken);
if (!base) return null;
if (portInvalid) return null;
const resolvedPort =
port !== undefined && !Number.isNaN(port)
? port
: optionPort !== undefined && !Number.isNaN(optionPort)
? optionPort
: base.port;
if (
resolvedPort !== undefined &&
(Number.isNaN(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535)
) {
return null;
}
return {
target: {
hostname: base.hostname,
username: optionUsername || username || base.username,
port: resolvedPort,
},
warnings: Array.from(new Set(warnings)),
};
};
// Parse user@host:port or ssh command formats with warning details
export function parseQuickConnectInputWithWarnings(
input: string,
): QuickConnectParseResult {
const trimmed = input.trim();
if (!trimmed) return { target: null, warnings: [] };
const sshTarget = parseSshCommand(trimmed);
if (sshTarget) return sshTarget;
return { target: parseDirectTarget(trimmed), warnings: [] };
}
// Parse user@host:port or ssh command formats
export function parseQuickConnectInput(
input: string,
): QuickConnectTarget | null {
return parseQuickConnectInputWithWarnings(input).target;
}
// Check if input looks like a quick connect address
export function isQuickConnectInput(input: string): boolean {
return parseQuickConnectInput(input) !== null;
}