From a682b8e655da8af6200c33f4d77ea60e11296716 Mon Sep 17 00:00:00 2001 From: robmry <148866618+robmry@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:11:46 +0000 Subject: [PATCH] Permit '=' separator and '[ipv6]' in --add-host Fixes #4648 Make it easier to specify IPv6 addresses in the '--add-host' option by permitting 'host=ip' in addition to 'host:ip', and allowing square brackets around the address. For example: --add-host=my-hostname:127.0.0.1 --add-host=my-hostname:::1 --add-host=my-hostname=::1 --add-host=my-hostname:[::1] To avoid compatibility problems, the CLI will replace an '=' separator with ':', and strip brackets, before sending the request to the API. Signed-off-by: Rob Murray --- docs/reference/commandline/build.md | 18 ++- docs/reference/commandline/run.md | 30 +++-- man/docker-build.1.md | 6 +- man/docker-run.1.md | 6 +- opts/hosts.go | 46 ++++++-- opts/hosts_test.go | 172 ++++++++++++++++++++++++---- 6 files changed, 226 insertions(+), 52 deletions(-) diff --git a/docs/reference/commandline/build.md b/docs/reference/commandline/build.md index 2f8be4f7f9..c636a794d3 100644 --- a/docs/reference/commandline/build.md +++ b/docs/reference/commandline/build.md @@ -454,12 +454,12 @@ Specifying the `--isolation` flag without a value is the same as setting `--isol ### Add entries to container hosts file (--add-host) -You can add other hosts into a container's `/etc/hosts` file by using one or -more `--add-host` flags. This example adds a static address for a host named -`docker`: +You can add other hosts into a build container's `/etc/hosts` file by using one +or more `--add-host` flags. This example adds static addresses for hosts named +`my-hostname` and `my_hostname_v6`: ```console -$ docker build --add-host docker:10.180.0.1 . +$ docker build --add-host my_hostname=8.8.8.8 --add-host my_hostname_v6=2001:4860:4860::8888 . ``` If you need your build to connect to services running on the host, you can use @@ -467,7 +467,15 @@ the special `host-gateway` value for `--add-host`. In the following example, build containers resolve `host.docker.internal` to the host's gateway IP. ```console -$ docker build --add-host host.docker.internal:host-gateway . +$ docker build --add-host host.docker.internal=host-gateway . +``` + +You can wrap an IPv6 address in square brackets. +`=` and `:` are both valid separators. +Both formats in the following example are valid: + +```console +$ docker build --add-host my-hostname:10.180.0.1 --add-host my-hostname_v6=[2001:4860:4860::8888] . ``` ### Specifying target build stage (--target) diff --git a/docs/reference/commandline/run.md b/docs/reference/commandline/run.md index 0f7dc23691..9bb96ad45f 100644 --- a/docs/reference/commandline/run.md +++ b/docs/reference/commandline/run.md @@ -746,22 +746,28 @@ section of the Docker run reference page. You can add other hosts into a container's `/etc/hosts` file by using one or more `--add-host` flags. This example adds a static address for a host named -`docker`: +`my-hostname`: ```console -$ docker run --add-host=docker:93.184.216.34 --rm -it alpine +$ docker run --add-host=my-hostname=8.8.8.8 --rm -it alpine -/ # ping docker -PING docker (93.184.216.34): 56 data bytes -64 bytes from 93.184.216.34: seq=0 ttl=37 time=93.052 ms -64 bytes from 93.184.216.34: seq=1 ttl=37 time=92.467 ms -64 bytes from 93.184.216.34: seq=2 ttl=37 time=92.252 ms +/ # ping my-hostname +PING my-hostname (8.8.8.8): 56 data bytes +64 bytes from 8.8.8.8: seq=0 ttl=37 time=93.052 ms +64 bytes from 8.8.8.8: seq=1 ttl=37 time=92.467 ms +64 bytes from 8.8.8.8: seq=2 ttl=37 time=92.252 ms ^C ---- docker ping statistics --- +--- my-hostname ping statistics --- 4 packets transmitted, 4 packets received, 0% packet loss round-trip min/avg/max = 92.209/92.495/93.052 ms ``` +You can wrap an IPv6 address in square brackets: + +```console +$ docker run --add-host my-hostname=[2001:db8::33] --rm -it alpine +``` + The `--add-host` flag supports a special `host-gateway` value that resolves to the internal IP address of the host. This is useful when you want containers to connect to services running on the host machine. @@ -779,11 +785,17 @@ $ echo "hello from host!" > ./hello $ python3 -m http.server 8000 Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... $ docker run \ - --add-host host.docker.internal:host-gateway \ + --add-host host.docker.internal=host-gateway \ curlimages/curl -s host.docker.internal:8000/hello hello from host! ``` +The `--add-host` flag also accepts a `:` separator, for example: + +```console +$ docker run --add-host=my-hostname:8.8.8.8 --rm -it alpine +``` + ### Set ulimits in container (--ulimit) Since setting `ulimit` settings in a container requires extra privileges not diff --git a/man/docker-build.1.md b/man/docker-build.1.md index 341a3db3a1..a1088f352e 100644 --- a/man/docker-build.1.md +++ b/man/docker-build.1.md @@ -78,10 +78,10 @@ set as the **URL**, the repository is cloned locally and then sent as the contex layers in tact, and one for the squashed version. **--add-host** [] - Add a custom host-to-IP mapping (host:ip) + Add a custom host-to-IP mapping (host=ip, or host:ip) - Add a line to /etc/hosts. The format is hostname:ip. The **--add-host** -option can be set multiple times. + Add a line to /etc/hosts. The format is hostname=ip, or hostname:ip. + The **--add-host** option can be set multiple times. **--build-arg** *variable* name and value of a **buildarg**. diff --git a/man/docker-run.1.md b/man/docker-run.1.md index 7b6384c668..89e6b1f94b 100644 --- a/man/docker-run.1.md +++ b/man/docker-run.1.md @@ -121,10 +121,10 @@ executables expect) and pass along signals. The **-a** option can be set for each of stdin, stdout, and stderr. **--add-host**=[] - Add a custom host-to-IP mapping (host:ip) + Add a custom host-to-IP mapping (host=ip, or host:ip) - Add a line to /etc/hosts. The format is hostname:ip. The **--add-host** -option can be set multiple times. + Add a line to /etc/hosts. The format is hostname=ip, or hostname:ip. + The **--add-host** option can be set multiple times. **--annotation**=[] Add an annotation to the container (passed through to the OCI runtime). diff --git a/opts/hosts.go b/opts/hosts.go index 7cdd1218f7..552ab6b4a5 100644 --- a/opts/hosts.go +++ b/opts/hosts.go @@ -161,21 +161,53 @@ func ParseTCPAddr(tryAddr string, defaultAddr string) (string, error) { return fmt.Sprintf("tcp://%s%s", net.JoinHostPort(host, port), u.Path), nil } -// ValidateExtraHost validates that the specified string is a valid extrahost and returns it. -// ExtraHost is in the form of name:ip where the ip has to be a valid ip (IPv4 or IPv6). +// ValidateExtraHost validates that the specified string is a valid extrahost and +// returns it. ExtraHost is in the form of name:ip or name=ip, where the ip has +// to be a valid ip (IPv4 or IPv6). The address may be enclosed in square +// brackets. // -// TODO(thaJeztah): remove client-side validation, and delegate to the API server. +// For example: +// +// my-hostname:127.0.0.1 +// my-hostname:::1 +// my-hostname=::1 +// my-hostname:[::1] +// +// For compatibility with the API server, this function normalises the given +// argument to use the ':' separator and strip square brackets enclosing the +// address. func ValidateExtraHost(val string) (string, error) { - // allow for IPv6 addresses in extra hosts by only splitting on first ":" - k, v, ok := strings.Cut(val, ":") - if !ok || k == "" { + k, v, ok := strings.Cut(val, "=") + if !ok { + // allow for IPv6 addresses in extra hosts by only splitting on first ":" + k, v, ok = strings.Cut(val, ":") + } + // Check that a hostname was given, and that it doesn't contain a ":". (Colon + // isn't allowed in a hostname, along with many other characters. It's + // special-cased here because the API server doesn't know about '=' separators in + // '--add-host'. So, it'll split at the first colon and generate a strange error + // message.) + if !ok || k == "" || strings.Contains(k, ":") { return "", fmt.Errorf("bad format for add-host: %q", val) } // Skip IPaddr validation for "host-gateway" string if v != hostGatewayName { + // If the address is enclosed in square brackets, extract it (for IPv6, but + // permit it for IPv4 as well; we don't know the address family here, but it's + // unambiguous). + if len(v) > 2 && v[0] == '[' && v[len(v)-1] == ']' { + v = v[1 : len(v)-1] + } + // ValidateIPAddress returns the address in canonical form (for example, + // 0:0:0:0:0:0:0:1 -> ::1). But, stick with the original form, to avoid + // surprising a user who's expecting to see the address they supplied in the + // output of 'docker inspect' or '/etc/hosts'. if _, err := ValidateIPAddress(v); err != nil { return "", fmt.Errorf("invalid IP address in add-host: %q", v) } } - return val, nil + // This result is passed directly to the API, the daemon doesn't accept the '=' + // separator or an address enclosed in brackets. So, construct something it can + // understand. + return k + ":" + v, nil } diff --git a/opts/hosts_test.go b/opts/hosts_test.go index 4da5f5625c..326d975564 100644 --- a/opts/hosts_test.go +++ b/opts/hosts_test.go @@ -2,8 +2,10 @@ package opts import ( "fmt" - "strings" "testing" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) func TestParseHost(t *testing.T) { @@ -146,32 +148,152 @@ func TestParseInvalidUnixAddrInvalid(t *testing.T) { } func TestValidateExtraHosts(t *testing.T) { - valid := []string{ - `myhost:192.168.0.1`, - `thathost:10.0.2.1`, - `anipv6host:2003:ab34:e::1`, - `ipv6local:::1`, - `host.docker.internal:host-gateway`, + tests := []struct { + doc string + input string + expectedOut string // Expect output==input if not set. + expectedErr string // Expect success if not set. + }{ + { + doc: "IPv4, colon sep", + input: `myhost:192.168.0.1`, + }, + { + doc: "IPv4, eq sep", + input: `myhost=192.168.0.1`, + expectedOut: `myhost:192.168.0.1`, + }, + { + doc: "Weird but permitted, IPv4 with brackets", + input: `myhost=[192.168.0.1]`, + expectedOut: `myhost:192.168.0.1`, + }, + { + doc: "Host and domain", + input: `host.and.domain.invalid:10.0.2.1`, + }, + { + doc: "IPv6, colon sep", + input: `anipv6host:2003:ab34:e::1`, + }, + { + doc: "IPv6, colon sep, brackets", + input: `anipv6host:[2003:ab34:e::1]`, + expectedOut: `anipv6host:2003:ab34:e::1`, + }, + { + doc: "IPv6, eq sep, brackets", + input: `anipv6host=[2003:ab34:e::1]`, + expectedOut: `anipv6host:2003:ab34:e::1`, + }, + { + doc: "IPv6 localhost, colon sep", + input: `ipv6local:::1`, + }, + { + doc: "IPv6 localhost, eq sep", + input: `ipv6local=::1`, + expectedOut: `ipv6local:::1`, + }, + { + doc: "IPv6 localhost, eq sep, brackets", + input: `ipv6local=[::1]`, + expectedOut: `ipv6local:::1`, + }, + { + doc: "IPv6 localhost, non-canonical, colon sep", + input: `ipv6local:0:0:0:0:0:0:0:1`, + }, + { + doc: "IPv6 localhost, non-canonical, eq sep", + input: `ipv6local=0:0:0:0:0:0:0:1`, + expectedOut: `ipv6local:0:0:0:0:0:0:0:1`, + }, + { + doc: "IPv6 localhost, non-canonical, eq sep, brackets", + input: `ipv6local=[0:0:0:0:0:0:0:1]`, + expectedOut: `ipv6local:0:0:0:0:0:0:0:1`, + }, + { + doc: "host-gateway special case, colon sep", + input: `host.docker.internal:host-gateway`, + }, + { + doc: "host-gateway special case, eq sep", + input: `host.docker.internal=host-gateway`, + expectedOut: `host.docker.internal:host-gateway`, + }, + { + doc: "Bad address, colon sep", + input: `myhost:192.notanipaddress.1`, + expectedErr: `invalid IP address in add-host: "192.notanipaddress.1"`, + }, + { + doc: "Bad address, eq sep", + input: `myhost=192.notanipaddress.1`, + expectedErr: `invalid IP address in add-host: "192.notanipaddress.1"`, + }, + { + doc: "No sep", + input: `thathost-nosemicolon10.0.0.1`, + expectedErr: `bad format for add-host: "thathost-nosemicolon10.0.0.1"`, + }, + { + doc: "Bad IPv6", + input: `anipv6host:::::1`, + expectedErr: `invalid IP address in add-host: "::::1"`, + }, + { + doc: "Bad IPv6, trailing colons", + input: `ipv6local:::0::`, + expectedErr: `invalid IP address in add-host: "::0::"`, + }, + { + doc: "Bad IPv6, missing close bracket", + input: `ipv6addr=[::1`, + expectedErr: `invalid IP address in add-host: "[::1"`, + }, + { + doc: "Bad IPv6, missing open bracket", + input: `ipv6addr=::1]`, + expectedErr: `invalid IP address in add-host: "::1]"`, + }, + { + doc: "Missing address, colon sep", + input: `myhost.invalid:`, + expectedErr: `invalid IP address in add-host: ""`, + }, + { + doc: "Missing address, eq sep", + input: `myhost.invalid=`, + expectedErr: `invalid IP address in add-host: ""`, + }, + { + doc: "IPv6 localhost, bad name", + input: `:=::1`, + expectedErr: `bad format for add-host: ":=::1"`, + }, + { + doc: "No input", + input: ``, + expectedErr: `bad format for add-host: ""`, + }, } - invalid := map[string]string{ - `myhost:192.notanipaddress.1`: `invalid IP`, - `thathost-nosemicolon10.0.0.1`: `bad format`, - `anipv6host:::::1`: `invalid IP`, - `ipv6local:::0::`: `invalid IP`, - } - - for _, extrahost := range valid { - if _, err := ValidateExtraHost(extrahost); err != nil { - t.Fatalf("ValidateExtraHost(`"+extrahost+"`) should succeed: error %v", err) - } - } - - for extraHost, expectedError := range invalid { - if _, err := ValidateExtraHost(extraHost); err == nil { - t.Fatalf("ValidateExtraHost(`%q`) should have failed validation", extraHost) - } else if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("ValidateExtraHost(`%q`) error should contain %q", extraHost, expectedError) + for _, tc := range tests { + tc := tc + if tc.expectedOut == "" { + tc.expectedOut = tc.input } + t.Run(tc.input, func(t *testing.T) { + actualOut, actualErr := ValidateExtraHost(tc.input) + if tc.expectedErr == "" { + assert.Check(t, is.Equal(tc.expectedOut, actualOut)) + assert.NilError(t, actualErr) + } else { + assert.Check(t, actualOut == "") + assert.Check(t, is.Error(actualErr, tc.expectedErr)) + } + }) } }