Skip to content

Commit

Permalink
Permit '=' separator and '[ipv6]' in --add-host
Browse files Browse the repository at this point in the history
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=hostname:127.0.0.1
    --add-host=hostname:::1
    --add-host=hostname=::1
    --add-host=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 <[email protected]>
  • Loading branch information
robmry committed Nov 16, 2023
1 parent a6114fc commit 0cecb05
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 44 deletions.
2 changes: 1 addition & 1 deletion cli/command/container/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
flags.SetAnnotation("cgroupns", "version", []string{"1.41"})

// Network and port publishing flag
flags.Var(&copts.extraHosts, "add-host", "Add a custom host-to-IP mapping (host:ip)")
flags.Var(&copts.extraHosts, "add-host", "Add a custom host-to-IP mapping (host:ip, or host=ip)")
flags.Var(&copts.dns, "dns", "Set custom DNS servers")
// We allow for both "--dns-opt" and "--dns-option", although the latter is the recommended way.
// This is to be consistent with service create/update
Expand Down
2 changes: 1 addition & 1 deletion cli/command/image/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ func NewBuildCommand(dockerCli command.Cli) *cobra.Command {
flags.StringSliceVar(&options.securityOpt, "security-opt", []string{}, "Security options")
flags.StringVar(&options.networkMode, "network", "default", "Set the networking mode for the RUN instructions during build")
flags.SetAnnotation("network", "version", []string{"1.25"})
flags.Var(&options.extraHosts, "add-host", `Add a custom host-to-IP mapping ("host:ip")`)
flags.Var(&options.extraHosts, "add-host", `Add a custom host-to-IP mapping ("host:ip" or "host=ip")`)
flags.StringVar(&options.target, "target", "", "Set the target build stage to build.")
flags.StringVar(&options.imageIDFile, "iidfile", "", "Write the image ID to the file")

Expand Down
8 changes: 7 additions & 1 deletion docs/reference/commandline/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Build an image from a Dockerfile

| Name | Type | Default | Description |
|:------------------------------------|:--------------|:----------|:------------------------------------------------------------------|
| [`--add-host`](#add-host) | `list` | | Add a custom host-to-IP mapping (`host:ip`) |
| [`--add-host`](#add-host) | `list` | | Add a custom host-to-IP mapping (`host:ip` or `host=ip`) |
| [`--build-arg`](#build-arg) | `list` | | Set build-time variables |
| [`--cache-from`](#cache-from) | `stringSlice` | | Images to consider as cache sources |
| [`--cgroup-parent`](#cgroup-parent) | `string` | | Set the parent cgroup for the `RUN` instructions during build |
Expand Down Expand Up @@ -462,6 +462,12 @@ more `--add-host` flags. This example adds a static address for a host named
$ docker build --add-host docker:10.180.0.1 .
```

For IPv6 addresses, the equivalent `host=ip` form may be clearer, for example:

```console
$ docker build --add-host thishost=::1 .
```

If you need your build to connect to services running on the host, you can use
the special `host-gateway` value for `--add-host`. In the following example,
build containers resolve `host.docker.internal` to the host's gateway IP.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commandline/image_build.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Build an image from a Dockerfile

| Name | Type | Default | Description |
|:--------------------------|:--------------|:----------|:------------------------------------------------------------------|
| `--add-host` | `list` | | Add a custom host-to-IP mapping (`host:ip`) |
| `--add-host` | `list` | | Add a custom host-to-IP mapping (`host:ip` or `host=ip`) |
| `--build-arg` | `list` | | Set build-time variables |
| `--cache-from` | `stringSlice` | | Images to consider as cache sources |
| `--cgroup-parent` | `string` | | Set the parent cgroup for the `RUN` instructions during build |
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/commandline/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Create and run a new container from an image

| Name | Type | Default | Description |
|:----------------------------------------------|:--------------|:----------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`--add-host`](#add-host) | `list` | | Add a custom host-to-IP mapping (host:ip) |
| [`--add-host`](#add-host) | `list` | | Add a custom host-to-IP mapping (host:ip, or host=ip) |
| `--annotation` | `map` | `map[]` | Add an annotation to the container (passed through to the OCI runtime) |
| [`-a`](#attach), [`--attach`](#attach) | `list` | | Attach to STDIN, STDOUT or STDERR |
| `--blkio-weight` | `uint16` | `0` | Block IO (relative weight), between 10 and 1000, or 0 to disable (default 0) |
Expand Down Expand Up @@ -762,6 +762,12 @@ PING docker (93.184.216.34): 56 data bytes
round-trip min/avg/max = 92.209/92.495/93.052 ms
```

For IPv6 addresses, the equivalent `host=ip` form may be clearer. For example:

```console
$ docker run --add-host=thishost=::1 --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.
Expand Down
6 changes: 3 additions & 3 deletions man/docker-build.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
6 changes: 3 additions & 3 deletions man/docker-run.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 39 additions & 7 deletions opts/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
// hostname:127.0.0.1
// hostname:::1
// hostname=::1
// 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
}
172 changes: 146 additions & 26 deletions opts/hosts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -146,34 +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`,
}

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`,
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: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: `missing.address.colon:`,
expectedErr: `invalid IP address in add-host: ""`,
},
{
doc: "Missing address, eq sep",
input: `missing.address.eq=`,
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: ""`,
},
}

for _, extrahost := range valid {
if _, err := ValidateExtraHost(extrahost); err != nil {
t.Fatalf("ValidateExtraHost(`"+extrahost+"`) should succeed: error %v", err)
for _, tc := range tests {
tc := tc
if tc.expectedOut == "" {
tc.expectedOut = tc.input
}
}

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

0 comments on commit 0cecb05

Please sign in to comment.