diff --git a/cli/command/swarm/opts.go b/cli/command/swarm/opts.go index 1aa90e757bac..a8821ee936cb 100644 --- a/cli/command/swarm/opts.go +++ b/cli/command/swarm/opts.go @@ -5,6 +5,7 @@ import ( "encoding/pem" "errors" "fmt" + "net" "os" "strings" "time" @@ -74,7 +75,15 @@ func (*NodeAddrOption) Type() string { // Value returns the value of this option as addr:port func (a *NodeAddrOption) Value() string { - return strings.TrimPrefix(a.addr, "tcp://") + addr := strings.TrimPrefix(a.addr, "tcp://") + host, port, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + // ParseTCPAddr returns a URL-safe zone identifier, but net.Listen expects + // the raw percent sign in an IPv6 zone. + host = strings.ReplaceAll(host, "%25", "%") + return net.JoinHostPort(host, port) } // NewNodeAddrOption returns a new node address option diff --git a/cli/command/swarm/opts_test.go b/cli/command/swarm/opts_test.go index 407cfa0f9a3d..97acd94064f8 100644 --- a/cli/command/swarm/opts_test.go +++ b/cli/command/swarm/opts_test.go @@ -26,6 +26,12 @@ func TestNodeAddrOptionSetHostOnlyIPv6(t *testing.T) { assert.Check(t, is.Equal("[::1]:2377", opt.Value())) } +func TestNodeAddrOptionSetHostAndPortIPv6Zone(t *testing.T) { + opt := NewListenAddrOption() + assert.NilError(t, opt.Set("tcp://[fe80::1%25lo]:5555")) + assert.Check(t, is.Equal("[fe80::1%lo]:5555", opt.Value())) +} + func TestNodeAddrOptionSetPortOnly(t *testing.T) { opt := NewListenAddrOption() assert.NilError(t, opt.Set(":4545")) diff --git a/opts/hosts.go b/opts/hosts.go index dcbbb7e78166..52c8464699b6 100644 --- a/opts/hosts.go +++ b/opts/hosts.go @@ -141,6 +141,10 @@ func ParseTCPAddr(tryAddr string, defaultAddr string) (string, error) { return "", fmt.Errorf("invalid bind address format: %s", tryAddr) } + // url.Parse unescapes the zone identifier in an IPv6 address. Re-escape + // its percent sign when formatting the address as a URL, otherwise the + // returned address cannot be parsed again. + host = strings.ReplaceAll(host, "%", "%25") return fmt.Sprintf("tcp://%s%s", net.JoinHostPort(host, port), u.Path), nil } diff --git a/opts/hosts_test.go b/opts/hosts_test.go index 28f4eb6fab9e..10898bf796e7 100644 --- a/opts/hosts_test.go +++ b/opts/hosts_test.go @@ -2,6 +2,7 @@ package opts import ( "net" + "net/url" "testing" "gotest.tools/v3/assert" @@ -135,6 +136,16 @@ func TestParseTCP(t *testing.T) { } } +func TestParseTCPAddrIPv6Zone(t *testing.T) { + addr, err := ParseTCPAddr("tcp://[fe80::1%25lo]:5555", "tcp://127.0.0.1:2376") + assert.NilError(t, err) + assert.Check(t, is.Equal("tcp://[fe80::1%25lo]:5555", addr)) + + u, err := url.Parse(addr) + assert.NilError(t, err) + assert.Check(t, is.Equal("fe80::1%lo", u.Hostname())) +} + func TestParseInvalidUnixAddrInvalid(t *testing.T) { if _, err := parseSimpleProtoAddr("unix", "tcp://127.0.0.1", "unix:///var/run/docker.sock"); err == nil || err.Error() != "invalid proto, expected unix: tcp://127.0.0.1" { t.Fatalf("Expected an error, got %v", err)