Repository: moby/moby (language: go)

Issue:
Cannot change ipv6-cidr in daemon.json anymore - dockerd fails to start
### Description

hello,

i try to reconfigure my docker default bridge, but after i used
```
{
  "ipv6": true,
  "fixed-cidr-v6": "fe80::/64",
  "ip6tables": true,
  "experimental": true
}
```
in daemon.json, its created ipv6 addres on docker0, but when i want to change the cidr in daemon.json, i cannot restart via systemctl restart docker. it fails with
```
Nov 17 17:36:05 gbc dockerd[8448]: time="2023-11-17T17:36:05.968731732+01:00" level=info msg="Loading containers: start."
Nov 17 17:36:06 gbc dockerd[8448]: time="2023-11-17T17:36:06.381431577+01:00" level=info msg="Default bridge (docker0) is assigned with an IP address 172.17.0.0/16. Daemon option --bip can be used to set a preferred IP address"
Nov 17 17:36:06 gbc dockerd[8448]: time="2023-11-17T17:36:06.382374332+01:00" level=info msg="stopping event stream following graceful shutdown" error="<nil>" module=libcontainerd namespace=moby
Nov 17 17:36:06 gbc dockerd[8448]: failed to start daemon: Error initializing network controller: error creating default "bridge" network: failed to add IPv6 address fe80::1/80 to bridge: file exists
Nov 17 17:36:06 gbc systemd[1]: docker.service: Main process exited, code=exited, status=1/FAILURE
```
i tried to forcefully remove via "sudo rm /var/lib/docker/network/files/local-kv.db", the folder is empty, but dockerd --debug still spits out the same error that the file exists.
after reediting the daemon.json to the old /64 cidr, restarting works again.

regards

### Reproduce

1. edit daemon.json to 
```
{
  "ipv6": true,
  "fixed-cidr-v6": "fe80::/64",
  "ip6tables": true,
  "experimental": true
}
```
2. restart docker
3. reedit daemon.json, change cidr like /80
4. try to restart via systemctl restart docker

### Expected behavior

_No response_

### docker version

```bash
Client: Docker Engine - Community
 Version:           24.0.7
 API version:       1.43
 Go version:        go1.20.10
 Git commit:        afdd53b
 Built:             Thu Oct 26 09:08:02 2023
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          24.0.7
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.10
  Git commit:       311b9ff
  Built:            Thu Oct 26 09:08:02 2023
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.6.24
  GitCommit:        61f9fd88f79f081d64d6fa3bb1a0dc71ec870523
 runc:
  Version:          1.1.9
  GitCommit:        v1.1.9-0-gccaecfc
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### docker info

```bash
Version:    24.0.7
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.11.2
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.21.0
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
 Containers: 11
  Running: 11
  Paused: 0
  Stopped: 0
 Images: 24
 Server Version: 24.0.7
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 61f9fd88f79f081d64d6fa3bb1a0dc71ec870523
 runc version: v1.1.9-0-gccaecfc
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.1.0-13-amd64
 Operating System: Debian GNU/Linux 12 (bookworm)
 OSType: linux
 Architecture: x86_64
 CPUs: 8
 Total Memory: 31.08GiB
 Name: gbc
 ID: a13438fe-d85c-4149-886b-05d2850e11e4
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: true
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```


### Additional Info

on my older local machine with debian 12 repo docker, i can change the cidr as-i-wish and restart wihtout a problme.

```
Client:
 Version:           20.10.24+dfsg1
 API version:       1.41
 Go version:        go1.19.8
 Git commit:        297e128
 Built:             Thu May 18 08:38:34 2023
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server:
 Engine:
  Version:          20.10.24+dfsg1
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.19.8
  Git commit:       5d6db84
  Built:            Thu May 18 08:38:34 2023
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.6.20~ds1
  GitCommit:        1.6.20~ds1-1+b1
 runc:
  Version:          1.1.5+ds1
  GitCommit:        1.1.5+ds1-1+b1
 docker-init:
  Version:          0.19.0
  GitCommit:
```
```
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc., v0.11.2)
  compose: Docker Compose (Docker Inc., v2.21.0)

Server:
 Containers: 20
  Running: 18
  Paused: 0
  Stopped: 2
 Images: 44
 Server Version: 20.10.24+dfsg1
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 io.containerd.runtime.v1.linux runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 1.6.20~ds1-1+b1
 runc version: 1.1.5+ds1-1+b1
 init version:
 Security Options:
  apparmor
  seccomp
   Profile: default
  cgroupns
 Kernel Version: 6.1.0-13-amd64
 Operating System: Debian GNU/Linux 12 (bookworm)
 OSType: linux
 Architecture: x86_64
 CPUs: 8
 Total Memory: 14.47GiB
 Name: debian-svr
 ID: NYSZ:IIV3:YFGV:N4P6:HDDA:ZUES:WA7W:UVKX:TAVP:3INY:X7QQ:7UFQ
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Registry: https://index.docker.io/v1/
 Labels:
 Experimental: true
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```



Discussion / hints:
The link-local addresses used in the example are a special case. But, the same applies to any address, for example - start the daemon with `fixed-cidr-v6` of `fec0::/64`, change the config to `fec0::/80` and the daemon fails to start.

The problem is the daemon adds required addresses before looking for unwanted addresses to remove. I'll create an initial PR to deal with that.

For link-local addresses - I'm not yet sure what's happening. The existing code always assigns the default `fe80::1/64`, regardless of the configured address. The PR I'll create in a minute allows that subnet to be overridden, but it doesn't make things work properly for a network that only has link-local addresses - which I think would be the intention.

With only link-local addresses, the problem may be link-local addresses being treated as a normal address pool, and management of that pool not working properly. In any case, here's the behaviour I see so far ...

As reported, when `fixed-cidr-v6` is an overlapping link-local address, the daemon doesn't start. When `fixed-cidr-v6` is the default link local address, `fe80::1/64`, the daemon does start (what actually happens is the default address is assigned, so when the daemon comes to assign the configured address it sees it's already there and does nothing).

It's possible to run a container on that network, the container ends up with two link local addresses. The same happens for subsequent containers added to that bridge - until a container is deleted. After that, it's not possible to add any new containers. For example:

```
# dockerd [...] --fixed-cidr-v6=fe80::/64

# docker run -ti alpine sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN qlen 1000
    link/tunnel6 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 brd 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
126: eth0@if127: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe12:2/64 scope link
       valid_lft forever preferred_lft forever
    inet6 fe80::42:242:ac12:2/64 scope link flags 02
       valid_lft forever preferred_lft forever
/ # exit

# docker run -ti alpine sh
docker: Error response from daemon: Address already in use.
ERRO[0000] error waiting for container:
#
```

It is possible to start the daemon with a non-overlapping link-local subnet, for example `--fixed-cidr-v6=fe80:1234::/42`. But, this also results in two link local networks because the default `fe80::/64` is still assigned first, which probably isn't what was expected:

```
155: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
    link/ether 02:42:dc:22:60:2d brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.1/16 brd 172.18.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80:1234::1/42 scope link tentative
       valid_lft forever preferred_lft forever
    inet6 fe80::1/64 scope link tentative
       valid_lft forever preferred_lft forever
```

For `docker network create`d bridge networks, things are a little different.

When the daemon has a non-link-local `fixed-cidr-v6`, `docker network create --subnet=fe80::/64` works.

Similar to the `docker0` case, the container gets two link local addresses. In this case, rather than containers failing to start after one has exited, the pool-assigned addresses leak - each new container gets a new link local address (probably not a problem in itself, because the pool is huge, but something's wrong)...

```
# docker run -ti --network testnet alpine sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN qlen 1000
    link/tunnel6 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 brd 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
