From 43942c9700c7f63a5b00071e8d9243594181d55a Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:04:28 +0100 Subject: [PATCH 01/29] Improve remote port check errors --- internal/ssh/ssh_tunnel.go | 46 ++++++++++++++++++++++----------- internal/ssh/ssh_tunnel_test.go | 23 ++++++++++------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 9416dfec..d8b830e4 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -2,12 +2,16 @@ package ssh import ( "crypto/sha256" + "errors" "fmt" "io" + "net" "os" "os/exec" "path/filepath" "runtime" + "syscall" + "time" "github.com/arm/topo/internal/command" "github.com/arm/topo/internal/operation" @@ -107,26 +111,38 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { } host := NewConfig(ct.TargetDest).HostName - if RemotePortRefusedConnection(host, ct.Port) { - _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) - return nil + if err := CheckRemotePortNotListening(host, ct.Port); err != nil { + return err } - return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", ct.Port) + _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) + return nil } -func RemotePortRefusedConnection(host, port string) bool { - cmd := exec.Command("curl", fmt.Sprintf("%s:%s", host, port), "--max-time", "5") - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - _ = cmd.Run() +func CheckRemotePortNotListening(host, port string) error { + address := net.JoinHostPort(host, port) + connection, err := net.DialTimeout("tcp", address, 5*time.Second) + if err == nil { + if closeErr := connection.Close(); closeErr != nil { + return fmt.Errorf("remote port %s accepted a TCP connection, but closing the connection failed: %w", address, closeErr) + } + return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port) + } - // Only curl exit 7 ("Failed to connect to host") proves no TCP listener - // answered. Anything else — connection succeeded, DNS failure, timeout, - // curl missing — leaves us unable to certify the port is not not listening. - if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 7 { - return true + if errors.Is(err, syscall.ECONNREFUSED) { + return nil } - return false + + var dnsError *net.DNSError + if errors.As(err, &dnsError) { + return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) + } + + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) + } + + return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) } type SSHTunnelStop struct { diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index f79a672e..ff58952f 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -108,28 +108,31 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { }) } -func TestRemotePortRefusedConnection(t *testing.T) { - t.Run("it returns true when nothing answers on the remote port", func(t *testing.T) { +func TestCheckRemotePortNotListening(t *testing.T) { + t.Run("it succeeds when nothing answers on the remote port", func(t *testing.T) { port := reserveFreePort(t) - assert.True(t, ssh.RemotePortRefusedConnection("127.0.0.1", port)) + err := ssh.CheckRemotePortNotListening("127.0.0.1", port) + + assert.NoError(t, err) }) - t.Run("it returns false when a TCP listener is bound on the remote port", func(t *testing.T) { + t.Run("it reports an exposed port when a TCP listener accepts the connection", func(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) t.Cleanup(func() { _ = listener.Close() }) _, port, err := net.SplitHostPort(listener.Addr().String()) require.NoError(t, err) - assert.False(t, ssh.RemotePortRefusedConnection("127.0.0.1", port)) + err = ssh.CheckRemotePortNotListening("127.0.0.1", port) + + assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) }) - t.Run("it returns false when curl exits with any other non-7 error", func(t *testing.T) { - // `.invalid` is reserved by RFC 6761 to never resolve, so curl - // exits 6 ("Couldn't resolve host") rather than 7. We can't - // certify the tunnel is safe, so we must fail. - assert.False(t, ssh.RemotePortRefusedConnection("nonexistent.invalid", "12345")) + t.Run("it reports a resolution error when the remote host does not resolve", func(t *testing.T) { + err := ssh.CheckRemotePortNotListening("nonexistent.invalid", "12345") + + assert.ErrorContains(t, err, `could not resolve remote host "nonexistent.invalid" while checking tunnel exposure`) }) } From ed9d1657033886dcda6471efe75a14e141d1f621 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:17:52 +0100 Subject: [PATCH 02/29] Add option to skip remote port check --- cmd/topo/deploy.go | 17 ++++++++++------- internal/deploy/deploy.go | 9 ++++++--- internal/deploy/deploy_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index 237502dd..dec6793c 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -18,11 +18,12 @@ import ( ) var ( - noRegistry bool - registryPort string - skipProjectChecks bool - forceRecreate bool - noRecreate bool + noRegistry bool + registryPort string + skipRemotePortCheck bool + skipProjectChecks bool + forceRecreate bool + noRecreate bool ) var deployOpts deploy.DeployOptions @@ -78,8 +79,9 @@ The compose file (compose.yaml) must be in the current working directory, as thi goos := runtime.GOOS if deploy.SupportsRegistry(noRegistry, deployOpts.TargetHost) { deployOpts.Registry = &deploy.RegistryConfig{ - Port: resolvedPort, - UseControlSockets: deploy.SupportsSSHControlSockets(goos), + Port: resolvedPort, + SkipRemotePortCheck: skipRemotePortCheck, + UseControlSockets: deploy.SupportsSSHControlSockets(goos), } } switch { @@ -143,6 +145,7 @@ func init() { addTargetFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) deployCmd.Flags().BoolVar(&noRegistry, "no-registry", false, "disable private registry flow; use direct save/load transfer") + deployCmd.Flags().BoolVar(&skipRemotePortCheck, "skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") deployCmd.Flags().BoolVar(&forceRecreate, "force-recreate", false, "force recreation of containers even if their configuration and image haven't changed") deployCmd.Flags().BoolVar(&noRecreate, "no-recreate", false, "prevent recreation of containers even if their configuration and image have changed") deployCmd.Flags().BoolVar(&skipProjectChecks, "skip-project-checks", false, "skip project compatibility checks for the target platform") diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index ad10c5fa..022e70bd 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -9,8 +9,9 @@ import ( ) type RegistryConfig struct { - Port string - UseControlSockets bool + Port string + SkipRemotePortCheck bool + UseControlSockets bool } type DeployOptions struct { @@ -46,7 +47,9 @@ func NewDeployment(composeFile string, opts DeployOptions) (goperation.Sequence, cleanup = stop ops = append(ops, operation.NewRunRegistry(opts.Registry.Port)...) ops = append(ops, start) - ops = append(ops, securityCheck) + if !opts.Registry.SkipRemotePortCheck { + ops = append(ops, securityCheck) + } ops = append(ops, operation.NewRegistryTransfer(composeFile, sourceHost, targetHost, opts.Registry.Port)) ops = append(ops, stop) } else { diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index 72617e1f..d082b838 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -148,6 +148,38 @@ func TestNewDeployment(t *testing.T) { ) assert.Equal(t, want, got) }) + + t.Run("excludes the remote port check when skipped", func(t *testing.T) { + remoteDest := ssh.NewDestination("user@remote") + port := operation.DefaultRegistryPort + opts := deploy.DeployOptions{ + TargetHost: remoteDest, + Registry: &deploy.RegistryConfig{ + Port: port, + SkipRemotePortCheck: true, + UseControlSockets: true, + }, + } + + got, _ := deploy.NewDeployment(composeFile, opts) + + wantTunnelStart, _, wantTunnelStop := ssh.NewSSHTunnel(remoteDest, port, opts.Registry.UseControlSockets) + localHost := command.LocalHost + remoteHost := command.NewHostFromDestination(remoteDest) + want := goperation.Sequence{ + operation.NewDockerComposeBuild(composeFile, localHost), + operation.NewDockerComposePull(composeFile, localHost), + } + want = append(want, operation.NewRunRegistry(port)...) + want = append(want, + wantTunnelStart, + operation.NewRegistryTransfer(composeFile, localHost, remoteHost, port), + wantTunnelStop, + operation.NewDockerComposeUp(composeFile, remoteHost, operation.RecreateModeDefault), + post_deploy.NewDeploySuccess(composeFile, remoteHost, "Run `topo ps` to see deployed containers"), + ) + assert.Equal(t, want, got) + }) } func TestDeployment(t *testing.T) { From 17ad9ae17e271ea6867f2562ab1f0aaf004550f8 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:21:41 +0100 Subject: [PATCH 03/29] Fail closed when SSH hostname is unavailable --- internal/ssh/config.go | 13 +++++++++++++ internal/ssh/ssh_tunnel.go | 5 ++++- internal/ssh/ssh_tunnel_test.go | 11 +++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/ssh/config.go b/internal/ssh/config.go index b689f8b9..af843337 100644 --- a/internal/ssh/config.go +++ b/internal/ssh/config.go @@ -22,6 +22,19 @@ func NewConfig(dest Destination) Config { return NewConfigFromBytes(output) } +func ResolveHostName(dest Destination) (string, error) { + output, err := readConfig(dest) + if err != nil { + return "", fmt.Errorf("could not resolve SSH hostname for %q: %w", dest.String(), err) + } + + hostName := NewConfigFromBytes(output).HostName + if hostName == "" { + return "", fmt.Errorf("could not resolve SSH hostname for %q: SSH configuration did not provide a hostname", dest.String()) + } + return hostName, nil +} + func NewConfigFromBytes(data []byte) Config { var config Config scanner := bufio.NewScanner(bytes.NewReader(data)) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index d8b830e4..30bd5c01 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -110,7 +110,10 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { return nil } - host := NewConfig(ct.TargetDest).HostName + host, err := ResolveHostName(ct.TargetDest) + if err != nil { + return err + } if err := CheckRemotePortNotListening(host, ct.Port); err != nil { return err } diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index ff58952f..41e4b523 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -2,6 +2,7 @@ package ssh_test import ( "fmt" + "io" "net" "os" "strings" @@ -106,6 +107,16 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { assert.Equal(t, "Check tunnel port is not exposed on remote network", got) }) }) + + t.Run("Run", func(t *testing.T) { + t.Run("it fails when the SSH hostname cannot be resolved", func(t *testing.T) { + check := ssh.NewCheckRemoteForwardNotExposed(ssh.Destination{}, "12345") + + err := check.Run(io.Discard) + + assert.ErrorContains(t, err, `could not resolve SSH hostname for "ssh://"`) + }) + }) } func TestCheckRemotePortNotListening(t *testing.T) { From 7ef279d574366bbb485fcb7e456d3249bde48cb2 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:25:23 +0100 Subject: [PATCH 04/29] Classify remote port timeouts first --- internal/ssh/ssh_tunnel.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 30bd5c01..afa498cf 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -135,16 +135,16 @@ func CheckRemotePortNotListening(host, port string) error { return nil } - var dnsError *net.DNSError - if errors.As(err, &dnsError) { - return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) - } - var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) } + var dnsError *net.DNSError + if errors.As(err, &dnsError) { + return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) + } + return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) } From af205746ccaafb9f17beda7ae9cecef906fe1d98 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:26:47 +0100 Subject: [PATCH 05/29] Test remote port error classification --- internal/ssh/ssh_tunnel.go | 6 +++++ internal/ssh/ssh_tunnel_internal_test.go | 33 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 internal/ssh/ssh_tunnel_internal_test.go diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index afa498cf..a8eb9148 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -130,7 +130,13 @@ func CheckRemotePortNotListening(host, port string) error { } return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port) } + return classifyRemotePortError(host, address, err) +} +func classifyRemotePortError(host, address string, err error) error { + if err == nil { + panic("classifyRemotePortError requires a non-nil error") + } if errors.Is(err, syscall.ECONNREFUSED) { return nil } diff --git a/internal/ssh/ssh_tunnel_internal_test.go b/internal/ssh/ssh_tunnel_internal_test.go new file mode 100644 index 00000000..cab956c2 --- /dev/null +++ b/internal/ssh/ssh_tunnel_internal_test.go @@ -0,0 +1,33 @@ +package ssh + +import ( + "errors" + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClassifyRemotePortError(t *testing.T) { + t.Run("returns a timeout-specific error for DNS timeouts", func(t *testing.T) { + dialError := &net.DNSError{ + Err: "operation timed out", + Name: "remote.example", + IsTimeout: true, + } + + err := classifyRemotePortError("remote.example", "remote.example:12345", dialError) + + assert.ErrorContains(t, err, "timed out while checking whether remote port remote.example:12345 is exposed") + assert.ErrorIs(t, err, dialError) + }) + + t.Run("returns a fallback error for an unknown network failure", func(t *testing.T) { + dialError := errors.New("unexpected network failure") + + err := classifyRemotePortError("remote.example", "remote.example:12345", dialError) + + assert.ErrorContains(t, err, "could not verify whether remote port remote.example:12345 is exposed") + assert.ErrorIs(t, err, dialError) + }) +} From 10032004bbce11f8d56f3072100c04844c41e2b8 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:29:38 +0100 Subject: [PATCH 06/29] Test remote port skip flag wiring --- cmd/topo/deploy.go | 31 ++++++++++++++++++------------ cmd/topo/deploy_internal_test.go | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 cmd/topo/deploy_internal_test.go diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index dec6793c..b9b3166c 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -18,12 +18,11 @@ import ( ) var ( - noRegistry bool - registryPort string - skipRemotePortCheck bool - skipProjectChecks bool - forceRecreate bool - noRecreate bool + noRegistry bool + registryPort string + skipProjectChecks bool + forceRecreate bool + noRecreate bool ) var deployOpts deploy.DeployOptions @@ -78,11 +77,7 @@ The compose file (compose.yaml) must be in the current working directory, as thi goos := runtime.GOOS if deploy.SupportsRegistry(noRegistry, deployOpts.TargetHost) { - deployOpts.Registry = &deploy.RegistryConfig{ - Port: resolvedPort, - SkipRemotePortCheck: skipRemotePortCheck, - UseControlSockets: deploy.SupportsSSHControlSockets(goos), - } + deployOpts.Registry = newRegistryConfig(cmd, resolvedPort, goos) } switch { case forceRecreate: @@ -141,11 +136,23 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { return flagValue, nil } +func newRegistryConfig(cmd *cobra.Command, port, goos string) *deploy.RegistryConfig { + skipRemotePortCheck, err := cmd.Flags().GetBool("skip-remote-port-check") + if err != nil { + panic("internal error: skip-remote-port-check flag not registered: " + err.Error()) + } + return &deploy.RegistryConfig{ + Port: port, + SkipRemotePortCheck: skipRemotePortCheck, + UseControlSockets: deploy.SupportsSSHControlSockets(goos), + } +} + func init() { addTargetFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) deployCmd.Flags().BoolVar(&noRegistry, "no-registry", false, "disable private registry flow; use direct save/load transfer") - deployCmd.Flags().BoolVar(&skipRemotePortCheck, "skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") + deployCmd.Flags().Bool("skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") deployCmd.Flags().BoolVar(&forceRecreate, "force-recreate", false, "force recreation of containers even if their configuration and image haven't changed") deployCmd.Flags().BoolVar(&noRecreate, "no-recreate", false, "prevent recreation of containers even if their configuration and image have changed") deployCmd.Flags().BoolVar(&skipProjectChecks, "skip-project-checks", false, "skip project compatibility checks for the target platform") diff --git a/cmd/topo/deploy_internal_test.go b/cmd/topo/deploy_internal_test.go new file mode 100644 index 00000000..9ca973b5 --- /dev/null +++ b/cmd/topo/deploy_internal_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "testing" + + "github.com/arm/topo/internal/deploy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRegistryConfig(t *testing.T) { + t.Run("enables skipping the remote port check from the deploy flag", func(t *testing.T) { + flag := deployCmd.Flags().Lookup("skip-remote-port-check") + require.NotNil(t, flag) + originalValue := flag.Value.String() + originalChanged := flag.Changed + t.Cleanup(func() { + require.NoError(t, flag.Value.Set(originalValue)) + flag.Changed = originalChanged + }) + require.NoError(t, flag.Value.Set("true")) + flag.Changed = true + + got := newRegistryConfig(deployCmd, "12345", "linux") + + want := &deploy.RegistryConfig{ + Port: "12345", + SkipRemotePortCheck: true, + UseControlSockets: true, + } + assert.Equal(t, want, got) + }) +} From 084b883dc42f9dde3b8c54882f60d799e3bfd639 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 14:57:21 +0100 Subject: [PATCH 07/29] Use consistent deploy flag binding --- cmd/topo/deploy.go | 21 +++++++++------------ cmd/topo/deploy_internal_test.go | 2 +- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index b9b3166c..cfb62738 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -18,11 +18,12 @@ import ( ) var ( - noRegistry bool - registryPort string - skipProjectChecks bool - forceRecreate bool - noRecreate bool + noRegistry bool + registryPort string + skipRemotePortCheck bool + skipProjectChecks bool + forceRecreate bool + noRecreate bool ) var deployOpts deploy.DeployOptions @@ -77,7 +78,7 @@ The compose file (compose.yaml) must be in the current working directory, as thi goos := runtime.GOOS if deploy.SupportsRegistry(noRegistry, deployOpts.TargetHost) { - deployOpts.Registry = newRegistryConfig(cmd, resolvedPort, goos) + deployOpts.Registry = newRegistryConfig(resolvedPort, goos) } switch { case forceRecreate: @@ -136,11 +137,7 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { return flagValue, nil } -func newRegistryConfig(cmd *cobra.Command, port, goos string) *deploy.RegistryConfig { - skipRemotePortCheck, err := cmd.Flags().GetBool("skip-remote-port-check") - if err != nil { - panic("internal error: skip-remote-port-check flag not registered: " + err.Error()) - } +func newRegistryConfig(port, goos string) *deploy.RegistryConfig { return &deploy.RegistryConfig{ Port: port, SkipRemotePortCheck: skipRemotePortCheck, @@ -152,7 +149,7 @@ func init() { addTargetFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) deployCmd.Flags().BoolVar(&noRegistry, "no-registry", false, "disable private registry flow; use direct save/load transfer") - deployCmd.Flags().Bool("skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") + deployCmd.Flags().BoolVar(&skipRemotePortCheck, "skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") deployCmd.Flags().BoolVar(&forceRecreate, "force-recreate", false, "force recreation of containers even if their configuration and image haven't changed") deployCmd.Flags().BoolVar(&noRecreate, "no-recreate", false, "prevent recreation of containers even if their configuration and image have changed") deployCmd.Flags().BoolVar(&skipProjectChecks, "skip-project-checks", false, "skip project compatibility checks for the target platform") diff --git a/cmd/topo/deploy_internal_test.go b/cmd/topo/deploy_internal_test.go index 9ca973b5..3c4698c3 100644 --- a/cmd/topo/deploy_internal_test.go +++ b/cmd/topo/deploy_internal_test.go @@ -21,7 +21,7 @@ func TestNewRegistryConfig(t *testing.T) { require.NoError(t, flag.Value.Set("true")) flag.Changed = true - got := newRegistryConfig(deployCmd, "12345", "linux") + got := newRegistryConfig("12345", "linux") want := &deploy.RegistryConfig{ Port: "12345", From 8b91456e5e63cbb3b0c121589a094b77ee0e44a6 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:00:51 +0100 Subject: [PATCH 08/29] Remove deploy flag wiring test --- cmd/topo/deploy.go | 14 +++++--------- cmd/topo/deploy_internal_test.go | 33 -------------------------------- 2 files changed, 5 insertions(+), 42 deletions(-) delete mode 100644 cmd/topo/deploy_internal_test.go diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index cfb62738..dec6793c 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -78,7 +78,11 @@ The compose file (compose.yaml) must be in the current working directory, as thi goos := runtime.GOOS if deploy.SupportsRegistry(noRegistry, deployOpts.TargetHost) { - deployOpts.Registry = newRegistryConfig(resolvedPort, goos) + deployOpts.Registry = &deploy.RegistryConfig{ + Port: resolvedPort, + SkipRemotePortCheck: skipRemotePortCheck, + UseControlSockets: deploy.SupportsSSHControlSockets(goos), + } } switch { case forceRecreate: @@ -137,14 +141,6 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { return flagValue, nil } -func newRegistryConfig(port, goos string) *deploy.RegistryConfig { - return &deploy.RegistryConfig{ - Port: port, - SkipRemotePortCheck: skipRemotePortCheck, - UseControlSockets: deploy.SupportsSSHControlSockets(goos), - } -} - func init() { addTargetFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) diff --git a/cmd/topo/deploy_internal_test.go b/cmd/topo/deploy_internal_test.go deleted file mode 100644 index 3c4698c3..00000000 --- a/cmd/topo/deploy_internal_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "testing" - - "github.com/arm/topo/internal/deploy" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewRegistryConfig(t *testing.T) { - t.Run("enables skipping the remote port check from the deploy flag", func(t *testing.T) { - flag := deployCmd.Flags().Lookup("skip-remote-port-check") - require.NotNil(t, flag) - originalValue := flag.Value.String() - originalChanged := flag.Changed - t.Cleanup(func() { - require.NoError(t, flag.Value.Set(originalValue)) - flag.Changed = originalChanged - }) - require.NoError(t, flag.Value.Set("true")) - flag.Changed = true - - got := newRegistryConfig("12345", "linux") - - want := &deploy.RegistryConfig{ - Port: "12345", - SkipRemotePortCheck: true, - UseControlSockets: true, - } - assert.Equal(t, want, got) - }) -} From 437632c4aa4fdde2d04c3da024d32eee9c754a44 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:02:43 +0100 Subject: [PATCH 09/29] Reject empty remote port hosts --- internal/ssh/ssh_tunnel.go | 3 +++ internal/ssh/ssh_tunnel_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index a8eb9148..1d5fd2e2 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -122,6 +122,9 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { } func CheckRemotePortNotListening(host, port string) error { + if host == "" { + return fmt.Errorf("could not check remote port %s: host must not be empty", port) + } address := net.JoinHostPort(host, port) connection, err := net.DialTimeout("tcp", address, 5*time.Second) if err == nil { diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index 41e4b523..2a42f595 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -120,6 +120,12 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { } func TestCheckRemotePortNotListening(t *testing.T) { + t.Run("it rejects an empty remote host", func(t *testing.T) { + err := ssh.CheckRemotePortNotListening("", "12345") + + assert.EqualError(t, err, "could not check remote port 12345: host must not be empty") + }) + t.Run("it succeeds when nothing answers on the remote port", func(t *testing.T) { port := reserveFreePort(t) From 813625b079b0f21c5103fbfed1026d320ce47819 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:06:31 +0100 Subject: [PATCH 10/29] Keep remote port probe internal --- internal/ssh/ssh_tunnel.go | 7 ++-- internal/ssh/ssh_tunnel_internal_test.go | 40 +++++++++++++++++++++ internal/ssh/ssh_tunnel_test.go | 45 ------------------------ 3 files changed, 42 insertions(+), 50 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 1d5fd2e2..69889f95 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -114,17 +114,14 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { if err != nil { return err } - if err := CheckRemotePortNotListening(host, ct.Port); err != nil { + if err := checkRemotePortNotListening(host, ct.Port); err != nil { return err } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil } -func CheckRemotePortNotListening(host, port string) error { - if host == "" { - return fmt.Errorf("could not check remote port %s: host must not be empty", port) - } +func checkRemotePortNotListening(host, port string) error { address := net.JoinHostPort(host, port) connection, err := net.DialTimeout("tcp", address, 5*time.Second) if err == nil { diff --git a/internal/ssh/ssh_tunnel_internal_test.go b/internal/ssh/ssh_tunnel_internal_test.go index cab956c2..e23d7463 100644 --- a/internal/ssh/ssh_tunnel_internal_test.go +++ b/internal/ssh/ssh_tunnel_internal_test.go @@ -2,12 +2,52 @@ package ssh import ( "errors" + "fmt" "net" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestCheckRemotePortNotListening(t *testing.T) { + t.Run("succeeds when nothing answers on the remote port", func(t *testing.T) { + port := reserveFreePort(t) + + err := checkRemotePortNotListening("127.0.0.1", port) + + assert.NoError(t, err) + }) + + t.Run("reports an exposed port when a TCP listener accepts the connection", func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + _, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + + err = checkRemotePortNotListening("127.0.0.1", port) + + assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) + }) + + t.Run("reports a resolution error when the remote host does not resolve", func(t *testing.T) { + err := checkRemotePortNotListening("nonexistent.invalid", "12345") + + assert.ErrorContains(t, err, `could not resolve remote host "nonexistent.invalid" while checking tunnel exposure`) + }) +} + +func reserveFreePort(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + _, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + require.NoError(t, listener.Close()) + return port +} + func TestClassifyRemotePortError(t *testing.T) { t.Run("returns a timeout-specific error for DNS timeouts", func(t *testing.T) { dialError := &net.DNSError{ diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index 2a42f595..db771d82 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -3,7 +3,6 @@ package ssh_test import ( "fmt" "io" - "net" "os" "strings" "testing" @@ -119,50 +118,6 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { }) } -func TestCheckRemotePortNotListening(t *testing.T) { - t.Run("it rejects an empty remote host", func(t *testing.T) { - err := ssh.CheckRemotePortNotListening("", "12345") - - assert.EqualError(t, err, "could not check remote port 12345: host must not be empty") - }) - - t.Run("it succeeds when nothing answers on the remote port", func(t *testing.T) { - port := reserveFreePort(t) - - err := ssh.CheckRemotePortNotListening("127.0.0.1", port) - - assert.NoError(t, err) - }) - - t.Run("it reports an exposed port when a TCP listener accepts the connection", func(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { _ = listener.Close() }) - _, port, err := net.SplitHostPort(listener.Addr().String()) - require.NoError(t, err) - - err = ssh.CheckRemotePortNotListening("127.0.0.1", port) - - assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) - }) - - t.Run("it reports a resolution error when the remote host does not resolve", func(t *testing.T) { - err := ssh.CheckRemotePortNotListening("nonexistent.invalid", "12345") - - assert.ErrorContains(t, err, `could not resolve remote host "nonexistent.invalid" while checking tunnel exposure`) - }) -} - -func reserveFreePort(t *testing.T) string { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - _, port, err := net.SplitHostPort(listener.Addr().String()) - require.NoError(t, err) - require.NoError(t, listener.Close()) - return port -} - func TestSSHTunnelStop(t *testing.T) { t.Run("Command", func(t *testing.T) { t.Run("it generates correct ssh command", func(t *testing.T) { From 4de355f7e35b36d6a5c91dc59f30d88b5bf19829 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:07:37 +0100 Subject: [PATCH 11/29] Remove internal SSH tunnel tests --- internal/ssh/ssh_tunnel_internal_test.go | 73 ------------------------ 1 file changed, 73 deletions(-) delete mode 100644 internal/ssh/ssh_tunnel_internal_test.go diff --git a/internal/ssh/ssh_tunnel_internal_test.go b/internal/ssh/ssh_tunnel_internal_test.go deleted file mode 100644 index e23d7463..00000000 --- a/internal/ssh/ssh_tunnel_internal_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package ssh - -import ( - "errors" - "fmt" - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCheckRemotePortNotListening(t *testing.T) { - t.Run("succeeds when nothing answers on the remote port", func(t *testing.T) { - port := reserveFreePort(t) - - err := checkRemotePortNotListening("127.0.0.1", port) - - assert.NoError(t, err) - }) - - t.Run("reports an exposed port when a TCP listener accepts the connection", func(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { _ = listener.Close() }) - _, port, err := net.SplitHostPort(listener.Addr().String()) - require.NoError(t, err) - - err = checkRemotePortNotListening("127.0.0.1", port) - - assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) - }) - - t.Run("reports a resolution error when the remote host does not resolve", func(t *testing.T) { - err := checkRemotePortNotListening("nonexistent.invalid", "12345") - - assert.ErrorContains(t, err, `could not resolve remote host "nonexistent.invalid" while checking tunnel exposure`) - }) -} - -func reserveFreePort(t *testing.T) string { - t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - _, port, err := net.SplitHostPort(listener.Addr().String()) - require.NoError(t, err) - require.NoError(t, listener.Close()) - return port -} - -func TestClassifyRemotePortError(t *testing.T) { - t.Run("returns a timeout-specific error for DNS timeouts", func(t *testing.T) { - dialError := &net.DNSError{ - Err: "operation timed out", - Name: "remote.example", - IsTimeout: true, - } - - err := classifyRemotePortError("remote.example", "remote.example:12345", dialError) - - assert.ErrorContains(t, err, "timed out while checking whether remote port remote.example:12345 is exposed") - assert.ErrorIs(t, err, dialError) - }) - - t.Run("returns a fallback error for an unknown network failure", func(t *testing.T) { - dialError := errors.New("unexpected network failure") - - err := classifyRemotePortError("remote.example", "remote.example:12345", dialError) - - assert.ErrorContains(t, err, "could not verify whether remote port remote.example:12345 is exposed") - assert.ErrorIs(t, err, dialError) - }) -} From cd5dea3e8f4e5fa939a3fae36a9639a954adffaf Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:13:58 +0100 Subject: [PATCH 12/29] Expand remote forward exposure tests --- internal/ssh/ssh_tunnel_test.go | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index db771d82..3aff57f6 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -3,6 +3,7 @@ package ssh_test import ( "fmt" "io" + "net" "os" "strings" "testing" @@ -108,6 +109,16 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { }) t.Run("Run", func(t *testing.T) { + t.Run("it skips the check for localhost", func(t *testing.T) { + check := ssh.NewCheckRemoteForwardNotExposed(ssh.PlainLocalhost, "invalid") + var output strings.Builder + + err := check.Run(&output) + + assert.NoError(t, err) + assert.Empty(t, output.String()) + }) + t.Run("it fails when the SSH hostname cannot be resolved", func(t *testing.T) { check := ssh.NewCheckRemoteForwardNotExposed(ssh.Destination{}, "12345") @@ -115,9 +126,43 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { assert.ErrorContains(t, err, `could not resolve SSH hostname for "ssh://"`) }) + + t.Run("it succeeds when the remote port refuses the connection", func(t *testing.T) { + port := reserveFreePort(t, "0.0.0.0") + check := ssh.NewCheckRemoteForwardNotExposed(ssh.NewDestination("0.0.0.0"), port) + var output strings.Builder + + err := check.Run(&output) + + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("Port %s is bound to remote loopback only\n", port), output.String()) + }) + + t.Run("it fails when the remote port accepts a connection", func(t *testing.T) { + listener, err := net.Listen("tcp", "0.0.0.0:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + _, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + check := ssh.NewCheckRemoteForwardNotExposed(ssh.NewDestination("0.0.0.0"), port) + + err = check.Run(io.Discard) + + assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) + }) }) } +func reserveFreePort(t *testing.T, host string) string { + t.Helper() + listener, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + require.NoError(t, err) + _, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + require.NoError(t, listener.Close()) + return port +} + func TestSSHTunnelStop(t *testing.T) { t.Run("Command", func(t *testing.T) { t.Run("it generates correct ssh command", func(t *testing.T) { From d51050419d908f34bc5633f431301e419023f6a5 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:18:58 +0100 Subject: [PATCH 13/29] Suggest skipping inconclusive port checks --- internal/ssh/ssh_tunnel.go | 30 +++++++++++++++++++++++++----- internal/ssh/ssh_tunnel_test.go | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 69889f95..8a14b1d4 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -94,6 +94,18 @@ type CheckRemoteForwardNotExposed struct { Port string } +type inconclusiveRemotePortCheckError struct { + err error +} + +func (e *inconclusiveRemotePortCheckError) Error() string { + return e.err.Error() +} + +func (e *inconclusiveRemotePortCheckError) Unwrap() error { + return e.err +} + // Checks whether the RemoteForward port exposes the registry to the target's // network, rather than being limited to target loopback. This can happen when // sshd permits non-loopback remote forwards, such as GatewayPorts. @@ -112,10 +124,10 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { host, err := ResolveHostName(ct.TargetDest) if err != nil { - return err + return remotePortCheckErrorWithSuggestion(&inconclusiveRemotePortCheckError{err: err}) } if err := checkRemotePortNotListening(host, ct.Port); err != nil { - return err + return remotePortCheckErrorWithSuggestion(err) } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil @@ -143,15 +155,23 @@ func classifyRemotePortError(host, address string, err error) error { var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { - return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) + return &inconclusiveRemotePortCheckError{err: fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err)} } var dnsError *net.DNSError if errors.As(err, &dnsError) { - return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) + return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err)} } - return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) + return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err)} +} + +func remotePortCheckErrorWithSuggestion(err error) error { + var inconclusiveError *inconclusiveRemotePortCheckError + if !errors.As(err, &inconclusiveError) { + return err + } + return fmt.Errorf("%w; retry after resolving the connectivity issue, or use `--skip-remote-port-check` if you understand the security risk", err) } type SSHTunnelStop struct { diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index 3aff57f6..a75cbfe3 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -125,6 +125,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { err := check.Run(io.Discard) assert.ErrorContains(t, err, `could not resolve SSH hostname for "ssh://"`) + assert.ErrorContains(t, err, "use `--skip-remote-port-check` if you understand the security risk") }) t.Run("it succeeds when the remote port refuses the connection", func(t *testing.T) { @@ -149,6 +150,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { err = check.Run(io.Discard) assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) + assert.NotContains(t, err.Error(), "--skip-remote-port-check") }) }) } From da858c1a025d0a632e9a4fda05c090aacd8d3a4d Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:55:04 +0100 Subject: [PATCH 14/29] Clarify inconclusive exposure errors --- internal/ssh/ssh_tunnel.go | 8 ++++---- internal/ssh/ssh_tunnel_test.go | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 8a14b1d4..41daa9be 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -124,10 +124,10 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { host, err := ResolveHostName(ct.TargetDest) if err != nil { - return remotePortCheckErrorWithSuggestion(&inconclusiveRemotePortCheckError{err: err}) + return remotePortCheckErrorWithSuggestion(&inconclusiveRemotePortCheckError{err: err}, ct.Port) } if err := checkRemotePortNotListening(host, ct.Port); err != nil { - return remotePortCheckErrorWithSuggestion(err) + return remotePortCheckErrorWithSuggestion(err, ct.Port) } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil @@ -166,12 +166,12 @@ func classifyRemotePortError(host, address string, err error) error { return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err)} } -func remotePortCheckErrorWithSuggestion(err error) error { +func remotePortCheckErrorWithSuggestion(err error, port string) error { var inconclusiveError *inconclusiveRemotePortCheckError if !errors.As(err, &inconclusiveError) { return err } - return fmt.Errorf("%w; retry after resolving the connectivity issue, or use `--skip-remote-port-check` if you understand the security risk", err) + return fmt.Errorf("cannot conclusively rule out network access to registry port %s because the exposure check did not complete: %w; retry after resolving the connectivity issue, or use `--skip-remote-port-check` if you understand the security risk", port, err) } type SSHTunnelStop struct { diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index a75cbfe3..9480e0c4 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -124,6 +124,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { err := check.Run(io.Discard) + assert.ErrorContains(t, err, "cannot conclusively rule out network access to registry port 12345") assert.ErrorContains(t, err, `could not resolve SSH hostname for "ssh://"`) assert.ErrorContains(t, err, "use `--skip-remote-port-check` if you understand the security risk") }) From 2b057336e6e80820a62f7a9ca744372c53d5563c Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 15:56:48 +0100 Subject: [PATCH 15/29] Support remote port check environment bypass --- cmd/topo/deploy.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index dec6793c..3262f8d0 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -10,6 +10,7 @@ import ( "github.com/arm/topo/internal/deploy" "github.com/arm/topo/internal/deploy/operation" checks "github.com/arm/topo/internal/deploy/project_checks" + "github.com/arm/topo/internal/env" goperation "github.com/arm/topo/internal/operation" "github.com/arm/topo/internal/output/logger" "github.com/arm/topo/internal/ssh" @@ -80,7 +81,7 @@ The compose file (compose.yaml) must be in the current working directory, as thi if deploy.SupportsRegistry(noRegistry, deployOpts.TargetHost) { deployOpts.Registry = &deploy.RegistryConfig{ Port: resolvedPort, - SkipRemotePortCheck: skipRemotePortCheck, + SkipRemotePortCheck: resolveSkipRemotePortCheck(cmd), UseControlSockets: deploy.SupportsSSHControlSockets(goos), } } @@ -129,7 +130,10 @@ func validatePort(port string) error { return nil } -const portEnvVar = "TOPO_PORT" +const ( + portEnvVar = "TOPO_PORT" + skipRemotePortCheckEnvVar = "TOPO_SKIP_REMOTE_PORT_CHECK" +) func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { if cmd.Flags().Changed("registry-port") { @@ -141,11 +145,23 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { return flagValue, nil } +func resolveSkipRemotePortCheck(cmd *cobra.Command) bool { + if !cmd.Flags().Changed("skip-remote-port-check") { + return env.IsVarTruthy(skipRemotePortCheckEnvVar) + } + + value, err := cmd.Flags().GetBool("skip-remote-port-check") + if err != nil { + panic("internal error: skip-remote-port-check flag not registered: " + err.Error()) + } + return value +} + func init() { addTargetFlag(deployCmd) deployCmd.Flags().StringVarP(®istryPort, "registry-port", "p", operation.DefaultRegistryPort, fmt.Sprintf("registry and SSH tunnel port (can also be set via %s env var)", portEnvVar)) deployCmd.Flags().BoolVar(&noRegistry, "no-registry", false, "disable private registry flow; use direct save/load transfer") - deployCmd.Flags().BoolVar(&skipRemotePortCheck, "skip-remote-port-check", false, "skip checking whether the SSH tunnel port is exposed on the remote network") + deployCmd.Flags().BoolVar(&skipRemotePortCheck, "skip-remote-port-check", false, fmt.Sprintf("skip checking whether the SSH tunnel port is exposed on the remote network (can also be set via %s env var)", skipRemotePortCheckEnvVar)) deployCmd.Flags().BoolVar(&forceRecreate, "force-recreate", false, "force recreation of containers even if their configuration and image haven't changed") deployCmd.Flags().BoolVar(&noRecreate, "no-recreate", false, "prevent recreation of containers even if their configuration and image have changed") deployCmd.Flags().BoolVar(&skipProjectChecks, "skip-project-checks", false, "skip project compatibility checks for the target platform") From cdec3676624243688790cda22cc8421cd960b3c6 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 16:12:58 +0100 Subject: [PATCH 16/29] Handle refused connections on Windows --- internal/ssh/connection_refused_unix.go | 12 ++++++++++++ internal/ssh/connection_refused_windows.go | 13 +++++++++++++ internal/ssh/ssh_tunnel.go | 3 +-- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 internal/ssh/connection_refused_unix.go create mode 100644 internal/ssh/connection_refused_windows.go diff --git a/internal/ssh/connection_refused_unix.go b/internal/ssh/connection_refused_unix.go new file mode 100644 index 00000000..9c295370 --- /dev/null +++ b/internal/ssh/connection_refused_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package ssh + +import ( + "errors" + "syscall" +) + +func isConnectionRefused(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) +} diff --git a/internal/ssh/connection_refused_windows.go b/internal/ssh/connection_refused_windows.go new file mode 100644 index 00000000..4d692e40 --- /dev/null +++ b/internal/ssh/connection_refused_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package ssh + +import ( + "errors" + "syscall" +) + +func isConnectionRefused(err error) bool { + const windowsConnectionRefused syscall.Errno = 10061 + return errors.Is(err, windowsConnectionRefused) +} diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 41daa9be..e10249bc 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "runtime" - "syscall" "time" "github.com/arm/topo/internal/command" @@ -149,7 +148,7 @@ func classifyRemotePortError(host, address string, err error) error { if err == nil { panic("classifyRemotePortError requires a non-nil error") } - if errors.Is(err, syscall.ECONNREFUSED) { + if isConnectionRefused(err) { return nil } From defb5e67d7d6c427c91791e547342fbece9865b9 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Mon, 13 Jul 2026 16:14:21 +0100 Subject: [PATCH 17/29] Consolidate refused connection handling --- internal/ssh/connection_refused_unix.go | 12 ------------ internal/ssh/connection_refused_windows.go | 13 ------------- internal/ssh/ssh_tunnel.go | 9 +++++++++ 3 files changed, 9 insertions(+), 25 deletions(-) delete mode 100644 internal/ssh/connection_refused_unix.go delete mode 100644 internal/ssh/connection_refused_windows.go diff --git a/internal/ssh/connection_refused_unix.go b/internal/ssh/connection_refused_unix.go deleted file mode 100644 index 9c295370..00000000 --- a/internal/ssh/connection_refused_unix.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !windows - -package ssh - -import ( - "errors" - "syscall" -) - -func isConnectionRefused(err error) bool { - return errors.Is(err, syscall.ECONNREFUSED) -} diff --git a/internal/ssh/connection_refused_windows.go b/internal/ssh/connection_refused_windows.go deleted file mode 100644 index 4d692e40..00000000 --- a/internal/ssh/connection_refused_windows.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build windows - -package ssh - -import ( - "errors" - "syscall" -) - -func isConnectionRefused(err error) bool { - const windowsConnectionRefused syscall.Errno = 10061 - return errors.Is(err, windowsConnectionRefused) -} diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index e10249bc..854195c5 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "syscall" "time" "github.com/arm/topo/internal/command" @@ -165,6 +166,14 @@ func classifyRemotePortError(host, address string, err error) error { return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err)} } +func isConnectionRefused(err error) bool { + if runtime.GOOS == "windows" { + const windowsConnectionRefused syscall.Errno = 10061 + return errors.Is(err, windowsConnectionRefused) + } + return errors.Is(err, syscall.ECONNREFUSED) +} + func remotePortCheckErrorWithSuggestion(err error, port string) error { var inconclusiveError *inconclusiveRemotePortCheckError if !errors.As(err, &inconclusiveError) { From c37a7f390a181ccf4f0e3141cde4e326fd3fd909 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 10:46:12 +0100 Subject: [PATCH 18/29] remove panic and simplify resolveSkipRemotePortCheck --- cmd/topo/deploy.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index 3262f8d0..69c4fb4e 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -150,11 +150,7 @@ func resolveSkipRemotePortCheck(cmd *cobra.Command) bool { return env.IsVarTruthy(skipRemotePortCheckEnvVar) } - value, err := cmd.Flags().GetBool("skip-remote-port-check") - if err != nil { - panic("internal error: skip-remote-port-check flag not registered: " + err.Error()) - } - return value + return env.IsVarTruthy(skipRemotePortCheckEnvVar) } func init() { From 3cd98ed115187131e2bfec23bc5f6491b7741d95 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 11:10:38 +0100 Subject: [PATCH 19/29] Simplify remote port check classification --- internal/deploy/deploy_test.go | 18 ++------------- internal/ssh/ssh_tunnel.go | 40 ++++++++++++---------------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index d082b838..52c1583f 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -163,22 +163,8 @@ func TestNewDeployment(t *testing.T) { got, _ := deploy.NewDeployment(composeFile, opts) - wantTunnelStart, _, wantTunnelStop := ssh.NewSSHTunnel(remoteDest, port, opts.Registry.UseControlSockets) - localHost := command.LocalHost - remoteHost := command.NewHostFromDestination(remoteDest) - want := goperation.Sequence{ - operation.NewDockerComposeBuild(composeFile, localHost), - operation.NewDockerComposePull(composeFile, localHost), - } - want = append(want, operation.NewRunRegistry(port)...) - want = append(want, - wantTunnelStart, - operation.NewRegistryTransfer(composeFile, localHost, remoteHost, port), - wantTunnelStop, - operation.NewDockerComposeUp(composeFile, remoteHost, operation.RecreateModeDefault), - post_deploy.NewDeploySuccess(composeFile, remoteHost, "Run `topo ps` to see deployed containers"), - ) - assert.Equal(t, want, got) + _, remotePortCheck, _ := ssh.NewSSHTunnel(remoteDest, port, opts.Registry.UseControlSockets) + assert.NotContains(t, got, remotePortCheck) }) } diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 854195c5..252682f3 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -94,18 +94,6 @@ type CheckRemoteForwardNotExposed struct { Port string } -type inconclusiveRemotePortCheckError struct { - err error -} - -func (e *inconclusiveRemotePortCheckError) Error() string { - return e.err.Error() -} - -func (e *inconclusiveRemotePortCheckError) Unwrap() error { - return e.err -} - // Checks whether the RemoteForward port exposes the registry to the target's // network, rather than being limited to target loopback. This can happen when // sshd permits non-loopback remote forwards, such as GatewayPorts. @@ -124,46 +112,50 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { host, err := ResolveHostName(ct.TargetDest) if err != nil { - return remotePortCheckErrorWithSuggestion(&inconclusiveRemotePortCheckError{err: err}, ct.Port) + return remotePortCheckErrorWithSuggestion(err, ct.Port) } - if err := checkRemotePortNotListening(host, ct.Port); err != nil { + err, inconclusive := checkRemotePortNotListening(host, ct.Port) + if inconclusive { return remotePortCheckErrorWithSuggestion(err, ct.Port) } + if err != nil { + return err + } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil } -func checkRemotePortNotListening(host, port string) error { +func checkRemotePortNotListening(host, port string) (error, bool) { address := net.JoinHostPort(host, port) connection, err := net.DialTimeout("tcp", address, 5*time.Second) if err == nil { if closeErr := connection.Close(); closeErr != nil { - return fmt.Errorf("remote port %s accepted a TCP connection, but closing the connection failed: %w", address, closeErr) + return fmt.Errorf("remote port %s accepted a TCP connection, but closing the connection failed: %w", address, closeErr), false } - return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port) + return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port), false } return classifyRemotePortError(host, address, err) } -func classifyRemotePortError(host, address string, err error) error { +func classifyRemotePortError(host, address string, err error) (error, bool) { if err == nil { panic("classifyRemotePortError requires a non-nil error") } if isConnectionRefused(err) { - return nil + return nil, false } var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { - return &inconclusiveRemotePortCheckError{err: fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err)} + return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err), true } var dnsError *net.DNSError if errors.As(err, &dnsError) { - return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err)} + return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err), true } - return &inconclusiveRemotePortCheckError{err: fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err)} + return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err), true } func isConnectionRefused(err error) bool { @@ -175,10 +167,6 @@ func isConnectionRefused(err error) bool { } func remotePortCheckErrorWithSuggestion(err error, port string) error { - var inconclusiveError *inconclusiveRemotePortCheckError - if !errors.As(err, &inconclusiveError) { - return err - } return fmt.Errorf("cannot conclusively rule out network access to registry port %s because the exposure check did not complete: %w; retry after resolving the connectivity issue, or use `--skip-remote-port-check` if you understand the security risk", port, err) } From 17349c5b8d0d0b9c099898e2ba94a789762244ea Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 11:39:40 +0100 Subject: [PATCH 20/29] Restore curl remote port probing --- internal/ssh/ssh_tunnel.go | 39 ++++++++++----------------------- internal/ssh/ssh_tunnel_test.go | 10 +++++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 252682f3..e6cf8455 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -10,8 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" - "syscall" - "time" + "strings" "github.com/arm/topo/internal/command" "github.com/arm/topo/internal/operation" @@ -127,45 +126,29 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { func checkRemotePortNotListening(host, port string) (error, bool) { address := net.JoinHostPort(host, port) - connection, err := net.DialTimeout("tcp", address, 5*time.Second) - if err == nil { - if closeErr := connection.Close(); closeErr != nil { - return fmt.Errorf("remote port %s accepted a TCP connection, but closing the connection failed: %w", address, closeErr), false - } + var remoteIP strings.Builder + cmd := exec.Command("curl", "http://"+address, "--max-time", "5", "--noproxy", "*", "--output", os.DevNull, "--silent", "--write-out", "%{remote_ip}") + cmd.Stdout = &remoteIP + cmd.Stderr = io.Discard + err := cmd.Run() + if err == nil || remoteIP.Len() > 0 { return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port), false } - return classifyRemotePortError(host, address, err) -} -func classifyRemotePortError(host, address string, err error) (error, bool) { - if err == nil { - panic("classifyRemotePortError requires a non-nil error") - } - if isConnectionRefused(err) { + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 7 { return nil, false } - - var networkError net.Error - if errors.As(err, &networkError) && networkError.Timeout() { + if errors.As(err, &exitError) && exitError.ExitCode() == 28 { return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err), true } - - var dnsError *net.DNSError - if errors.As(err, &dnsError) { + if errors.As(err, &exitError) && exitError.ExitCode() == 6 { return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err), true } return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err), true } -func isConnectionRefused(err error) bool { - if runtime.GOOS == "windows" { - const windowsConnectionRefused syscall.Errno = 10061 - return errors.Is(err, windowsConnectionRefused) - } - return errors.Is(err, syscall.ECONNREFUSED) -} - func remotePortCheckErrorWithSuggestion(err error, port string) error { return fmt.Errorf("cannot conclusively rule out network access to registry port %s because the exposure check did not complete: %w; retry after resolving the connectivity issue, or use `--skip-remote-port-check` if you understand the security risk", port, err) } diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index 9480e0c4..cb587ba0 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -144,6 +144,15 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { listener, err := net.Listen("tcp", "0.0.0.0:0") require.NoError(t, err) t.Cleanup(func() { _ = listener.Close() }) + connectionClosed := make(chan error, 1) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + connectionClosed <- acceptErr + return + } + connectionClosed <- connection.Close() + }() _, port, err := net.SplitHostPort(listener.Addr().String()) require.NoError(t, err) check := ssh.NewCheckRemoteForwardNotExposed(ssh.NewDestination("0.0.0.0"), port) @@ -152,6 +161,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) assert.NotContains(t, err.Error(), "--skip-remote-port-check") + assert.NoError(t, <-connectionClosed) }) }) } From 51494073bc41a070a88600f1f8e2036faf14c622 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 11:43:17 +0100 Subject: [PATCH 21/29] Improve exposed port remediation guidance --- internal/ssh/ssh_tunnel.go | 4 ++-- internal/ssh/ssh_tunnel_test.go | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index e6cf8455..3c0f1c53 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -95,7 +95,7 @@ type CheckRemoteForwardNotExposed struct { // Checks whether the RemoteForward port exposes the registry to the target's // network, rather than being limited to target loopback. This can happen when -// sshd permits non-loopback remote forwards, such as GatewayPorts. +// the SSH server permits non-loopback remote forwards. func NewCheckRemoteForwardNotExposed(targetDest Destination, port string) *CheckRemoteForwardNotExposed { return &CheckRemoteForwardNotExposed{TargetDest: targetDest, Port: port} } @@ -132,7 +132,7 @@ func checkRemotePortNotListening(host, port string) (error, bool) { cmd.Stderr = io.Discard err := cmd.Run() if err == nil || remoteIP.Len() > 0 { - return fmt.Errorf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port), false + return fmt.Errorf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", port), false } var exitError *exec.ExitError diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index cb587ba0..f6e6350f 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -159,8 +159,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { err = check.Run(io.Discard) - assert.EqualError(t, err, fmt.Sprintf("remote sshd might be exposing the forwarded port %s on its network (likely GatewayPorts=yes); the local registry may be reachable without SSH auth", port)) - assert.NotContains(t, err.Error(), "--skip-remote-port-check") + assert.EqualError(t, err, fmt.Sprintf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", port)) assert.NoError(t, <-connectionClosed) }) }) From d8fc01283ec284232cc9e994ad2e59593412e5f4 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 14:14:07 +0100 Subject: [PATCH 22/29] rename some functions and error messages --- internal/deploy/deploy_test.go | 5 +---- internal/ssh/config.go | 4 ++-- internal/ssh/ssh_tunnel.go | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index 52c1583f..84bac7ba 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -151,19 +151,16 @@ func TestNewDeployment(t *testing.T) { t.Run("excludes the remote port check when skipped", func(t *testing.T) { remoteDest := ssh.NewDestination("user@remote") - port := operation.DefaultRegistryPort opts := deploy.DeployOptions{ TargetHost: remoteDest, Registry: &deploy.RegistryConfig{ - Port: port, SkipRemotePortCheck: true, - UseControlSockets: true, }, } got, _ := deploy.NewDeployment(composeFile, opts) - _, remotePortCheck, _ := ssh.NewSSHTunnel(remoteDest, port, opts.Registry.UseControlSockets) + _, remotePortCheck, _ := ssh.NewSSHTunnel(remoteDest, opts.Registry.Port, opts.Registry.UseControlSockets) assert.NotContains(t, got, remotePortCheck) }) } diff --git a/internal/ssh/config.go b/internal/ssh/config.go index af843337..f3a018fa 100644 --- a/internal/ssh/config.go +++ b/internal/ssh/config.go @@ -22,10 +22,10 @@ func NewConfig(dest Destination) Config { return NewConfigFromBytes(output) } -func ResolveHostName(dest Destination) (string, error) { +func resolveHostName(dest Destination) (string, error) { output, err := readConfig(dest) if err != nil { - return "", fmt.Errorf("could not resolve SSH hostname for %q: %w", dest.String(), err) + return "", fmt.Errorf("could not resolve SSH configuration for %q: %w", dest.String(), err) } hostName := NewConfigFromBytes(output).HostName diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 3c0f1c53..cf98fc94 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -109,7 +109,7 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { return nil } - host, err := ResolveHostName(ct.TargetDest) + host, err := resolveHostName(ct.TargetDest) if err != nil { return remotePortCheckErrorWithSuggestion(err, ct.Port) } From 826444f332f3189debc765344c89622c8817538a Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 14:19:54 +0100 Subject: [PATCH 23/29] Update SSH configuration error assertion --- internal/ssh/ssh_tunnel_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index f6e6350f..e8d256cd 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -125,7 +125,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { err := check.Run(io.Discard) assert.ErrorContains(t, err, "cannot conclusively rule out network access to registry port 12345") - assert.ErrorContains(t, err, `could not resolve SSH hostname for "ssh://"`) + assert.ErrorContains(t, err, `could not resolve SSH configuration for "ssh://"`) assert.ErrorContains(t, err, "use `--skip-remote-port-check` if you understand the security risk") }) From b9753f44f0fba6e0e12e02b8464aa69f7b8cc84c Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Wed, 15 Jul 2026 16:35:47 +0100 Subject: [PATCH 24/29] Make remote port test portable on Windows --- internal/ssh/ssh_tunnel_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/ssh/ssh_tunnel_test.go b/internal/ssh/ssh_tunnel_test.go index e8d256cd..f2dba956 100644 --- a/internal/ssh/ssh_tunnel_test.go +++ b/internal/ssh/ssh_tunnel_test.go @@ -141,7 +141,7 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { }) t.Run("it fails when the remote port accepts a connection", func(t *testing.T) { - listener, err := net.Listen("tcp", "0.0.0.0:0") + listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) t.Cleanup(func() { _ = listener.Close() }) connectionClosed := make(chan error, 1) @@ -155,11 +155,13 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) { }() _, port, err := net.SplitHostPort(listener.Addr().String()) require.NoError(t, err) - check := ssh.NewCheckRemoteForwardNotExposed(ssh.NewDestination("0.0.0.0"), port) + check := ssh.NewCheckRemoteForwardNotExposed(ssh.NewDestination("localhost."), port) err = check.Run(io.Discard) + listenerCloseErr := listener.Close() assert.EqualError(t, err, fmt.Sprintf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", port)) + assert.NoError(t, listenerCloseErr) assert.NoError(t, <-connectionClosed) }) }) From 8a986e1da46632b54d41b3daead8e1aad6a34998 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Thu, 16 Jul 2026 09:58:31 +0100 Subject: [PATCH 25/29] Respect explicit remote port check flag --- cmd/topo/deploy.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/topo/deploy.go b/cmd/topo/deploy.go index 69c4fb4e..6ebbd834 100644 --- a/cmd/topo/deploy.go +++ b/cmd/topo/deploy.go @@ -146,8 +146,9 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) { } func resolveSkipRemotePortCheck(cmd *cobra.Command) bool { - if !cmd.Flags().Changed("skip-remote-port-check") { - return env.IsVarTruthy(skipRemotePortCheckEnvVar) + flagValue, _ := cmd.Flags().GetBool("skip-remote-port-check") + if cmd.Flags().Changed("skip-remote-port-check") { + return flagValue } return env.IsVarTruthy(skipRemotePortCheckEnvVar) From d4ad8367f41e0c254d8fa3fe170314683a9d1260 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Thu, 16 Jul 2026 11:24:12 +0100 Subject: [PATCH 26/29] refactor remote port check error handling --- internal/ssh/ssh_tunnel.go | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index cf98fc94..82a9fe0a 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -113,18 +113,19 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { if err != nil { return remotePortCheckErrorWithSuggestion(err, ct.Port) } - err, inconclusive := checkRemotePortNotListening(host, ct.Port) - if inconclusive { - return remotePortCheckErrorWithSuggestion(err, ct.Port) - } + err = ensureRemotePortNotListening(host, ct.Port) if err != nil { - return err + var exposedError *remotePortExposedError + if errors.As(err, &exposedError) { + return err + } + return remotePortCheckErrorWithSuggestion(err, ct.Port) } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil } -func checkRemotePortNotListening(host, port string) (error, bool) { +func ensureRemotePortNotListening(host, port string) error { address := net.JoinHostPort(host, port) var remoteIP strings.Builder cmd := exec.Command("curl", "http://"+address, "--max-time", "5", "--noproxy", "*", "--output", os.DevNull, "--silent", "--write-out", "%{remote_ip}") @@ -132,21 +133,29 @@ func checkRemotePortNotListening(host, port string) (error, bool) { cmd.Stderr = io.Discard err := cmd.Run() if err == nil || remoteIP.Len() > 0 { - return fmt.Errorf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", port), false + return &remotePortExposedError{port: port} } var exitError *exec.ExitError if errors.As(err, &exitError) && exitError.ExitCode() == 7 { - return nil, false + return nil } if errors.As(err, &exitError) && exitError.ExitCode() == 28 { - return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err), true + return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) } if errors.As(err, &exitError) && exitError.ExitCode() == 6 { - return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err), true + return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) } - return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err), true + return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) +} + +type remotePortExposedError struct { + port string +} + +func (err *remotePortExposedError) Error() string { + return fmt.Sprintf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", err.port) } func remotePortCheckErrorWithSuggestion(err error, port string) error { From 1ab175e75977cd11edda55a7278dde918be5fe1f Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Thu, 16 Jul 2026 11:46:03 +0100 Subject: [PATCH 27/29] simplify curl exit error handling --- internal/ssh/ssh_tunnel.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 82a9fe0a..12784b4a 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -137,13 +137,16 @@ func ensureRemotePortNotListening(host, port string) error { } var exitError *exec.ExitError - if errors.As(err, &exitError) && exitError.ExitCode() == 7 { + if !errors.As(err, &exitError) { + return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) + } + if exitError.ExitCode() == 7 { return nil } - if errors.As(err, &exitError) && exitError.ExitCode() == 28 { + if exitError.ExitCode() == 28 { return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) } - if errors.As(err, &exitError) && exitError.ExitCode() == 6 { + if exitError.ExitCode() == 6 { return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) } From 0b413abeb3aeb5d69631c97105c948bc3b946fb5 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Thu, 16 Jul 2026 13:07:42 +0100 Subject: [PATCH 28/29] document curl tunnel check options --- internal/ssh/ssh_tunnel.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 12784b4a..5ceece6e 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -128,6 +128,10 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { func ensureRemotePortNotListening(host, port string) error { address := net.JoinHostPort(host, port) var remoteIP strings.Builder + // Release binaries use Go's resolver because CGO is disabled. Use curl so + // host resolution matches OpenSSH for mDNS, NSS, and split-DNS configurations. + // remote_ip detects a connection even when HTTP fails, noproxy ensures the + // connection is direct, and silent suppresses curl's progress and errors. cmd := exec.Command("curl", "http://"+address, "--max-time", "5", "--noproxy", "*", "--output", os.DevNull, "--silent", "--write-out", "%{remote_ip}") cmd.Stdout = &remoteIP cmd.Stderr = io.Discard From f1a501fc20eabe3fdfba3450870ac540b1dc53e8 Mon Sep 17 00:00:00 2001 From: Yejin Seo Date: Thu, 16 Jul 2026 16:47:38 +0100 Subject: [PATCH 29/29] Refactor remote port listening check --- internal/ssh/ssh_tunnel.go | 49 +++++++++++++++----------------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/internal/ssh/ssh_tunnel.go b/internal/ssh/ssh_tunnel.go index 5ceece6e..325df4b1 100644 --- a/internal/ssh/ssh_tunnel.go +++ b/internal/ssh/ssh_tunnel.go @@ -113,19 +113,18 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error { if err != nil { return remotePortCheckErrorWithSuggestion(err, ct.Port) } - err = ensureRemotePortNotListening(host, ct.Port) + listening, err := isRemotePortListening(host, ct.Port) if err != nil { - var exposedError *remotePortExposedError - if errors.As(err, &exposedError) { - return err - } return remotePortCheckErrorWithSuggestion(err, ct.Port) } + if listening { + return fmt.Errorf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", ct.Port) + } _, _ = fmt.Fprintf(w, "Port %s is bound to remote loopback only\n", ct.Port) return nil } -func ensureRemotePortNotListening(host, port string) error { +func isRemotePortListening(host, port string) (bool, error) { address := net.JoinHostPort(host, port) var remoteIP strings.Builder // Release binaries use Go's resolver because CGO is disabled. Use curl so @@ -137,32 +136,22 @@ func ensureRemotePortNotListening(host, port string) error { cmd.Stderr = io.Discard err := cmd.Run() if err == nil || remoteIP.Len() > 0 { - return &remotePortExposedError{port: port} - } - - var exitError *exec.ExitError - if !errors.As(err, &exitError) { - return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) - } - if exitError.ExitCode() == 7 { - return nil - } - if exitError.ExitCode() == 28 { - return fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) - } - if exitError.ExitCode() == 6 { - return fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) + return true, nil + } + + exitError, ok := errors.AsType[*exec.ExitError](err) + if ok { + switch exitError.ExitCode() { + case 7: + return false, nil + case 28: + return false, fmt.Errorf("timed out while checking whether remote port %s is exposed: %w", address, err) + case 6: + return false, fmt.Errorf("could not resolve remote host %q while checking tunnel exposure: %w", host, err) + } } - return fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) -} - -type remotePortExposedError struct { - port string -} - -func (err *remotePortExposedError) Error() string { - return fmt.Sprintf("the remote SSH server is exposing forwarded registry port %s beyond remote loopback; configure the SSH server to bind remote forwards to loopback only, or use `--skip-remote-port-check` if you understand that the registry may be reachable without SSH authentication", err.port) + return false, fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err) } func remotePortCheckErrorWithSuggestion(err error, port string) error {