From f39e3fe01c82d3c4f2a86aa77db1a668eeb4ca11 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 14 Apr 2026 12:18:02 +0300 Subject: [PATCH] feat: add ServerAliveInterval and ServerAliveCountMax to SSH config Set ServerAliveInterval=10 and ServerAliveCountMax=3 as defaults for all Coder SSH connections. This detects dead connections within ~30 seconds, which helps keep sessions alive through NATs and firewalls. Also renames SSHValues to SshValues for consistency and extracts a shared BASE_SSH_VALUES test fixture to reduce boilerplate across tests. --- src/remote/remote.ts | 8 +- src/remote/sshConfig.ts | 8 +- test/unit/remote/sshConfig.test.ts | 175 +++++++++-------------------- 3 files changed, 62 insertions(+), 129 deletions(-) diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 30d2df37..fbd772b8 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -53,7 +53,7 @@ import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { SshConfig, - type SSHValues, + type SshValues, mergeSshConfigValues, parseCoderSshOptions, parseSshConfig, @@ -801,13 +801,15 @@ export class Remote { cliAuth, ); - const sshValues: SSHValues = { + const sshValues: SshValues = { Host: hostPrefix + `*`, ProxyCommand: proxyCommand, ConnectTimeout: "0", StrictHostKeyChecking: "no", UserKnownHostsFile: "/dev/null", LogLevel: "ERROR", + ServerAliveInterval: "10", + ServerAliveCountMax: "3", }; if (sshSupportsSetEnv()) { // This allows for tracking the number of extension @@ -824,7 +826,7 @@ export class Remote { hostName, sshConfig.getRaw(), ); - const keysToMatch: Array = [ + const keysToMatch: Array = [ "ProxyCommand", "UserKnownHostsFile", "StrictHostKeyChecking", diff --git a/src/remote/sshConfig.ts b/src/remote/sshConfig.ts index ee26eb25..ff7da8af 100644 --- a/src/remote/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -18,13 +18,15 @@ interface Block { raw: string; } -export interface SSHValues { +export interface SshValues { Host: string; ProxyCommand: string; ConnectTimeout: string; StrictHostKeyChecking: string; UserKnownHostsFile: string; LogLevel: string; + ServerAliveInterval: string; + ServerAliveCountMax: string; SetEnv?: string; } @@ -219,7 +221,7 @@ export class SshConfig { */ async update( safeHostname: string, - values: SSHValues, + values: SshValues, overrides?: Record, ) { const block = this.getBlock(safeHostname); @@ -298,7 +300,7 @@ export class SshConfig { */ private buildBlock( safeHostname: string, - values: SSHValues, + values: SshValues, overrides?: Record, ) { const { Host, ...otherValues } = values; diff --git a/test/unit/remote/sshConfig.test.ts b/test/unit/remote/sshConfig.test.ts index a460e4c6..19442aeb 100644 --- a/test/unit/remote/sshConfig.test.ts +++ b/test/unit/remote/sshConfig.test.ts @@ -5,6 +5,7 @@ import { parseCoderSshOptions, parseSshConfig, mergeSshConfigValues, + type SshValues, } from "@/remote/sshConfig"; import { createMockLogger } from "../../mocks/testHelpers"; @@ -29,6 +30,17 @@ const mockFileSystem = { const mockLogger = createMockLogger(); +const BASE_SSH_VALUES = { + Host: "coder-vscode.dev.coder.com--*", + ProxyCommand: "some-command-here", + ConnectTimeout: "0", + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + LogLevel: "ERROR", + ServerAliveInterval: "10", + ServerAliveCountMax: "3", +} as const satisfies SshValues; + afterEach(() => { vi.clearAllMocks(); }); @@ -39,14 +51,7 @@ it("creates a new file and adds config with empty label", async () => { const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update("", { - Host: "coder-vscode--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("", { ...BASE_SSH_VALUES, Host: "coder-vscode--*" }); const expectedOutput = `# --- START CODER VSCODE --- ${managedHeader} @@ -54,6 +59,8 @@ Host coder-vscode--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE ---`; @@ -82,14 +89,7 @@ it("creates a new file and adds the config", async () => { const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES); const expectedOutput = `# --- START CODER VSCODE dev.coder.com --- ${managedHeader} @@ -97,6 +97,8 @@ Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---`; @@ -132,14 +134,7 @@ it("adds a new coder config in an existent SSH configuration", async () => { const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES); const expectedOutput = `${existentSSHConfig} @@ -149,6 +144,8 @@ Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---`; @@ -204,12 +201,11 @@ Host * const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); await sshConfig.update("dev.coder.com", { + ...BASE_SSH_VALUES, Host: "coder-vscode.dev-updated.coder.com--*", ProxyCommand: "some-updated-command-here", ConnectTimeout: "1", StrictHostKeyChecking: "yes", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", }); const expectedOutput = `${keepSSHConfig} @@ -220,6 +216,8 @@ Host coder-vscode.dev-updated.coder.com--* ConnectTimeout 1 LogLevel ERROR ProxyCommand some-updated-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking yes UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com --- @@ -261,14 +259,7 @@ Host coder-vscode--* const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES); const expectedOutput = `${existentSSHConfig} @@ -278,6 +269,8 @@ Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---`; @@ -304,14 +297,7 @@ it("it does not remove a user-added block that only matches the host of an old c const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES); const expectedOutput = `Host coder-vscode--* ForwardAgent=yes @@ -322,6 +308,8 @@ Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---`; @@ -365,14 +353,7 @@ Host afterconfig // When we try to update the config, it should throw an error. await expect( - sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }), + sshConfig.update("dev.coder.com", BASE_SSH_VALUES), ).rejects.toThrow( `Malformed config: ${sshFilePath} has an unterminated START CODER VSCODE dev.coder.com block. Each START block must have an END block.`, ); @@ -420,14 +401,7 @@ Host afterconfig // When we try to update the config, it should throw an error. await expect( - sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }), + sshConfig.update("dev.coder.com", BASE_SSH_VALUES), ).rejects.toThrow( `Malformed config: ${sshFilePath} has an unterminated START CODER VSCODE dev.coder.com block. Each START block must have an END block.`, ); @@ -470,16 +444,7 @@ Host afterconfig await sshConfig.load(); // When we try to update the config, it should throw an error. - await expect( - sshConfig.update("", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }), - ).rejects.toThrow( + await expect(sshConfig.update("", BASE_SSH_VALUES)).rejects.toThrow( `Malformed config: ${sshFilePath} has an unterminated START CODER VSCODE block. Each START block must have an END block.`, ); }); @@ -521,14 +486,7 @@ Host afterconfig // When we try to update the config, it should throw an error. await expect( - sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }), + sshConfig.update("dev.coder.com", BASE_SSH_VALUES), ).rejects.toThrow( `Malformed config: ${sshFilePath} has 2 START CODER VSCODE dev.coder.com sections. Please remove all but one.`, ); @@ -593,6 +551,8 @@ Host coder-vscode.dev.coder.com--* ConnectTimeout 0 LogLevel ERROR ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 StrictHostKeyChecking no UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com --- @@ -601,14 +561,7 @@ Host afterconfig HostName after.config.tld User after`; - await sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES); expect(mockFileSystem.writeFile).toHaveBeenCalledWith( expect.stringContaining(sshTempFilePrefix), @@ -630,27 +583,16 @@ it("override values", async () => { const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); - await sshConfig.update( - "dev.coder.com", - { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }, - { - loglevel: "DEBUG", // This tests case insensitive - ConnectTimeout: "500", - ExtraKey: "ExtraValue", - Foo: "bar", - Buzz: "baz", - // Remove this key - StrictHostKeyChecking: "", - ExtraRemove: "", - }, - ); + await sshConfig.update("dev.coder.com", BASE_SSH_VALUES, { + loglevel: "DEBUG", // This tests case insensitive + ConnectTimeout: "500", + ExtraKey: "ExtraValue", + Foo: "bar", + Buzz: "baz", + // Remove this key + StrictHostKeyChecking: "", + ExtraRemove: "", + }); const expectedOutput = `# --- START CODER VSCODE dev.coder.com --- ${managedHeader} @@ -660,6 +602,8 @@ Host coder-vscode.dev.coder.com--* ExtraKey ExtraValue Foo bar ProxyCommand some-command-here + ServerAliveCountMax 3 + ServerAliveInterval 10 UserKnownHostsFile /dev/null loglevel DEBUG # --- END CODER VSCODE dev.coder.com ---`; @@ -699,14 +643,7 @@ it("fails if we are unable to write the temporary file", async () => { expect.anything(), ); await expect( - sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", - ProxyCommand: "some-command-here", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", - }), + sshConfig.update("dev.coder.com", BASE_SSH_VALUES), ).rejects.toThrow(/Failed to write temporary SSH config file.*EACCES/); }); @@ -722,12 +659,8 @@ it("cleans up temp file when rename fails", async () => { await sshConfig.load(); await expect( sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", + ...BASE_SSH_VALUES, ProxyCommand: "cmd", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", }), ).rejects.toThrow(/Failed to rename temporary SSH config file/); expect(mockFileSystem.unlink).toHaveBeenCalledWith( @@ -762,12 +695,8 @@ describe("rename retry on Windows", () => { const sshConfig = new SshConfig(sshFilePath, mockLogger, mockFileSystem); await sshConfig.load(); const promise = sshConfig.update("dev.coder.com", { - Host: "coder-vscode.dev.coder.com--*", + ...BASE_SSH_VALUES, ProxyCommand: "cmd", - ConnectTimeout: "0", - StrictHostKeyChecking: "no", - UserKnownHostsFile: "/dev/null", - LogLevel: "ERROR", }); await vi.advanceTimersByTimeAsync(100);