Retry transient terminal DNS lookups (#1011)

* Retry transient terminal DNS lookups

* Apply DNS retry to SSH entry points
This commit is contained in:
ZacharyZcR
2026-07-10 08:03:19 +08:00
committed by GitHub
parent 6b98b86969
commit 4f05bd4fbd
9 changed files with 270 additions and 4 deletions
+9
View File
@@ -14,6 +14,7 @@ import {
getContainerRuntimeConfig, getContainerRuntimeConfig,
type ContainerRuntime, type ContainerRuntime,
} from "./container-runtime.js"; } from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
const sshLogger = systemLogger; const sshLogger = systemLogger;
@@ -276,6 +277,10 @@ async function createJumpHostChain(
}); });
} }
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve()); client.on("ready", () => resolve());
client.on("error", reject); client.on("error", reject);
@@ -489,6 +494,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
} }
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve()); client.on("ready", () => resolve());
client.on("error", reject); client.on("error", reject);
+2
View File
@@ -28,6 +28,7 @@ import {
getRuntimeLabel, getRuntimeLabel,
type ContainerRuntime, type ContainerRuntime,
} from "./container-runtime.js"; } from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
const sshLogger = logger; const sshLogger = logger;
@@ -1617,6 +1618,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
return; return;
} }
} else { } else {
await resolveSshConnectConfigHost(config);
client.connect(config); client.connect(config);
} }
} catch (error) { } catch (error) {
+3
View File
@@ -43,6 +43,7 @@ import {
} from "./file-manager-session.js"; } from "./file-manager-session.js";
import { registerFileListingRoutes } from "./file-manager-list-routes.js"; import { registerFileListingRoutes } from "./file-manager-list-routes.js";
import { registerFileOperationRoutes } from "./file-manager-operation-routes.js"; import { registerFileOperationRoutes } from "./file-manager-operation-routes.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { registerFileDownloadRoutes } from "./file-manager-download-routes.js"; import { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
import { registerFileActionRoutes } from "./file-manager-action-routes.js"; import { registerFileActionRoutes } from "./file-manager-action-routes.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js";
@@ -194,6 +195,7 @@ async function buildDedicatedTransferConnectConfig(
compress: ["none", "zlib@openssh.com", "zlib"], compress: ["none", "zlib@openssh.com", "zlib"],
}, },
}; };
await resolveSshConnectConfigHost(config);
const authType = host.authType; const authType = host.authType;
@@ -1759,6 +1761,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}); });
} }
} else { } else {
await resolveSshConnectConfigHost(config);
client.connect(config); client.connect(config);
} }
}); });
+23 -2
View File
@@ -41,6 +41,7 @@ import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js"; import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
import { AlertEngine } from "./alert-engine.js"; import { AlertEngine } from "./alert-engine.js";
import { registerManagerRoutes } from "./managers/index.js"; import { registerManagerRoutes } from "./managers/index.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { AccessDeniedError } from "./managers/route-helpers.js"; import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js"; import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "./host-metrics-jump-hosts.js"; import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
@@ -1342,8 +1343,18 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
client.connect(config); client.connect(config);
}, },
); );
} else { } else if (config.sock) {
client.connect(config); client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
return;
} }
}); });
}; };
@@ -2372,7 +2383,17 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
} }
}); });
} else { } else {
client.connect(config); resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
reject(error);
}
});
} }
}); });
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import {
isRetriableDnsError,
resolveHostForSshConnect,
resolveSshConnectConfigHost,
shouldResolveBeforeSshConnect,
} from "./ssh-dns.js";
describe("SSH DNS resolution", () => {
it("retries transient EAI_AGAIN errors before returning an address", async () => {
const lookup = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("try again"), { code: "EAI_AGAIN" }),
)
.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 });
const wait = vi.fn().mockResolvedValue(undefined);
await expect(
resolveHostForSshConnect("alp", lookup, [10], wait),
).resolves.toEqual({
host: "10.0.0.5",
resolvedAddress: "10.0.0.5",
attempts: 2,
});
expect(wait).toHaveBeenCalledWith(10);
});
it("does not retry permanent DNS failures", async () => {
const error = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
const lookup = vi.fn().mockRejectedValue(error);
const wait = vi.fn().mockResolvedValue(undefined);
await expect(
resolveHostForSshConnect("missing", lookup, [10], wait),
).rejects.toBe(error);
expect(wait).not.toHaveBeenCalled();
});
it("skips DNS lookup for literal IP addresses", async () => {
const lookup = vi.fn();
await expect(
resolveHostForSshConnect("192.0.2.1", lookup),
).resolves.toEqual({
host: "192.0.2.1",
attempts: 0,
});
expect(lookup).not.toHaveBeenCalled();
});
it("detects retryable DNS errors by code or message", () => {
expect(isRetriableDnsError({ code: "EAI_AGAIN" })).toBe(true);
expect(isRetriableDnsError(new Error("getaddrinfo EAI_AGAIN alp"))).toBe(
true,
);
expect(isRetriableDnsError({ code: "ENOTFOUND" })).toBe(false);
});
it("only pre-resolves hostnames", () => {
expect(shouldResolveBeforeSshConnect("alp")).toBe(true);
expect(shouldResolveBeforeSshConnect("127.0.0.1")).toBe(false);
expect(shouldResolveBeforeSshConnect("[2001:db8::1]")).toBe(false);
});
it("updates SSH connect config hosts in place", async () => {
const lookup = vi
.fn()
.mockResolvedValue({ address: "10.0.0.6", family: 4 });
const config = { host: "alp", port: 22 };
await expect(resolveSshConnectConfigHost(config, lookup)).resolves.toEqual({
host: "10.0.0.6",
port: 22,
originalHost: "alp",
resolvedHost: "10.0.0.6",
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import dns from "dns/promises";
import net from "net";
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
type Lookup = typeof dns.lookup;
type Sleep = (ms: number) => Promise<void>;
type SshConnectConfigHost = {
host?: unknown;
};
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function isRetriableDnsError(error: unknown): boolean {
const err = error as { code?: unknown; message?: unknown };
return (
err.code === "EAI_AGAIN" ||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
);
}
export function shouldResolveBeforeSshConnect(host: string): boolean {
const normalized = host.replace(/^\[|\]$/g, "").trim();
if (!normalized) return false;
return net.isIP(normalized) === 0;
}
export async function resolveHostForSshConnect(
host: string,
lookup: Lookup = dns.lookup,
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
wait: Sleep = sleep,
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
const normalized = host.replace(/^\[|\]$/g, "").trim();
if (!shouldResolveBeforeSshConnect(normalized)) {
return { host: normalized || host, attempts: 0 };
}
for (let attempt = 0; ; attempt += 1) {
try {
const result = await lookup(normalized);
return {
host: result.address,
resolvedAddress: result.address,
attempts: attempt + 1,
};
} catch (error) {
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
throw error;
}
await wait(retryDelaysMs[attempt]);
}
}
}
export async function resolveSshConnectConfigHost<
T extends SshConnectConfigHost,
>(
config: T,
lookup: Lookup = dns.lookup,
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
wait: Sleep = sleep,
): Promise<
T & { host?: unknown; resolvedHost?: string; originalHost?: string }
> {
if (typeof config.host !== "string") return config;
const originalHost = config.host;
const resolution = await resolveHostForSshConnect(
originalHost,
lookup,
retryDelaysMs,
wait,
);
if (!resolution.resolvedAddress) return config;
config.host = resolution.host;
return Object.assign(config, {
originalHost,
resolvedHost: resolution.resolvedAddress,
});
}
+35 -1
View File
@@ -37,6 +37,7 @@ import {
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { triggerLoginAlert } from "../utils/alert-trigger.js"; import { triggerLoginAlert } from "../utils/alert-trigger.js";
import { isRetriableDnsError, resolveHostForSshConnect } from "./ssh-dns.js";
interface ConnectToHostData { interface ConnectToHostData {
cols: number; cols: number;
@@ -1282,6 +1283,39 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
sendLog("dns", "info", `Starting address resolution of ${ip}`); sendLog("dns", "info", `Starting address resolution of ${ip}`);
let connectHost = ip;
try {
const resolution = await resolveHostForSshConnect(ip);
connectHost = resolution.host;
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
sendLog(
"dns",
"success",
`Resolved ${ip} to ${resolution.resolvedAddress}`,
{ attempts: resolution.attempts },
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
sshLogger.error("SSH hostname resolution failed", error, {
operation: "terminal_dns_resolve",
hostId: id,
ip,
port,
transient: isRetriableDnsError(error),
});
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
ws.send(
JSON.stringify({
type: "error",
message: isRetriableDnsError(error)
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
: "SSH error: Could not resolve hostname from the Termix server container.",
}),
);
cleanupAuthState(connectionTimeout);
return;
}
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
sshConn.on("ready", () => { sshConn.on("ready", () => {
@@ -2320,7 +2354,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id); const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
const connectConfig: Record<string, unknown> = { const connectConfig: Record<string, unknown> = {
host: ip, host: connectHost,
port, port,
username, username,
tryKeyboard: resolvedCredentials.authType !== "tailscale", tryKeyboard: resolvedCredentials.authType !== "tailscale",
+11 -1
View File
@@ -20,6 +20,7 @@ import {
type SOCKS5Config, type SOCKS5Config,
} from "../utils/socks5-helper.js"; } from "../utils/socks5-helper.js";
import { withConnection } from "./ssh-connection-pool.js"; import { withConnection } from "./ssh-connection-pool.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js"; import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
import { import {
SEP, SEP,
@@ -209,8 +210,17 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
client.connect(config); client.connect(config);
}, },
); );
} else { } else if (config.sock) {
client.connect(config); client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
} }
}); });
}; };
+26
View File
@@ -61,6 +61,7 @@ import {
handleC2SRelayTest, handleC2SRelayTest,
type C2SOpenMessage, type C2SOpenMessage,
} from "./tunnel-c2s-relay.js"; } from "./tunnel-c2s-relay.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { handleSocks5Connect } from "./tunnel-socks5-relay.js"; import { handleSocks5Connect } from "./tunnel-socks5-relay.js";
const app = express(); const app = express();
@@ -1507,6 +1508,27 @@ async function connectSSHTunnel(
} }
} }
try {
await resolveSshConnectConfigHost(connOptions);
} catch (error) {
tunnelLogger.error("Tunnel source hostname resolution failed", error, {
operation: "tunnel_dns_resolve",
tunnelName,
sourceHost: `${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
retryAttempt,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason:
error instanceof Error
? error.message
: "Failed to resolve tunnel source hostname",
});
tunnelConnecting.delete(tunnelName);
return;
}
conn.connect(connOptions); conn.connect(connOptions);
} }
@@ -1693,6 +1715,10 @@ async function killRemoteTunnelByMarker(
} }
} }
if (!connOptions.sock) {
await resolveSshConnectConfigHost(connOptions);
}
return new Promise<Client>((resolve, reject) => { return new Promise<Client>((resolve, reject) => {
const conn = new Client(); const conn = new Client();
conn.on("ready", () => resolve(conn)); conn.on("ready", () => resolve(conn));