132: eth0@if133: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether 02:42:ac:13:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.19.0.2/16 brd 172.19.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe13:2/64 scope link
       valid_lft forever preferred_lft forever
    inet6 fe80::2/64 scope link flags 02
       valid_lft forever preferred_lft forever
/ # exit

# docker run -ti --network testnet alpine sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN qlen 1000
    link/tunnel6 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 brd 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
134: eth0@if135: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether 02:42:ac:13:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.19.0.2/16 brd 172.19.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe13:2/64 scope link
       valid_lft forever preferred_lft forever
    inet6 fe80::3/64 scope link flags 02
       valid_lft forever preferred_lft forever
/ #
```

It's not possible to change the size of the link local subnet with `docker network create --subnet`, but it is possible to add a second (non-overlapping) link local network:

```
# docker network create --ipv6 --subnet=fe80::/42 testnet
Error response from daemon: failed to add IPv6 address fe80::1/42 to bridge: file exists

# docker network create --ipv6 --subnet=fe80:1234::/50 testnet
a53c53289f89709f48a73ac74dd3e438540edaf329e1cbfc7165470ae84c0a71
```

maybe i didnt pointed that out, somehow i broke my docker installation, even if i put a valid ula cidr in there like fd00::/64 it breaks in that point, maybe i should'nt put a link local range in the first place there. since then its broken and cant put any new cidr in there. i went over to just create new bridges with ula ranges for ipv6 to enable ipv6 in a container. 
btw. is it possible to set a flag when creating a bridge for ip6tables to allow e.g. icmp6? it opens the ports according to the compose-file/cli but blocks icmp by default, was a bit tricky to find it out. i was playing with my router and prefix delegation. this works so far.
```
For link-local addresses - I'm not yet sure what's happening. The existing code always assigns the default fe80::1/64, regardless of the configured address.
```
when creating a ipv6 bridge, gateway always assigns to ::1 and all other containerns joining get ipv6 counted upwards e.g. ::2 ::3 ff. didnt found it how to enable randomized ipv6 token (ula or assigned pd)

> maybe i didnt pointed that out, somehow i broke my docker installation, even if i put a valid ula cidr in there like fd00::/64 it breaks in that point, maybe i should'nt put a link local range in the first place there. since then its broken and cant put any new cidr in there.

Thank you for the update. What error message are you getting now?

> i went over to just create new bridges with ula ranges for ipv6 to enable ipv6 in a container.

> when creating a ipv6 bridge, gateway always assigns to ::1 and all other containerns joining get ipv6 counted upwards e.g. ::2 ::3 ff. didnt found it how to enable randomized ipv6 token (ula or assigned pd)

Those addresses are assigned for a bridge network created via `docker network create` or the docker-compose equivalent, rather than the default bridge. If you're not using the default bridge, you don't need the `fixed-cidr-v6` or `ipv6` options in your `daemon.json` file ... if they're still there, could you try removing them to see if dockerd will start? (If `fixed-cidr-v6` overlaps with any other networks you've created, the daemon won't start.) Also, with dockerd stopped, deleting the default bridge won't hurt and might clean things up, `ip link del docker0`.

> btw. is it possible to set a flag when creating a bridge for ip6tables to allow e.g. icmp6? it opens the ports according to the compose-file/cli but blocks icmp by default, was a bit tricky to find it out. i was playing with my router and prefix delegation. this works so far.

I think it should be ok - perhaps we could get the networking sorted out and dockerd running, then come back to that as a separate question? (But, depending on the OS in the container you're running, to make pings work you may need to add `--cap-add=NET_ADMIN` to your `docker run` command, or the equivalent in your compose config?)
For link-local addresses ...

The only problem seems to be that they're aren't released back into the pool, because of an explicit check in the endpoint code. I'll update the PR to deal with that.

The difference in behaviour I saw between docker0 and other networks was simply because, for the default network, container addresses are assigned based on the container's MAC address (to preserve some legacy behaviour). That IP address isn't returned to the pool when the container's deleted so, once a MAC address is recycled, the container won't start - because the same calculated IP address is needed.

For other non-default networks, pool addresses leak, so each container gets a new address.

Linux always assumes the default `fe80::/64` and assigns addresses to interfaces based on that. So, when using a different link-local subnet, interfaces will get two LL addresses - one from docker, one from Linux.
The changes for this ticket fix the problem described in https://forums.docker.com/t/docker-containers-fail-to-restart-with-ipv6-enabled-with-lla-fixed-cidr/137567 ... when using a link-local subnet on the default bridge, containers won't restart.

For the default bridge only, IP addresses are based on the container's MAC address, and the MAC address is recycled. LL IP addresses weren't released, so couldn't be re-allocated for the restarted container.

Fix patch:
diff --git a/daemon/config/config_linux.go b/daemon/config/config_linux.go
index 39f2d16610d83..b5fca392a3b21 100644
--- a/daemon/config/config_linux.go
+++ b/daemon/config/config_linux.go
@@ -9,6 +9,7 @@ import (
 	"github.com/containerd/cgroups/v3"
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/system"
+	"github.com/docker/docker/libnetwork/drivers/bridge"
 	"github.com/docker/docker/opts"
 	"github.com/docker/docker/pkg/homedir"
 	"github.com/docker/docker/pkg/rootless"
@@ -175,6 +176,10 @@ func (conf *Config) ValidatePlatformConfig() error {
 		return err
 	}
 
+	if err := bridge.ValidateFixedCIDRV6(conf.FixedCIDRv6); err != nil {
+		return errors.Wrap(err, "invalid fixed-cidr-v6")
+	}
+
 	return verifyDefaultCgroupNsMode(conf.CgroupNamespaceMode)
 }
 
diff --git a/libnetwork/drivers/bridge/bridge_linux.go b/libnetwork/drivers/bridge/bridge_linux.go
index a9b54cfce3955..c6f61de02825e 100644
--- a/libnetwork/drivers/bridge/bridge_linux.go
+++ b/libnetwork/drivers/bridge/bridge_linux.go
@@ -1,8 +1,8 @@
 package bridge
 
 import (
+	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"net"
 	"os"
@@ -11,6 +11,7 @@ import (
 	"sync"
 
 	"github.com/containerd/log"
+	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/libnetwork/datastore"
 	"github.com/docker/docker/libnetwork/driverapi"
 	"github.com/docker/docker/libnetwork/iptables"
@@ -22,6 +23,7 @@ import (
 	"github.com/docker/docker/libnetwork/portmapper"
 	"github.com/docker/docker/libnetwork/scope"
 	"github.com/docker/docker/libnetwork/types"
+	"github.com/pkg/errors"
 	"github.com/vishvananda/netlink"
 )
 
@@ -179,6 +181,44 @@ func Register(r driverapi.Registerer, config map[string]interface{}) error {
 	})
 }
 
+// The behaviour of previous implementations of bridge subnet prefix assignment
+// is preserved here...
+//
+// The LL prefix, 'fe80::/64' can be used as an IPAM pool. Linux always assigns
+// link-local addresses with this prefix. But, pool-assigned addresses are very
+// unlikely to conflict.
+//
+// Don't allow a nonstandard LL subnet to overlap with 'fe80::/64'. For example,
+// if the config asked for subnet prefix 'fe80::/80', the bridge and its
+// containers would each end up with two LL addresses, Linux's '/64' and one from
+// the IPAM pool claiming '/80'. Although the specified prefix length must not
+// affect the host's determination of whether the address is on-link and to be
+// added to the interface's Prefix List (RFC-5942), differing prefix lengths
+// would be confusing and have been disallowed by earlier implementations of
+// bridge address assignment.
+func validateIPv6Subnet(addr *net.IPNet) error {
+	if addr != nil && bridgeIPv6.Contains(addr.IP) && !bytes.Equal(bridgeIPv6.Mask, addr.Mask) {
+		return errdefs.InvalidParameter(errors.New("clash with the Link-Local prefix 'fe80::/64'"))
+	}
+	return nil
+}
+
+// ValidateFixedCIDRV6 checks that val is an IPv6 address and prefix length that
+// does not overlap with the link local subnet prefix 'fe80::/64'.
+func ValidateFixedCIDRV6(val string) error {
+	if val == "" {
+		return nil
+	}
+	ip, ipNet, err := net.ParseCIDR(val)
+	if err != nil {
+		return errdefs.InvalidParameter(err)
+	}
+	if ip.To4() != nil {
+		return errdefs.InvalidParameter(errors.New("fixed-cidr-v6 is not an IPv6 subnet"))
+	}
+	return validateIPv6Subnet(ipNet)
+}
+
 // Validate performs a static validation on the network configuration parameters.
 // Whatever can be assessed a priori before attempting any programming.
 func (c *networkConfiguration) Validate() error {
@@ -516,6 +556,13 @@ func (c *networkConfiguration) processIPAM(id string, ipamV4Data, ipamV6Data []d
 		}
 	}
 
+	// TODO(robmry) - move this to networkConfiguration.Validate()
+	//   - but that can't happen until Validate() is called after processIPAM() has set
+	//     up the IP addresses, instead of during parseNetworkOptions().
+	if err := validateIPv6Subnet(c.AddressIPv6); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -759,9 +806,9 @@ func (d *driver) createNetwork(config *networkConfiguration) (err error) {
 		// assigned an IPv6 link-local address.
 		{config.EnableIPv6, setupBridgeIPv6},
 
-		// We ensure that the bridge has the expectedIPv4 and IPv6 addresses in
-		// the case of a previously existing device.
-		{bridgeAlreadyExists && !config.InhibitIPv4, setupVerifyAndReconcile},
+		// Ensure the bridge has the expected IPv4 addresses in the case of a previously
+		// existing device.
+		{bridgeAlreadyExists && !config.InhibitIPv4, setupVerifyAndReconcileIPv4},
 
 		// Enable IPv6 Forwarding
 		{enableIPv6Forwarding, setupIPv6Forwarding},
@@ -1059,7 +1106,6 @@ func (d *driver) CreateEndpoint(nid, eid string, ifInfo driverapi.InterfaceInfo,
 		if config.AddressIPv6 != nil {
 			network = config.AddressIPv6
 		}
-
 		ones, _ := network.Mask.Size()
 		if ones > 80 {
 			err = types.ForbiddenErrorf("Cannot self generate an IPv6 address on network %v: At least 48 host bits are needed.", network)
diff --git a/libnetwork/drivers/bridge/errors.go b/libnetwork/drivers/bridge/errors.go
index ad72900c124bd..bf03157f8d176 100644
--- a/libnetwork/drivers/bridge/errors.go
+++ b/libnetwork/drivers/bridge/errors.go
@@ -222,19 +222,6 @@ func (ipv4 *IPv4AddrAddError) Error() string {
 // InternalError denotes the type of this error
 func (ipv4 *IPv4AddrAddError) InternalError() {}
 
-// IPv6AddrAddError is returned when IPv6 address could not be added to the bridge.
-type IPv6AddrAddError struct {
-	IP  *net.IPNet
-	Err error
-}
-
-func (ipv6 *IPv6AddrAddError) Error() string {
-	return fmt.Sprintf("failed to add IPv6 address %s to bridge: %v", ipv6.IP, ipv6.Err)
-}
-
-// InternalError denotes the type of this error
-func (ipv6 *IPv6AddrAddError) InternalError() {}
-
 // IPv4AddrNoMatchError is returned when the bridge's IPv4 address does not match configured.
 type IPv4AddrNoMatchError struct {
 	IP    net.IP
diff --git a/libnetwork/drivers/bridge/interface_linux.go b/libnetwork/drivers/bridge/interface_linux.go
index 6618fd66bd56d..add9c231b5e26 100644
--- a/libnetwork/drivers/bridge/interface_linux.go
+++ b/libnetwork/drivers/bridge/interface_linux.go
@@ -4,8 +4,11 @@ import (
 	"context"
 	"fmt"
 	"net"
+	"net/netip"
 
 	"github.com/containerd/log"
+	"github.com/docker/docker/errdefs"
+	"github.com/docker/docker/libnetwork/internal/netiputil"
 	"github.com/vishvananda/netlink"
 )
 
@@ -53,39 +56,103 @@ func (i *bridgeInterface) exists() bool {
 	return i.Link != nil
 }
 
-// addresses returns all IPv4 addresses and all IPv6 addresses for the bridge interface.
-func (i *bridgeInterface) addresses() ([]netlink.Addr, []netlink.Addr, error) {
+// addresses returns a bridge's addresses, IPv4 (with family=netlink.FAMILY_V4)
+// or IPv6 (family=netlink.FAMILY_V6).
+func (i *bridgeInterface) addresses(family int) ([]netlink.Addr, error) {
 	if !i.exists() {
 		// A nonexistent interface, by definition, cannot have any addresses.
-		return nil, nil, nil
+		return nil, nil
 	}
-	v4addr, err := i.nlh.AddrList(i.Link, netlink.FAMILY_V4)
+	addrs, err := i.nlh.AddrList(i.Link, family)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Failed to retrieve V4 addresses: %v", err)
+		return nil, fmt.Errorf("Failed to retrieve addresses: %v", err)
 	}
+	return addrs, nil
+}
 
-	v6addr, err := i.nlh.AddrList(i.Link, netlink.FAMILY_V6)
-	if err != nil {
-		return nil, nil, fmt.Errorf("Failed to retrieve V6 addresses: %v", err)
+func getRequiredIPv6Addrs(config *networkConfiguration) (requiredAddrs map[netip.Addr]netip.Prefix, addr *net.IPNet, gateway net.IP, err error) {
+	requiredAddrs = make(map[netip.Addr]netip.Prefix)
+
+	// TODO(robmry) - is config.AddressIPv6 always set at this point?
+	//   The logic here is preserved from the original setupBridgeIPv6(), but
+	//   can probably be simplified.
+
+	// Always give the bridge 'fe80::1' - every interface is required to have an
+	// address in 'fe80::/64'. Linux may assign an address, but we'll replace it with
+	// 'fe80::1'. Then, if the configured prefix is 'fe80::/64', the IPAM pool
+	// assigned address will not be a second address in the LL subnet.
+	addr = bridgeIPv6
+	gateway = bridgeIPv6.IP
+	ra, ok := netiputil.ToPrefix(bridgeIPv6)
+	if !ok {
+		err = fmt.Errorf("Failed to convert Link-Local IPv6 address to netip.Prefix")
+		return nil, nil, net.IP{}, err
 	}
+	requiredAddrs[ra.Addr()] = ra
 
-	if len(v4addr) == 0 {
-		return nil, v6addr, nil
+	// Set up the user-specified bridge address and gateway.
+	if config.AddressIPv6 != nil {
+		addr = config.AddressIPv6
+		gateway = config.AddressIPv6.IP
+		ra, ok := netiputil.ToPrefix(config.AddressIPv6)
+		if !ok {
+			err = fmt.Errorf("failed to convert bridge IPv6 address '%s' to netip.Prefix", config.AddressIPv6.String())
+			return nil, nil, net.IP{}, err
+		}
+		requiredAddrs[ra.Addr()] = ra
 	}
-	return v4addr, v6addr, nil
+
+	return requiredAddrs, addr, gateway, nil
 }
 
-func (i *bridgeInterface) programIPv6Address() error {
-	_, nlAddressList, err := i.addresses()
+func (i *bridgeInterface) programIPv6Addresses(config *networkConfiguration) error {
+	// Get the IPv6 addresses currently assigned to the bridge, if any.
+	existingAddrs, err := i.addresses(netlink.FAMILY_V6)
 	if err != nil {
-		return &IPv6AddrAddError{IP: i.bridgeIPv6, Err: fmt.Errorf("failed to retrieve address list: %v", err)}
+		return errdefs.System(err)
 	}
-	nlAddr := netlink.Addr{IPNet: i.bridgeIPv6}
-	if findIPv6Address(nlAddr, nlAddressList) {
-		return nil
+
+	// Get the list of required IPv6 addresses for this bridge.
+	var requiredAddrs map[netip.Addr]netip.Prefix
+	requiredAddrs, i.bridgeIPv6, i.gatewayIPv6, err = getRequiredIPv6Addrs(config)
+	if err != nil {
+		return errdefs.System(err)
+	}
+
+	// Remove addresses that aren't required.
+	for _, existingAddr := range existingAddrs {
+		ea, ok := netip.AddrFromSlice(existingAddr.IP)
+		if !ok {
+			return errdefs.System(fmt.Errorf("Failed to convert IPv6 address '%s' to netip.Addr", config.AddressIPv6))
+		}
+		// Ignore the prefix length when comparing addresses, it's informational
+		// (RFC-5942 section 4), and removing/re-adding an address that's still valid
+		// would disrupt traffic on live-restore.
+		if _, required := requiredAddrs[ea]; !required {
+			err := i.nlh.AddrDel(i.Link, &existingAddr) //#nosec G601 -- Memory aliasing is not an issue in practice as the &existingAddr pointer is not retained by the callee after the AddrDel() call returns.
+			if err != nil {
+				log.G(context.TODO()).WithFields(log.Fields{"error": err, "address": existingAddr.IPNet}).Warnf("Failed to remove residual IPv6 address from bridge")
+			}
+		}
 	}
-	if err := i.nlh.AddrAdd(i.Link, &nlAddr); err != nil {
-		return &IPv6AddrAddError{IP: i.bridgeIPv6, Err: err}
+	// Add or update required addresses.
+	for _, addrPrefix := range requiredAddrs {
+		// Using AddrReplace(), rather than AddrAdd(). When the subnet is changed for an
+		// existing bridge in a way that doesn't affect the bridge's assigned address,
+		// the old address has not been removed at this point - because that would be
+		// service-affecting for a running container.
+		//
+		// But if, for example, 'fixed-cidr-v6' is changed from '2000:dbe::/64' to
+		// '2000:dbe::/80', the default bridge will still be assigned address
+		// '2000:dbe::1'. In the output of 'ip a', the prefix length is displayed - and
+		// the user is likely to expect to see it updated from '64' to '80'.
+		// Unfortunately, 'netlink.AddrReplace()' ('RTM_NEWADDR' with 'NLM_F_REPLACE')
+		// doesn't update the prefix length. This is a cosmetic problem, the prefix
+		// length of an assigned address is not used to determine whether an address is
+		// "on-link" (RFC-5942).
+		if err := i.nlh.AddrReplace(i.Link, &netlink.Addr{IPNet: netiputil.ToIPNet(addrPrefix)}); err != nil {
+			return errdefs.System(fmt.Errorf("failed to add IPv6 address %s to bridge: %v", i.bridgeIPv6, err))
+		}
 	}
 	return nil
 }
diff --git a/libnetwork/drivers/bridge/setup_ipv4_linux.go b/libnetwork/drivers/bridge/setup_ipv4_linux.go
index 30096014e7859..0940745c23b4c 100644
--- a/libnetwork/drivers/bridge/setup_ipv4_linux.go
+++ b/libnetwork/drivers/bridge/setup_ipv4_linux.go
@@ -37,7 +37,7 @@ func setupBridgeIPv4(config *networkConfiguration, i *bridgeInterface) error {
 	}
 
 	if !config.InhibitIPv4 {
-		addrv4List, _, err := i.addresses()
+		addrv4List, err := i.addresses(netlink.FAMILY_V4)
 		if err != nil {
 			return fmt.Errorf("failed to retrieve bridge interface addresses: %v", err)
 		}
diff --git a/libnetwork/drivers/bridge/setup_ipv6_linux.go b/libnetwork/drivers/bridge/setup_ipv6_linux.go
index 6cc12926fe22e..08ce0f4397386 100644
--- a/libnetwork/drivers/bridge/setup_ipv6_linux.go
+++ b/libnetwork/drivers/bridge/setup_ipv6_linux.go
@@ -32,23 +32,9 @@ func setupBridgeIPv6(config *networkConfiguration, i *bridgeInterface) error {
 		}
 	}
 
-	// Store bridge network and default gateway
-	i.bridgeIPv6 = bridgeIPv6
-	i.gatewayIPv6 = i.bridgeIPv6.IP
-
-	if err := i.programIPv6Address(); err != nil {
-		return err
-	}
-
-	if config.AddressIPv6 == nil {
-		return nil
-	}
-
-	// Store the user specified bridge network and network gateway and program it
-	i.bridgeIPv6 = config.AddressIPv6
-	i.gatewayIPv6 = config.AddressIPv6.IP
-
-	if err := i.programIPv6Address(); err != nil {
+	// Remove unwanted addresses from the bridge, add required addresses, and assign
+	// values to "i.bridgeIPv6", "i.gatewayIPv6".
+	if err := i.programIPv6Addresses(config); err != nil {
 		return err
 	}
 
diff --git a/libnetwork/drivers/bridge/setup_verify_linux.go b/libnetwork/drivers/bridge/setup_verify_linux.go
index c05fa7e2420c9..a39d7503460c5 100644
--- a/libnetwork/drivers/bridge/setup_verify_linux.go
+++ b/libnetwork/drivers/bridge/setup_verify_linux.go
@@ -1,28 +1,25 @@
 package bridge
 
 import (
-	"context"
 	"fmt"
 	"strings"
 
-	"github.com/containerd/log"
 	"github.com/docker/docker/libnetwork/ns"
-	"github.com/docker/docker/libnetwork/types"
 	"github.com/vishvananda/netlink"
 )
 
-// setupVerifyAndReconcile checks what IP addresses the given i interface has and ensures that they match the passed
-// network config. It also removes any extra unicast IPv6 addresses found.
-func setupVerifyAndReconcile(config *networkConfiguration, i *bridgeInterface) error {
+// setupVerifyAndReconcileIPv4 checks what IPv4 addresses the given i interface has
+// and ensures that they match the passed network config.
+func setupVerifyAndReconcileIPv4(config *networkConfiguration, i *bridgeInterface) error {
 	// Fetch a slice of IPv4 addresses and a slice of IPv6 addresses from the bridge.
-	addrsv4, addrsv6, err := i.addresses()
+	addrsv4, err := i.addresses(netlink.FAMILY_V4)
 	if err != nil {
 		return fmt.Errorf("Failed to verify ip addresses: %v", err)
 	}
 
 	addrv4, _ := selectIPv4Address(addrsv4, config.AddressIPv4)
 
-	// Verify that the bridge does have an IPv4 address.
+	// Verify that the bridge has an IPv4 address.
 	if !config.Internal && addrv4.IPNet == nil {
 		return &ErrNoIPAddr{}
 	}
@@ -32,34 +29,9 @@ func setupVerifyAndReconcile(config *networkConfiguration, i *bridgeInterface) e
 		return &IPv4AddrNoMatchError{IP: addrv4.IP, CfgIP: config.AddressIPv4.IP}
 	}
 
-	// Verify that one of the bridge IPv6 addresses matches the requested
-	// configuration.
-	if config.EnableIPv6 && !config.Internal && !findIPv6Address(netlink.Addr{IPNet: bridgeIPv6}, addrsv6) {
-		return (*IPv6AddrNoMatchError)(bridgeIPv6)
-	}
-
-	// Release any residual IPv6 address that might be there because of older daemon instances
-	for _, addrv6 := range addrsv6 {
-		addrv6 := addrv6
-		if addrv6.IP.IsGlobalUnicast() && !types.CompareIPNet(addrv6.IPNet, i.bridgeIPv6) {
-			if err := i.nlh.AddrDel(i.Link, &addrv6); err != nil {
-				log.G(context.TODO()).Warnf("Failed to remove residual IPv6 address %s from bridge: %v", addrv6.IPNet, err)
-			}
-		}
-	}
-
 	return nil
 }
 
-func findIPv6Address(addr netlink.Addr, addresses []netlink.Addr) bool {
-	for _, addrv6 := range addresses {
-		if addrv6.String() == addr.String() {
-			return true
-		}
-	}
-	return false
-}
-
 func bridgeInterfaceExists(name string) (bool, error) {
 	nlh := ns.NlHandle()
 	link, err := nlh.LinkByName(name)
diff --git a/libnetwork/endpoint.go b/libnetwork/endpoint.go
index 79a2689366f92..16e92b547b269 100644
--- a/libnetwork/endpoint.go
+++ b/libnetwork/endpoint.go
@@ -1121,7 +1121,7 @@ func (ep *Endpoint) releaseAddress() {
 		}
 	}
 
-	if ep.iface.addrv6 != nil && ep.iface.addrv6.IP.IsGlobalUnicast() {
+	if ep.iface.addrv6 != nil {
 		if err := ipam.ReleaseAddress(ep.iface.v6PoolID, ep.iface.addrv6.IP); err != nil {
 			log.G(context.TODO()).Warnf("Failed to release ip address %s on delete of endpoint %s (%s): %v", ep.iface.addrv6.IP, ep.Name(), ep.ID(), err)
 		}
diff --git a/libnetwork/ipam/utils.go b/libnetwork/internal/netiputil/netiputil.go
similarity index 54%
rename from libnetwork/ipam/utils.go
rename to libnetwork/internal/netiputil/netiputil.go
index 4e7d72c2e052e..76cb5922a3e00 100644
--- a/libnetwork/ipam/utils.go
+++ b/libnetwork/internal/netiputil/netiputil.go
@@ -1,4 +1,4 @@
-package ipam
+package netiputil
 
 import (
 	"net"
@@ -7,7 +7,8 @@ import (
 	"github.com/docker/docker/libnetwork/ipbits"
 )
 
-func toIPNet(p netip.Prefix) *net.IPNet {
+// ToIPNet converts p into a *net.IPNet, returning nil if p is not valid.
+func ToIPNet(p netip.Prefix) *net.IPNet {
 	if !p.IsValid() {
 		return nil
 	}
@@ -17,7 +18,9 @@ func toIPNet(p netip.Prefix) *net.IPNet {
 	}
 }
 
-func toPrefix(n *net.IPNet) (netip.Prefix, bool) {
+// ToPrefix converts n into a netip.Prefix. If n is not a valid IPv4 or IPV6
+// address, ToPrefix returns netip.Prefix{}, false.
+func ToPrefix(n *net.IPNet) (netip.Prefix, bool) {
 	if ll := len(n.Mask); ll != net.IPv4len && ll != net.IPv6len {
 		return netip.Prefix{}, false
 	}
@@ -35,14 +38,16 @@ func toPrefix(n *net.IPNet) (netip.Prefix, bool) {
 	return netip.PrefixFrom(addr.Unmap(), ones), true
 }
 
-func hostID(addr netip.Addr, bits uint) uint64 {
+// HostID masks out the 'bits' most-significant bits of addr. The result is
+// undefined if bits > addr.BitLen().
+func HostID(addr netip.Addr, bits uint) uint64 {
 	return ipbits.Field(addr, bits, uint(addr.BitLen()))
 }
 
-// subnetRange returns the amount to add to network.Addr() in order to yield the
+// SubnetRange returns the amount to add to network.Addr() in order to yield the
 // first and last addresses in subnet, respectively.
-func subnetRange(network, subnet netip.Prefix) (start, end uint64) {
-	start = hostID(subnet.Addr(), uint(network.Bits()))
+func SubnetRange(network, subnet netip.Prefix) (start, end uint64) {
+	start = HostID(subnet.Addr(), uint(network.Bits()))
 	end = start + (1 << uint64(subnet.Addr().BitLen()-subnet.Bits())) - 1
 	return start, end
 }
diff --git a/libnetwork/ipam/allocator.go b/libnetwork/ipam/allocator.go
index 55b51643d1b00..79376c49bbaf0 100644
--- a/libnetwork/ipam/allocator.go
+++ b/libnetwork/ipam/allocator.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/containerd/log"
 	"github.com/docker/docker/libnetwork/bitmap"
+	"github.com/docker/docker/libnetwork/internal/netiputil"
 	"github.com/docker/docker/libnetwork/ipamapi"
 	"github.com/docker/docker/libnetwork/ipbits"
 	"github.com/docker/docker/libnetwork/types"
@@ -46,7 +47,7 @@ func newAddrSpace(predefined []*net.IPNet) (*addrSpace, error) {
 	pdf := make([]netip.Prefix, len(predefined))
 	for i, n := range predefined {
 		var ok bool
-		pdf[i], ok = toPrefix(n)
+		pdf[i], ok = netiputil.ToPrefix(n)
 		if !ok {
 			return nil, fmt.Errorf("network at index %d (%v) is not in canonical form", i, n)
 		}
@@ -91,7 +92,7 @@ func (a *Allocator) RequestPool(addressSpace, requestedPool, requestedSubPool st
 		if err != nil {
 			return "", nil, nil, err
 		}
-		return k.String(), toIPNet(k.Subnet), nil, nil
+		return k.String(), netiputil.ToIPNet(k.Subnet), nil, nil
 	}
 
 	if k.Subnet, err = netip.ParsePrefix(requestedPool); err != nil {
@@ -119,7 +120,7 @@ func (a *Allocator) RequestPool(addressSpace, requestedPool, requestedSubPool st
 		return "", nil, nil, err
 	}
 
-	return k.String(), toIPNet(k.Subnet), nil, nil
+	return k.String(), netiputil.ToIPNet(k.Subnet), nil, nil
 }
 
 // ReleasePool releases the address pool identified by the passed id
@@ -336,7 +337,7 @@ func (aSpace *addrSpace) releaseAddress(nw, sub netip.Prefix, address netip.Addr
 
 	defer log.G(context.TODO()).Debugf("Released address Address:%v Sequence:%s", address, p.addrs)
 
-	return p.addrs.Unset(hostID(address, uint(nw.Bits())))
+	return p.addrs.Unset(netiputil.HostID(address, uint(nw.Bits())))
 }
 
 func getAddress(base netip.Prefix, bitmask *bitmap.Bitmap, prefAddress netip.Addr, ipr netip.Prefix, serial bool) (netip.Addr, error) {
@@ -353,10 +354,10 @@ func getAddress(base netip.Prefix, bitmask *bitmap.Bitmap, prefAddress netip.Add
 	if ipr == (netip.Prefix{}) && prefAddress == (netip.Addr{}) {
 		ordinal, err = bitmask.SetAny(serial)
 	} else if prefAddress != (netip.Addr{}) {
-		ordinal = hostID(prefAddress, uint(base.Bits()))
+		ordinal = netiputil.HostID(prefAddress, uint(base.Bits()))
 		err = bitmask.Set(ordinal)
 	} else {
-		start, end := subnetRange(base, ipr)
+		start, end := netiputil.SubnetRange(base, ipr)
 		ordinal, err = bitmask.SetAnyInRange(start, end, serial)
 	}
 


Test patch:
diff --git a/integration/networking/bridge_test.go b/integration/networking/bridge_test.go
index f9c92909fa238..e3d1fe2a361cc 100644
--- a/integration/networking/bridge_test.go
+++ b/integration/networking/bridge_test.go
@@ -86,6 +86,32 @@ func TestBridgeICC(t *testing.T) {
 			},
 			linkLocal: true,
 		},
+		{
+			// As for 'LL non-internal', but ping the container by name instead of by address
+			// - the busybox test containers only have one interface with a link local
+			// address, so the zone index is not required:
+			//   RFC-4007, section 6: "[...] for nodes with only a single non-loopback
+			//   interface (e.g., a single Ethernet interface), the common case, link-local
+			//   addresses need not be qualified with a zone index."
+			// So, for this common case, LL addresses should be included in DNS config.
+			name: "IPv6 link-local address on non-internal network ping by name",
+			bridgeOpts: []func(*types.NetworkCreate){
+				network.WithIPv6(),
+				network.WithIPAM("fe80::/64", "fe80::1"),
+			},
+		},
+		{
+			name: "IPv6 nonstandard link-local subnet on non-internal network ping by name",
+			// No interfaces apart from the one on the bridge network with this non-default
+			// subnet will be on this link local subnet (it's not currently possible to
+			// configure two networks with the same LL subnet, although perhaps it should
+			// be). So, again, no zone index is required and the LL address should be
+			// included in DNS config.
+			bridgeOpts: []func(*types.NetworkCreate){
+				network.WithIPv6(),
+				network.WithIPAM("fe80:1234::/64", "fe80:1234::1"),
+			},
+		},
 		{
 			name: "IPv6 non-internal network with SLAAC LL address",
 			bridgeOpts: []func(*types.NetworkCreate){
@@ -292,3 +318,162 @@ func TestBridgeINC(t *testing.T) {
 		})
 	}
 }
+
+func TestDefaultBridgeIPv6(t *testing.T) {
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
+
+	ctx := setupTest(t)
+
+	testcases := []struct {
+		name          string
+		fixed_cidr_v6 string
+	}{
+		{
+			name:          "IPv6 ULA",
+			fixed_cidr_v6: "fd00:1234::/64",
+		},
+		{
+			name:          "IPv6 LLA only",
+			fixed_cidr_v6: "fe80::/64",
+		},
+		{
+			name:          "IPv6 nonstandard LLA only",
+			fixed_cidr_v6: "fe80:1234::/64",
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := testutil.StartSpan(ctx, t)
+
+			d := daemon.New(t)
+			d.StartWithBusybox(ctx, t,
+				"--experimental",
+				"--ip6tables",
+				"--ipv6",
+				"--fixed-cidr-v6", tc.fixed_cidr_v6,
+			)
+			defer d.Stop(t)
+
+			c := d.NewClientT(t)
+			defer c.Close()
+
+			cID := container.Run(ctx, t, c,
+				container.WithImage("busybox:latest"),
+				container.WithCmd("top"),
+			)
+			defer c.ContainerRemove(ctx, cID, containertypes.RemoveOptions{
+				Force: true,
+			})
+
+			networkName := "bridge"
+			inspect := container.Inspect(ctx, t, c, cID)
+			pingHost := inspect.NetworkSettings.Networks[networkName].GlobalIPv6Address
+
+			attachCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+			defer cancel()
+			res := container.RunAttach(attachCtx, t, c,
+				container.WithImage("busybox:latest"),
+				container.WithCmd("ping", "-c1", "-W3", pingHost),
+			)
+			defer c.ContainerRemove(ctx, res.ContainerID, containertypes.RemoveOptions{
+				Force: true,
+			})
+
+			assert.Check(t, is.Equal(res.ExitCode, 0))
+			assert.Check(t, is.Equal(res.Stderr.String(), ""))
+			assert.Check(t, is.Contains(res.Stdout.String(), "1 packets transmitted, 1 packets received"))
+		})
+	}
+}
+
+// Check that it's possible to change 'fixed-cidr-v6' and restart the daemon.
+func TestDefaultBridgeAddresses(t *testing.T) {
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
+
+	ctx := setupTest(t)
+	d := daemon.New(t)
+
+	type testStep struct {
+		stepName    string
+		fixedCIDRV6 string
+		expAddrs    []string
+	}
+
+	testcases := []struct {
+		name  string
+		steps []testStep
+	}{
+		{
+			name: "Unique-Local Subnet Changes",
+			steps: []testStep{
+				{
+					stepName:    "Set up initial UL prefix",
+					fixedCIDRV6: "fd1c:f1a0:5d8d:aaaa::/64",
+					expAddrs:    []string{"fd1c:f1a0:5d8d:aaaa::1/64", "fe80::1/64"},
+				},
+				{
+					// Modify that prefix, the default bridge's address must be deleted and re-added.
+					stepName:    "Modify UL prefix - address change",
+					fixedCIDRV6: "fd1c:f1a0:5d8d:bbbb::/64",
+					expAddrs:    []string{"fd1c:f1a0:5d8d:bbbb::1/64", "fe80::1/64"},
+				},
+				{
+					// Modify the prefix length, the default bridge's address should not change.
+					stepName:    "Modify UL prefix - no address change",
+					fixedCIDRV6: "fd1c:f1a0:5d8d:bbbb::/80",
+					// The prefix length displayed by 'ip a' is not updated - it's informational, and
+					// can't be changed without unnecessarily deleting and re-adding the address.
+					expAddrs: []string{"fd1c:f1a0:5d8d:bbbb::1/64", "fe80::1/64"},
+				},
+			},
+		},
+		{
+			name: "Link-Local Subnet Changes",
+			steps: []testStep{
+				{
+					stepName:    "Standard LL subnet prefix",
+					fixedCIDRV6: "fe80::/64",
+					expAddrs:    []string{"fe80::1/64"},
+				},
+				{
+					// Modify that prefix, the default bridge's address must be deleted and re-added.
+					// The bridge must still have an address in the required (standard) LL subnet.
+					stepName:    "Nonstandard LL prefix - address change",
+					fixedCIDRV6: "fe80:1234::/32",
+					expAddrs:    []string{"fe80:1234::1/32", "fe80::1/64"},
+				},
+				{
+					// Modify the prefix length, the addresses should not change.
+					stepName:    "Modify LL prefix - no address change",
+					fixedCIDRV6: "fe80:1234::/64",
+					// The prefix length displayed by 'ip a' is not updated - it's informational, and
+					// can't be changed without unnecessarily deleting and re-adding the address.
+					expAddrs: []string{"fe80:1234::1/", "fe80::1/64"},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			for _, step := range tc.steps {
+				// Check that the daemon starts - regression test for:
+				//   https://github.com/moby/moby/issues/46829
+				d.Start(t, "--experimental", "--ipv6", "--ip6tables", "--fixed-cidr-v6="+step.fixedCIDRV6)
+				d.Stop(t)
+
+				// Check that the expected addresses have been applied to the bridge. (Skip in
+				// rootless mode, because the bridge is in a different network namespace.)
+				if !testEnv.IsRootless() {
+					res := testutil.RunCommand(ctx, "ip", "-6", "addr", "show", "docker0")
+					assert.Equal(t, res.ExitCode, 0, step.stepName)
+					stdout := res.Stdout()
+					for _, expAddr := range step.expAddrs {
+						assert.Check(t, is.Contains(stdout, expAddr))
+					}
+				}
+			}
+		})
+	}
+}
diff --git a/libnetwork/drivers/bridge/bridge_linux_test.go b/libnetwork/drivers/bridge/bridge_linux_test.go
index 1f93ef1e444c2..1f9bd51231f34 100644
--- a/libnetwork/drivers/bridge/bridge_linux_test.go
+++ b/libnetwork/drivers/bridge/bridge_linux_test.go
@@ -19,6 +19,8 @@ import (
 	"github.com/docker/docker/libnetwork/portallocator"
 	"github.com/docker/docker/libnetwork/types"
 	"github.com/vishvananda/netlink"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
 func TestEndpointMarshalling(t *testing.T) {
@@ -964,6 +966,81 @@ func TestValidateConfig(t *testing.T) {
 	}
 }
 
+func TestValidateFixedCIDRV6(t *testing.T) {
+	tests := []struct {
+		doc, input, expectedErr string
+	}{
+		{
+			doc:   "valid",
+			input: "2001:db8::/32",
+		},
+		{
+			// fixed-cidr-v6 doesn't have to be specified.
+			doc: "empty",
+		},
+		{
+			// Using the LL subnet prefix is ok.
+			doc:   "Link-Local subnet prefix",
+			input: "fe80::/64",
+		},
+		{
+			// Using a nonstandard LL prefix that doesn't overlap with the standard LL prefix
+			// is ok.
+			doc:   "non-overlapping link-local prefix",
+			input: "fe80:1234::/80",
+		},
+		{
+			// Overlapping with the standard LL prefix isn't allowed.
+			doc:         "overlapping link-local prefix fe80::/63",
+			input:       "fe80::/63",
+			expectedErr: "clash with the Link-Local prefix 'fe80::/64'",
+		},
+		{
+			// Overlapping with the standard LL prefix isn't allowed.
+			doc:         "overlapping link-local subnet fe80::/65",
+			input:       "fe80::/65",
+			expectedErr: "clash with the Link-Local prefix 'fe80::/64'",
+		},
+		{
+			// The address has to be valid IPv6 subnet.
+			doc:         "invalid IPv6 subnet",
+			input:       "2000:db8::",
+			expectedErr: "invalid CIDR address: 2000:db8::",
+		},
+		{
+			doc:         "non-IPv6 subnet",
+			input:       "10.3.4.5/24",
+			expectedErr: "fixed-cidr-v6 is not an IPv6 subnet",
+		},
+		{
+			doc:         "IPv4-mapped subnet 1",
+			input:       "::ffff:10.2.4.0/24",
+			expectedErr: "fixed-cidr-v6 is not an IPv6 subnet",
+		},
+		{
+			doc:         "IPv4-mapped subnet 2",
+			input:       "::ffff:a01:203/24",
+			expectedErr: "fixed-cidr-v6 is not an IPv6 subnet",
+		},
+		{
+			doc:         "invalid subnet",
+			input:       "nonsense",
+			expectedErr: "invalid CIDR address: nonsense",
+		},
+	}
+	for _, tc := range tests {
+		tc := tc
+		t.Run(tc.doc, func(t *testing.T) {
+			err := ValidateFixedCIDRV6(tc.input)
+			if tc.expectedErr == "" {
+				assert.Check(t, err)
+			} else {
+				assert.Check(t, is.Error(err, tc.expectedErr))
+			}
+		})
+	}
+}
+
 func TestSetDefaultGw(t *testing.T) {
 	defer netnsutils.SetupTestOSContext(t)()
 
@@ -1098,7 +1175,7 @@ func TestCreateWithExistingBridge(t *testing.T) {
 		t.Fatalf("Failed to getNetwork(%s): %v", brName, err)
 	}
 
-	addrs4, _, err := nw.bridge.addresses()
+	addrs4, err := nw.bridge.addresses(netlink.FAMILY_V4)
 	if err != nil {
 		t.Fatalf("Failed to get the bridge network's address: %v", err)
 	}
diff --git a/libnetwork/drivers/bridge/interface_linux_test.go b/libnetwork/drivers/bridge/interface_linux_test.go
index d7859131d0dfb..734c69b4dd983 100644
--- a/libnetwork/drivers/bridge/interface_linux_test.go
+++ b/libnetwork/drivers/bridge/interface_linux_test.go
@@ -1,12 +1,43 @@
 package bridge
 
 import (
+	"net"
+	"net/netip"
+	"strings"
 	"testing"
 
 	"github.com/docker/docker/internal/testutils/netnsutils"
+	"github.com/google/go-cmp/cmp"
 	"github.com/vishvananda/netlink"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
+func cidrToIPNet(t *testing.T, cidr string) *net.IPNet {
+	t.Helper()
+	ip, ipNet, err := net.ParseCIDR(cidr)
+	assert.Assert(t, is.Nil(err))
+	return &net.IPNet{IP: ip, Mask: ipNet.Mask}
+}
+
+func addAddr(t *testing.T, link netlink.Link, addr string) {
+	t.Helper()
+	ipNet := cidrToIPNet(t, addr)
+	err := netlink.AddrAdd(link, &netlink.Addr{IPNet: ipNet})
+	assert.Assert(t, is.Nil(err))
+}
+
+func prepTestBridge(t *testing.T, nc *networkConfiguration) *bridgeInterface {
+	t.Helper()
+	nh, err := netlink.NewHandle()
+	assert.Assert(t, err)
+	i, err := newInterface(nh, nc)
+	assert.Assert(t, err)
+	err = setupDevice(nc, i)
+	assert.Assert(t, err)
+	return i
+}
+
 func TestInterfaceDefaultName(t *testing.T) {
 	defer netnsutils.SetupTestOSContext(t)()
 
@@ -16,35 +47,142 @@ func TestInterfaceDefaultName(t *testing.T) {
 	}
 	config := &networkConfiguration{}
 	_, err = newInterface(nh, config)
-	if err != nil {
-		t.Fatalf("newInterface() failed: %v", err)
-	}
+	assert.Check(t, err)
+	assert.Equal(t, config.BridgeName, DefaultBridgeName)
+}
 
-	if config.BridgeName != DefaultBridgeName {
-		t.Fatalf("Expected default interface name %q, got %q", DefaultBridgeName, config.BridgeName)
-	}
+func TestAddressesNoInterface(t *testing.T) {
+	i := bridgeInterface{}
+	addrs, err := i.addresses(netlink.FAMILY_V6)
+	assert.NilError(t, err)
+	assert.Check(t, is.Len(addrs, 0))
 }
 
 func TestAddressesEmptyInterface(t *testing.T) {
 	defer netnsutils.SetupTestOSContext(t)()
 
 	nh, err := netlink.NewHandle()
-	if err != nil {
-		t.Fatal(err)
-	}
+	assert.NilError(t, err)
+
 	inf, err := newInterface(nh, &networkConfiguration{})
-	if err != nil {
-		t.Fatalf("newInterface() failed: %v", err)
-	}
+	assert.NilError(t, err)
 
-	addrsv4, addrsv6, err := inf.addresses()
-	if err != nil {
-		t.Fatalf("Failed to get addresses of default interface: %v", err)
+	addrsv4, err := inf.addresses(netlink.FAMILY_V4)
+	assert.NilError(t, err)
+	assert.Check(t, is.Len(addrsv4, 0))
+
+	addrsv6, err := inf.addresses(netlink.FAMILY_V6)
+	assert.NilError(t, err)
+	assert.Check(t, is.Len(addrsv6, 0))
+}
+
+func TestAddressesNonEmptyInterface(t *testing.T) {
+	defer netnsutils.SetupTestOSContext(t)()
+
+	i := prepTestBridge(t, &networkConfiguration{})
+
+	const expAddrV4, expAddrV6 = "192.168.1.2/24", "fd00:1234::/64"
+	addAddr(t, i.Link, expAddrV4)
+	addAddr(t, i.Link, expAddrV6)
+
+	addrs, err := i.addresses(netlink.FAMILY_V4)
+	assert.NilError(t, err)
+	assert.Check(t, is.Len(addrs, 1))
+	assert.Equal(t, addrs[0].IPNet.String(), expAddrV4)
+
+	addrs, err = i.addresses(netlink.FAMILY_V6)
+	assert.NilError(t, err)
+	assert.Check(t, is.Len(addrs, 1))
+	assert.Equal(t, addrs[0].IPNet.String(), expAddrV6)
+}
+
+func TestGetRequiredIPv6Addrs(t *testing.T) {
+	testcases := []struct {
+		name         string
+		addressIPv6  string
+		expReqdAddrs []string
+	}{
+		{
+			name:         "Regular address, expect default link local",
+			addressIPv6:  "2000:3000::1/80",
+			expReqdAddrs: []string{"fe80::1/64", "2000:3000::1/80"},
+		},
+		{
+			name:         "Standard link local address only",
+			addressIPv6:  "fe80::1/64",
+			expReqdAddrs: []string{"fe80::1/64"},
+		},
+		{
+			name:         "Nonstandard link local address",
+			addressIPv6:  "fe80:abcd::1/42",
+			expReqdAddrs: []string{"fe80:abcd::1/42", "fe80::1/64"},
+		},
 	}
-	if len(addrsv4) != 0 {
-		t.Fatalf("Default interface has unexpected IPv4: %s", addrsv4)
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			config := &networkConfiguration{
+				AddressIPv6: cidrToIPNet(t, tc.addressIPv6),
+			}
+
+			expResult := map[netip.Addr]netip.Prefix{}
+			for _, addr := range tc.expReqdAddrs {
+				expResult[netip.MustParseAddr(strings.Split(addr, "/")[0])] = netip.MustParsePrefix(addr)
+			}
+
+			reqd, addr, gw, err := getRequiredIPv6Addrs(config)
+			assert.Check(t, is.Nil(err))
+			assert.Check(t, is.DeepEqual(addr, config.AddressIPv6))
+			assert.Check(t, is.DeepEqual(gw, config.AddressIPv6.IP))
+			assert.Check(t, is.DeepEqual(reqd, expResult,
+				cmp.Comparer(func(a, b netip.Prefix) bool { return a == b })))
+		})
 	}
-	if len(addrsv6) != 0 {
-		t.Fatalf("Default interface has unexpected IPv6: %v", addrsv6)
+}
+
+func TestProgramIPv6Addresses(t *testing.T) {
+	defer netnsutils.SetupTestOSContext(t)()
+
+	checkAddrs := func(i *bridgeInterface, expAddrs []string) {
+		t.Helper()
+		exp := []netlink.Addr{}
+		for _, a := range expAddrs {
+			ipNet := cidrToIPNet(t, a)
+			exp = append(exp, netlink.Addr{IPNet: ipNet})
+		}
+		actual, err := i.addresses(netlink.FAMILY_V6)
+		assert.NilError(t, err)
+		assert.DeepEqual(t, exp, actual)
 	}
+
+	nc := &networkConfiguration{}
+	i := prepTestBridge(t, nc)
+
+	// The bridge has no addresses, ask for a regular IPv6 network and expect it to
+	// be added to the bridge, with the default link local address.
+	nc.AddressIPv6 = cidrToIPNet(t, "2000:3000::1/64")
+	err := i.programIPv6Addresses(nc)
+	assert.NilError(t, err)
+	checkAddrs(i, []string{"2000:3000::1/64", "fe80::1/64"})
+
+	// Shrink the subnet of that regular address, the prefix length of the address
+	// will not be modified - but it's informational-only, the address itself has
+	// not changed.
+	nc.AddressIPv6 = cidrToIPNet(t, "2000:3000::1/80")
+	err = i.programIPv6Addresses(nc)
+	assert.NilError(t, err)
+	checkAddrs(i, []string{"2000:3000::1/64", "fe80::1/64"})
+
+	// Ask for link-local only, by specifying an address with the Link Local prefix.
+	// The regular address should be removed.
+	nc.AddressIPv6 = cidrToIPNet(t, "fe80::1/64")
+	err = i.programIPv6Addresses(nc)
+	assert.NilError(t, err)
+	checkAddrs(i, []string{"fe80::1/64"})
+
+	// Swap the standard link local address for a nonstandard one.
+	nc.AddressIPv6 = cidrToIPNet(t, "fe80:5555::1/55")
+	err = i.programIPv6Addresses(nc)
+	assert.NilError(t, err)
+	checkAddrs(i, []string{"fe80:5555::1/55", "fe80::1/64"})
 }
diff --git a/libnetwork/drivers/bridge/setup_verify_linux_test.go b/libnetwork/drivers/bridge/setup_verify_linux_test.go
index f6850ba202954..045f3a16812e4 100644
--- a/libnetwork/drivers/bridge/setup_verify_linux_test.go
+++ b/libnetwork/drivers/bridge/setup_verify_linux_test.go
@@ -38,7 +38,7 @@ func TestSetupVerify(t *testing.T) {
 		t.Fatalf("Failed to assign IPv4 %s to interface: %v", config.AddressIPv4, err)
 	}
 
-	if err := setupVerifyAndReconcile(config, inf); err != nil {
+	if err := setupVerifyAndReconcileIPv4(config, inf); err != nil {
 		t.Fatalf("Address verification failed: %v", err)
 	}
 }
@@ -56,7 +56,7 @@ func TestSetupVerifyBad(t *testing.T) {
 		t.Fatalf("Failed to assign IPv4 %s to interface: %v", ipnet, err)
 	}
 
-	if err := setupVerifyAndReconcile(config, inf); err == nil {
+	if err := setupVerifyAndReconcileIPv4(config, inf); err == nil {
 		t.Fatal("Address verification was expected to fail")
 	}
 }
@@ -69,46 +69,7 @@ func TestSetupVerifyMissing(t *testing.T) {
 	config := &networkConfiguration{}
 	config.AddressIPv4 = &net.IPNet{IP: addrv4, Mask: addrv4.DefaultMask()}
 
-	if err := setupVerifyAndReconcile(config, inf); err == nil {
-		t.Fatal("Address verification was expected to fail")
-	}
-}
-
-func TestSetupVerifyIPv6(t *testing.T) {
-	defer netnsutils.SetupTestOSContext(t)()
-
-	addrv4 := net.IPv4(192, 168, 1, 1)
-	inf := setupVerifyTest(t)
-	config := &networkConfiguration{}
-	config.AddressIPv4 = &net.IPNet{IP: addrv4, Mask: addrv4.DefaultMask()}
-	config.EnableIPv6 = true
-
-	if err := netlink.AddrAdd(inf.Link, &netlink.Addr{IPNet: bridgeIPv6}); err != nil {
-		t.Fatalf("Failed to assign IPv6 %s to interface: %v", bridgeIPv6, err)
-	}
-	if err := netlink.AddrAdd(inf.Link, &netlink.Addr{IPNet: config.AddressIPv4}); err != nil {
-		t.Fatalf("Failed to assign IPv4 %s to interface: %v", config.AddressIPv4, err)
-	}
-
-	if err := setupVerifyAndReconcile(config, inf); err != nil {
-		t.Fatalf("Address verification failed: %v", err)
-	}
-}
-
-func TestSetupVerifyIPv6Missing(t *testing.T) {
-	defer netnsutils.SetupTestOSContext(t)()
-
-	addrv4 := net.IPv4(192, 168, 1, 1)
-	inf := setupVerifyTest(t)
-	config := &networkConfiguration{}
-	config.AddressIPv4 = &net.IPNet{IP: addrv4, Mask: addrv4.DefaultMask()}
-	config.EnableIPv6 = true
-
-	if err := netlink.AddrAdd(inf.Link, &netlink.Addr{IPNet: config.AddressIPv4}); err != nil {
-		t.Fatalf("Failed to assign IPv4 %s to interface: %v", config.AddressIPv4, err)
-	}
-
-	if err := setupVerifyAndReconcile(config, inf); err == nil {
+	if err := setupVerifyAndReconcileIPv4(config, inf); err == nil {
 		t.Fatal("Address verification was expected to fail")
 	}
 }