Files
Netcatty/domain/quickConnect.ts
陈大猫 5d29c8d91a
Some checks failed
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
fix: support IPv6 addresses in quick connect and fix display formatting (#472)
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +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;
}