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 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>
305 lines
7.8 KiB
TypeScript
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;
|
|
}
|