Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow announcing extra routes through DHCPv4 #1734

Merged
merged 8 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
- id: ShellCheck
name: Differential ShellCheck
uses: redhat-plumbers-in-action/differential-shellcheck@v5
env:
SHELLCHECK_OPTS: --shell sh
with:
token: ${{ secrets.GITHUB_TOKEN }}
if: github.event_name == 'pull_request' && matrix.go == 'stable'
Expand Down
15 changes: 15 additions & 0 deletions cmd/incusd/main_forknet.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,21 @@ func (c *cmdForknet) RunDHCP(cmd *cobra.Command, args []string) error {
return nil
}

for _, staticRoute := range lease.Offer.ClasslessStaticRoute() {
route := &ip.Route{
DevName: iface,
Route: staticRoute.Dest.String(),
Via: staticRoute.Router.String(),
Family: ip.FamilyV4,
}

err = route.Add()
if err != nil {
fmt.Fprintf(os.Stderr, "Giving up on DHCP, couldn't add classless static route to %q: %v\n", iface, err)
return nil
}
}

// Create PID file.
err = os.WriteFile(filepath.Join(args[0], "dhcp.pid"), []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions doc/.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Diátaxis
Diffie
Distrobuilder
DNS
dnsmasq
DNSSEC
DoS
DRM
Expand Down
4 changes: 4 additions & 0 deletions doc/api-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2717,3 +2717,7 @@ Adds support for `DNS-01` challenge to the Incus ACME support for certificate ge
## `security_iommu`
Introduce a new `security.iommu` configuration key to control whether to
enable IOMMU emulation. This is done through `virtio_iommu` on Linux and the emulated Intel IOMMU on Windows.

## `network_ipv4_dhcp_routes`
Introduces a new `ipv4.dhcp.routes` configuration option on bridged and OVN networks.
This allows specifying pairs of CIDR networks and gateway address to be announced by the DHCP server.
1 change: 1 addition & 0 deletions doc/reference/network_bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Key | Type | Condition | Defau
`ipv4.dhcp.expiry` | string | IPv4 DHCP | `1h` | When to expire DHCP leases
`ipv4.dhcp.gateway` | string | IPv4 DHCP | IPv4 address | Address of the gateway for the subnet
`ipv4.dhcp.ranges` | string | IPv4 DHCP | all addresses | Comma-separated list of IP ranges to use for DHCP (FIRST-LAST format)
`ipv4.dhcp.routes` | string | IPv4 DHCP | - | Static routes to provide via DHCP option 121, as a comma-separated list of alternating subnets (CIDR) and gateway addresses (same syntax as dnsmasq)
`ipv4.firewall` | bool | IPv4 address | `true` | Whether to generate filtering firewall rules for this network
`ipv4.nat` | bool | IPv4 address | `false` (initial value on creation if `ipv4.address` is set to `auto`: `true`) | Whether to NAT
`ipv4.nat.address` | string | IPv4 address | - | The source address used for outbound traffic from the bridge
Expand Down
1 change: 1 addition & 0 deletions doc/reference/network_ovn.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Key | Type | Condition | Defau
`dns.zone.reverse.ipv6` | string | - | - | DNS zone name for IPv6 reverse DNS records
`ipv4.address` | string | standard mode | - (initial value on creation: `auto`) | IPv4 address for the bridge (use `none` to turn off IPv4 or `auto` to generate a new random unused subnet) (CIDR)
`ipv4.dhcp` | bool | IPv4 address | `true` | Whether to allocate addresses using DHCP
`ipv4.dhcp.routes` | string | IPv4 DHCP | - | Static routes to provide via DHCP option 121, as a comma-separated list of alternating subnets (CIDR) and gateway addresses (same syntax as dnsmasq and OVN)
`ipv4.l3only` | bool | IPv4 address | `false` | Whether to enable layer 3 only mode.
`ipv4.nat` | bool | IPv4 address | `false` (initial value on creation if `ipv4.address` is set to `auto`: `true`) | Whether to NAT
`ipv4.nat.address` | string | IPv4 address | - | The source address used for outbound traffic from the network (requires uplink `ovn.ingress_mode=routed`)
Expand Down
5 changes: 5 additions & 0 deletions internal/server/network/driver_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func (n *bridge) Validate(config map[string]string) error {
"ipv4.dhcp.gateway": validate.Optional(validate.IsNetworkAddressV4),
"ipv4.dhcp.expiry": validate.IsAny,
"ipv4.dhcp.ranges": validate.Optional(validate.IsListOf(validate.IsNetworkRangeV4)),
"ipv4.dhcp.routes": validate.Optional(validate.IsDHCPRouteList),
"ipv4.routes": validate.Optional(validate.IsListOf(validate.IsNetworkV4)),
"ipv4.routing": validate.Optional(validate.IsBool),
"ipv4.ovn.ranges": validate.Optional(validate.IsListOf(validate.IsNetworkRangeV4)),
Expand Down Expand Up @@ -928,6 +929,10 @@ func (n *bridge) setup(oldConfig map[string]string) error {
dnsmasqCmd = append(dnsmasqCmd, fmt.Sprintf("--dhcp-option-force=119,%s", strings.Trim(dnsSearch, " ")))
}

if n.config["ipv4.dhcp.routes"] != "" {
dnsmasqCmd = append(dnsmasqCmd, fmt.Sprintf("--dhcp-option-force=121,%s", strings.Replace(n.config["ipv4.dhcp.routes"], " ", "", -1)))
}

expiry := "1h"
if n.config["ipv4.dhcp.expiry"] != "" {
expiry = n.config["ipv4.dhcp.expiry"]
Expand Down
2 changes: 2 additions & 0 deletions internal/server/network/driver_ovn.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ func (n *ovn) Validate(config map[string]string) error {
}),
"ipv4.dhcp": validate.Optional(validate.IsBool),
"ipv4.dhcp.ranges": validate.Optional(validate.IsListOf(validate.IsNetworkRangeV4)),
"ipv4.dhcp.routes": validate.Optional(validate.IsDHCPRouteList),
"ipv6.address": validate.Optional(func(value string) error {
if validate.IsOneOf("none", "auto")(value) == nil {
return nil
Expand Down Expand Up @@ -2656,6 +2657,7 @@ func (n *ovn) setup(update bool) error {
MTU: bridgeMTU,
Netmask: dhcpV4Netmask,
DNSSearchList: n.getDNSSearchList(),
StaticRoutes: n.config["ipv4.dhcp.routes"],
}

if uplinkNet != nil {
Expand Down
7 changes: 7 additions & 0 deletions internal/server/network/ovn/ovn_nb_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type OVNDHCPv4Opts struct {
MTU uint32
Netmask string
DNSSearchList []string
StaticRoutes string
}

// OVNDHCPv6Opts IPv6 DHCP option set that can be created (and then applied to a switch port by resulting ID).
Expand Down Expand Up @@ -1287,6 +1288,12 @@ func (o *NB) UpdateLogicalSwitchDHCPv4Options(ctx context.Context, switchName OV
dhcpOption.Options["netmask"] = opts.Netmask
}

if opts.StaticRoutes != "" {
dhcpOption.Options["classless_static_route"] = fmt.Sprintf("{%s}", opts.StaticRoutes)
} else {
delete(dhcpOption.Options, "classless_static_route")
}

// Prepare the changes.
operations := []ovsdb.Operation{}
if dhcpOption.UUID == "" {
Expand Down
1 change: 1 addition & 0 deletions internal/version/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ var APIExtensions = []string{
"api_filtering_extended",
"acme_dns01",
"security_iommu",
"network_ipv4_dhcp_routes",
}

// APIExtensionsCount returns the number of available API extensions.
Expand Down
24 changes: 24 additions & 0 deletions shared/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,30 @@ func IsNetworkPortRange(value string) error {
return nil
}

// IsDHCPRouteList validates a comma-separated list of alternating CIDR networks and IP addresses.
func IsDHCPRouteList(value string) error {
parts := strings.Split(value, ",")
for i, s := range parts {
// routes are pairs of subnet and gateway
var err error
if i%2 == 0 { // subnet part
err = IsNetworkV4(s)
} else { // gateway part
err = IsNetworkAddressV4(s)
}

if err != nil {
return err
}
}

if len(parts)%2 != 0 { // uneven number of parts means the gateway of the last route is missing
return fmt.Errorf("missing gateway for route %v", parts[len(parts)-1])
}

return nil
}

// IsURLSegmentSafe validates whether value can be used in a URL segment.
func IsURLSegmentSafe(value string) error {
for _, char := range []string{"/", "?", "&", "+"} {
Expand Down
1 change: 1 addition & 0 deletions test/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ if [ "${1:-"all"}" != "cluster" ]; then
run_test test_server_config "server configuration"
run_test test_filemanip "file manipulations"
run_test test_network "network management"
run_test test_network_dhcp_routes "network dhcp routes"
run_test test_network_acl "network ACL management"
run_test test_network_forward "network address forwards"
run_test test_network_zone "network DNS zones"
Expand Down
60 changes: 60 additions & 0 deletions test/suites/network_dhcp_routes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
test_network_dhcp_routes() {
ensure_import_testimage
ensure_has_localhost_remote "${INCUS_ADDR}"

# bridge network
incus network create inct$$
incus network set inct$$ ipv4.address 10.13.37.1/24
incus network set inct$$ ipv4.dhcp.routes 1.2.0.0/16,10.13.37.5,2.3.0.0/16,10.13.37.7

incus launch testimage nettest -n inct$$

cat > "${TEST_DIR}"/udhcpc.sh <<EOL
#!/bin/sh
[ "\$1" = "bound" ] && echo "STATICROUTES: \$staticroutes"
EOL

incus file push "${TEST_DIR}/udhcpc.sh" nettest/udhcpc.sh
incus exec nettest -- chmod a+rx /udhcpc.sh

staticroutes_output=$(incus exec nettest -- udhcpc -s /udhcpc.sh 2>/dev/null | grep STATICROUTES)

echo "$staticroutes_output" | grep -q "1.2.0.0/16 10.13.37.5"
echo "$staticroutes_output" | grep -q "2.3.0.0/16 10.13.37.7"

incus delete nettest -f

if [ -n "${INCUS_OFFLINE:-}" ]; then
echo "==> SKIP: Skipping OCI tests as running offline"
else
ensure_has_localhost_remote "${INCUS_ADDR}"

incus remote add docker https://docker.io --protocol=oci
incus launch docker:alpine nettest --network=inct$$

incus exec nettest -- ip route list | grep -q "1.2.0.0/16 via 10.13.37.5"
incus exec nettest -- ip route list | grep -q "2.3.0.0/16 via 10.13.37.7"

incus delete -f nettest
incus remote remove docker
fi

incus network delete inct$$


# ovn network
if incus network create inct$$ -t ovn network=none; then
incus network set inct$$ ipv4.address 10.13.37.1/24
incus network set inct$$ ipv4.dhcp.routes 1.2.0.0/16,10.13.37.5,2.3.0.0/16,10.13.37.7

incus launch testimage nettest -n inct$$

incus exec nettest -- ip route list | grep -q "1.2.0.0/16 via 10.13.37.5"
incus exec nettest -- ip route list | grep -q "2.3.0.0/16 via 10.13.37.7"

incus delete nettest -f
incus network delete inct$$
else
echo "==> SKIP: Skipping OVN tests"
fi
}