Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
43942c9
Improve remote port check errors
yejseo01 Jul 13, 2026
ed9d165
Add option to skip remote port check
yejseo01 Jul 13, 2026
17ad9ae
Fail closed when SSH hostname is unavailable
yejseo01 Jul 13, 2026
7ef279d
Classify remote port timeouts first
yejseo01 Jul 13, 2026
af20574
Test remote port error classification
yejseo01 Jul 13, 2026
1003200
Test remote port skip flag wiring
yejseo01 Jul 13, 2026
084b883
Use consistent deploy flag binding
yejseo01 Jul 13, 2026
8b91456
Remove deploy flag wiring test
yejseo01 Jul 13, 2026
437632c
Reject empty remote port hosts
yejseo01 Jul 13, 2026
813625b
Keep remote port probe internal
yejseo01 Jul 13, 2026
4de355f
Remove internal SSH tunnel tests
yejseo01 Jul 13, 2026
cd5dea3
Expand remote forward exposure tests
yejseo01 Jul 13, 2026
d510504
Suggest skipping inconclusive port checks
yejseo01 Jul 13, 2026
da858c1
Clarify inconclusive exposure errors
yejseo01 Jul 13, 2026
2b05733
Support remote port check environment bypass
yejseo01 Jul 13, 2026
cdec367
Handle refused connections on Windows
yejseo01 Jul 13, 2026
defb5e6
Consolidate refused connection handling
yejseo01 Jul 13, 2026
c37a7f3
remove panic and simplify resolveSkipRemotePortCheck
yejseo01 Jul 15, 2026
3cd98ed
Simplify remote port check classification
yejseo01 Jul 15, 2026
17349c5
Restore curl remote port probing
yejseo01 Jul 15, 2026
5149407
Improve exposed port remediation guidance
yejseo01 Jul 15, 2026
d8fc012
rename some functions and error messages
yejseo01 Jul 15, 2026
826444f
Update SSH configuration error assertion
yejseo01 Jul 15, 2026
b9753f4
Make remote port test portable on Windows
yejseo01 Jul 15, 2026
8a986e1
Respect explicit remote port check flag
yejseo01 Jul 16, 2026
d4ad836
refactor remote port check error handling
yejseo01 Jul 16, 2026
1ab175e
simplify curl exit error handling
yejseo01 Jul 16, 2026
0b413ab
document curl tunnel check options
yejseo01 Jul 16, 2026
f1a501f
Refactor remote port listening check
yejseo01 Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions cmd/topo/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -18,11 +19,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
Expand Down Expand Up @@ -78,8 +80,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: resolveSkipRemotePortCheck(cmd),
UseControlSockets: deploy.SupportsSSHControlSockets(goos),
}
}
switch {
Expand Down Expand Up @@ -127,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") {
Expand All @@ -139,10 +145,20 @@ func resolvePort(cmd *cobra.Command, flagValue string) (string, error) {
return flagValue, nil
}

func resolveSkipRemotePortCheck(cmd *cobra.Command) bool {
flagValue, _ := cmd.Flags().GetBool("skip-remote-port-check")
if cmd.Flags().Changed("skip-remote-port-check") {
return flagValue
}

return env.IsVarTruthy(skipRemotePortCheckEnvVar)
}
Comment thread
awphi marked this conversation as resolved.

func init() {
addTargetFlag(deployCmd)
deployCmd.Flags().StringVarP(&registryPort, "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, 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")
Expand Down
9 changes: 6 additions & 3 deletions internal/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import (
)

type RegistryConfig struct {
Port string
UseControlSockets bool
Port string
SkipRemotePortCheck bool
UseControlSockets bool
}

type DeployOptions struct {
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions internal/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ 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")
opts := deploy.DeployOptions{
TargetHost: remoteDest,
Registry: &deploy.RegistryConfig{
SkipRemotePortCheck: true,
},
}

got, _ := deploy.NewDeployment(composeFile, opts)

_, remotePortCheck, _ := ssh.NewSSHTunnel(remoteDest, opts.Registry.Port, opts.Registry.UseControlSockets)
assert.NotContains(t, got, remotePortCheck)
})
}

func TestDeployment(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions internal/ssh/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 configuration for %q: %w", dest.String(), err)
}
Comment thread
awphi marked this conversation as resolved.

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))
Expand Down
61 changes: 45 additions & 16 deletions internal/ssh/ssh_tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package ssh

import (
"crypto/sha256"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/arm/topo/internal/command"
"github.com/arm/topo/internal/operation"
Expand Down Expand Up @@ -92,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}
}
Expand All @@ -106,27 +109,53 @@ func (ct *CheckRemoteForwardNotExposed) Run(w io.Writer) error {
return nil
}

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
host, err := resolveHostName(ct.TargetDest)
if err != nil {
return remotePortCheckErrorWithSuggestion(err, ct.Port)
}
listening, err := isRemotePortListening(host, ct.Port)
if err != nil {
return remotePortCheckErrorWithSuggestion(err, ct.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", 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 RemotePortRefusedConnection(host, port string) bool {
cmd := exec.Command("curl", fmt.Sprintf("%s:%s", host, port), "--max-time", "5")
cmd.Stdout = io.Discard
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
// 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
Comment thread
awphi marked this conversation as resolved.
cmd.Stderr = io.Discard
_ = cmd.Run()
err := cmd.Run()
if err == nil || remoteIP.Len() > 0 {
return true, nil
}

// 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
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 false

return false, fmt.Errorf("could not verify whether remote port %s is exposed: %w", address, err)
}

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)
}

type SSHTunnelStop struct {
Expand Down
76 changes: 55 additions & 21 deletions internal/ssh/ssh_tunnel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ssh_test

import (
"fmt"
"io"
"net"
"os"
"strings"
Expand Down Expand Up @@ -106,36 +107,69 @@ func TestCheckRemoteForwardNotExposed(t *testing.T) {
assert.Equal(t, "Check tunnel port is not exposed on remote network", got)
})
})
}

func TestRemotePortRefusedConnection(t *testing.T) {
t.Run("it returns true when nothing answers on the remote port", func(t *testing.T) {
port := reserveFreePort(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

assert.True(t, ssh.RemotePortRefusedConnection("127.0.0.1", port))
})
err := check.Run(&output)

assert.NoError(t, err)
assert.Empty(t, output.String())
})

t.Run("it returns false when a TCP listener is bound on the remote port", 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)
t.Run("it fails when the SSH hostname cannot be resolved", func(t *testing.T) {
check := ssh.NewCheckRemoteForwardNotExposed(ssh.Destination{}, "12345")

assert.False(t, ssh.RemotePortRefusedConnection("127.0.0.1", port))
})
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 configuration 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) {
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)

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"))
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", "127.0.0.1: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("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)
})
})
}

func reserveFreePort(t *testing.T) string {
func reserveFreePort(t *testing.T, host string) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
listener, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
require.NoError(t, err)
_, port, err := net.SplitHostPort(listener.Addr().String())
require.NoError(t, err)
Expand Down