diff --git a/doc/.wordlist.txt b/doc/.wordlist.txt index 55b94096ec9..da04a7967af 100644 --- a/doc/.wordlist.txt +++ b/doc/.wordlist.txt @@ -373,3 +373,7 @@ Zettabyte ZFS zpool zpools +WireGuard +OpenVPN +IPsec +wireguard \ No newline at end of file diff --git a/doc/config_options.txt b/doc/config_options.txt index 50c1acac596..28818a61fb4 100644 --- a/doc/config_options.txt +++ b/doc/config_options.txt @@ -1429,6 +1429,127 @@ The custom policy routing table ID to add IPv6 static routes to (in addition to ``` + +```{config:option} host_name devices-nic_wireguard +:default: "randomly assigned" +:shortdesc: "The name of the interface on the host" +:type: "string" + +``` + +```{config:option} hwaddr devices-nic_wireguard +:default: "randomly assigned" +:shortdesc: "The MAC address of the new interface" +:type: "string" + +``` + +```{config:option} ipv4.gateway devices-nic_wireguard +:default: "auto" +:shortdesc: "Whether to add an automatic default IPv4 gateway (can be `auto` or `none`)" +:type: "string" + +``` + +```{config:option} ipv4.host_tables devices-nic_wireguard +:default: "254" +:shortdesc: "Comma-delimited list of routing tables IDs to add IPv4 static routes to" +:type: "string" + +``` + +```{config:option} ipv4.neighbor_probe devices-nic_wireguard +:default: "true" +:shortdesc: "Whether to probe the parent network for IPv4 address availability using ARP" +:type: "bool" + +``` + +```{config:option} ipv4.routes devices-nic_wireguard +:shortdesc: "Comma-delimited list of IPv4 static routes to add on host to NIC" +:type: "string" + +``` + +```{config:option} ipv6.gateway devices-nic_wireguard +:default: "auto" +:shortdesc: "Whether to add an automatic default IPv6 gateway (can be `auto` or `none`)" +:type: "string" + +``` + +```{config:option} ipv6.host_tables devices-nic_wireguard +:default: "254" +:shortdesc: "Comma-delimited list of routing tables IDs to add IPv6 static routes to" +:type: "string" + +``` + +```{config:option} ipv6.neighbor_probe devices-nic_wireguard +:default: "true" +:shortdesc: "Whether to probe the parent network for IPv6 address availability using NDP" +:type: "bool" + +``` + +```{config:option} ipv6.routes devices-nic_wireguard +:shortdesc: "Comma-delimited list of IPv6 static routes to add on host to NIC" +:type: "string" + +``` + +```{config:option} limits.egress devices-nic_wireguard +:shortdesc: "I/O limit in bit/s for outgoing traffic (various suffixes supported, see {ref}`instances-limit-units`)" +:type: "string" + +``` + +```{config:option} limits.ingress devices-nic_wireguard +:shortdesc: "I/O limit in bit/s for incoming traffic (various suffixes supported, see {ref}`instances-limit-units`)" +:type: "string" + +``` + +```{config:option} limits.max devices-nic_wireguard +:shortdesc: "I/O limit in bit/s for both incoming and outgoing traffic (same as setting both limits.ingress and limits.egress)" +:type: "string" + +``` + +```{config:option} limits.priority devices-nic_wireguard +:shortdesc: "The priority for outgoing traffic, to be used by the kernel queuing discipline to prioritize network packets" +:type: "integer" + +``` + +```{config:option} mtu devices-nic_wireguard +:default: "parent MTU" +:shortdesc: "The Maximum Transmit Unit (MTU) of the new interface" +:type: "integer" + +``` + +```{config:option} name devices-nic_wireguard +:default: "kernel assigned" +:shortdesc: "The name of the interface inside the instance" +:type: "string" + +``` + +```{config:option} network devices-nic_wireguard +:required: "yes" +:shortdesc: "The managed WireGuard network to link the device to" +:type: "string" + +``` + +```{config:option} vrf devices-nic_wireguard +:shortdesc: "The VRF on the host in which the host-side interface and routes are created" +:type: "string" + +``` + + ```{config:option} address devices-pci :required: "yes" @@ -4137,6 +4258,89 @@ User keys can be used in search. ``` + +```{config:option} interface network_wireguard-common +:condition: "-" +:defaultdesc: "Network name" +:shortdesc: "WireGuard interface name" +:type: "string" + +``` + +```{config:option} ipv4.address network_wireguard-common +:condition: "-" +:shortdesc: "Comma-separated list of IPv4 addresses and CIDR for the WireGuard interface (e.g., 10.0.0.1/24)" +:type: "string" + +``` + +```{config:option} ipv6.address network_wireguard-common +:condition: "-" +:shortdesc: "Comma-separated list of IPv6 addresses and CIDR for the WireGuard interface (e.g., 2001:db8::1/64)" +:type: "string" + +``` + +```{config:option} listen_port network_wireguard-common +:condition: "-" +:defaultdesc: "Auto-assigned by WireGuard" +:shortdesc: "UDP port to listen on. If not specified or set to 0, WireGuard will automatically assign an available port." +:type: "integer" + +``` + +```{config:option} mtu network_wireguard-common +:condition: "-" +:defaultdesc: "`1420`" +:shortdesc: "The MTU of the WireGuard interface" +:type: "integer" + +``` + +```{config:option} private_key network_wireguard-common +:condition: "-" +:shortdesc: "WireGuard private key (base64 encoded). If not provided, one will be generated." +:type: "string" + +``` + +```{config:option} user.* network_wireguard-common +:shortdesc: "User-provided free-form key/value pairs" +:type: "string" + +``` + + + +```{config:option} peers.NAME.allowed_ips network_wireguard-peers +:condition: "-" +:shortdesc: "Allowed IPs for peer NAME (comma-separated CIDR addresses)" +:type: "string" + +``` + +```{config:option} peers.NAME.endpoint network_wireguard-peers +:condition: "-" +:shortdesc: "Endpoint address for peer NAME (IP:port or hostname:port)" +:type: "string" + +``` + +```{config:option} peers.NAME.persistent_keepalive network_wireguard-peers +:condition: "-" +:shortdesc: "Persistent keep-alive interval in seconds for peer NAME" +:type: "integer" + +``` + +```{config:option} peers.NAME.public_key network_wireguard-peers +:condition: "-" +:shortdesc: "Public key of peer NAME" +:type: "string" + +``` + + ```{config:option} dns.nameservers network_zone-common :required: "no" diff --git a/doc/explanation/networks.md b/doc/explanation/networks.md index f14639a84e9..63d01beebd0 100644 --- a/doc/explanation/networks.md +++ b/doc/explanation/networks.md @@ -80,6 +80,16 @@ Incus supports the following network types: This means that you can create your own OVN network as a non-admin user, even in a restricted project. ``` +{ref}`network-wireguard` +: % Include content from [../reference/network_wireguard.md](../reference/network_wireguard.md) + ```{include} ../reference/network_wireguard.md + :start-after: + :end-before: + ``` + + In Incus context, the `wireguard` network type creates a WireGuard VPN interface that instances can connect to. + WireGuard operates at layer 3 (network layer), making it suitable for secure VPN connections. + ### External networks % Include content from [../reference/network_external.md](../reference/network_external.md) diff --git a/doc/howto/network_create.md b/doc/howto/network_create.md index 282c499bc3c..d98890fa000 100644 --- a/doc/howto/network_create.md +++ b/doc/howto/network_create.md @@ -29,6 +29,9 @@ The following network types are available: * - `physical` - {ref}`network-physical` - {ref}`network-physical-options` +* - `wireguard` + - {ref}`network-wireguard` + - {ref}`network-wireguard-options` ``` diff --git a/doc/reference/devices_nic.md b/doc/reference/devices_nic.md index 0107a740f42..9558ad80d34 100644 --- a/doc/reference/devices_nic.md +++ b/doc/reference/devices_nic.md @@ -53,6 +53,7 @@ The following NICs can be added using the `nictype` or `network` options: The following NICs can be added using only the `network` option: - [`ovn`](nic-ovn): Uses an existing OVN network and creates a virtual device pair to connect the instance to it. +- [`wireguard`](nic-wireguard): Uses an existing WireGuard network and creates a routed connection to it. The following NICs can be added using only the `nictype` option: @@ -203,6 +204,28 @@ Note that using `none` with either `ipv4.address` or `ipv6.address` needs the ot There is currently no way for OVN to disable IP allocation just on IPv4 or IPv6. ``` +(nic-wireguard)= +### `nictype`: `wireguard` + +```{note} +You can select this NIC type only through the `network` option (see {ref}`network-wireguard` for information about the managed `wireguard` network). +``` + +A `wireguard` NIC uses an existing WireGuard network and creates a routed connection to it. +WireGuard is a modern, fast, and secure VPN tunnel that uses state-of-the-art cryptography. + +WireGuard networks operate at layer 3 (network layer), making them suitable for routing traffic between instances and remote peers. + +#### Device options + +NIC devices of type `wireguard` have the following device options: + +% Include content from [config_options.txt](../config_options.txt) +```{include} ../config_options.txt + :start-after: + :end-before: +``` + (nic-physical)= ### `nictype`: `physical` diff --git a/doc/reference/network_wireguard.md b/doc/reference/network_wireguard.md new file mode 100644 index 00000000000..16ed18cbf1f --- /dev/null +++ b/doc/reference/network_wireguard.md @@ -0,0 +1,87 @@ +(network-wireguard)= +# WireGuard network + + +{abbr}`WireGuard` is a modern, fast, and secure VPN tunnel that uses state-of-the-art cryptography. +It is designed to be faster, simpler, and more secure than IPsec and OpenVPN. +See [`www.wireguard.com`](https://www.wireguard.com/) for more information. + + +The `wireguard` network type allows you to create a WireGuard VPN interface that instances can connect to using the `wireguard` NIC type. +This enables secure point-to-point and site-to-site VPN connections. + +WireGuard networks operate at layer 3 (network layer), making them suitable for routing traffic between instances and remote peers. + +```{note} +WireGuard requires the `wireguard-tools` package to be installed on the host system. +``` + +(network-wireguard-options)= +## Configuration options + +The following configuration key namespaces are currently supported for the `wireguard` network type: + +- `user` (free-form key/value for user metadata) + +```{note} +{{note_ip_addresses_CIDR}} +``` + +The following configuration options are available for the `wireguard` network type: + +% Include content from [config_options.txt](../config_options.txt) +```{include} ../config_options.txt + :start-after: + :end-before: +``` + +You can also configure peers for the `wireguard` network type. Each peer can have the following configuration options: + +% Include content from [config_options.txt](../config_options.txt) +```{include} ../config_options.txt + :start-after: + :end-before: +``` + +(network-wireguard-features)= +## Supported features + +The following features are supported for the `wireguard` network type: + +- **Node-specific configuration**: Each cluster member can have different WireGuard interface configurations +- **Network peering**: WireGuard networks support peering with remote WireGuard peers + +(network-wireguard-examples)= +## Examples + +### Create a basic WireGuard network + +```bash +incus network create wg0 --type=wireguard ipv4.address=10.0.0.1/24 +``` + +### Create a WireGuard network with IPv6 + +```bash +incus network create wg0 --type=wireguard ipv4.address=10.0.0.1/24 ipv6.address=2001:db8::1/64 +``` + +### Create a WireGuard network with a peer + +```bash +incus network create wg0 --type=wireguard \ + ipv4.address=10.0.0.1/24 \ + private_key="" \ + peers.remote.public_key="" \ + peers.remote.allowed_ips="10.0.0.0/24" \ + peers.remote.endpoint="192.168.1.100:51820" \ + peers.remote.persistent_keepalive=25 +``` + +### Connect an instance to a WireGuard network + +```bash +incus launch images:ubuntu/jammy/cloud myinstance --network=wg0 +``` + +The instance will automatically receive an IP address from the WireGuard network's address range. diff --git a/internal/server/db/networks.go b/internal/server/db/networks.go index 654d17c41ae..b1213646d8d 100644 --- a/internal/server/db/networks.go +++ b/internal/server/db/networks.go @@ -561,11 +561,12 @@ type NetworkType int // Network types. const ( - NetworkTypeBridge NetworkType = iota // Network type bridge. - NetworkTypeMacvlan // Network type macvlan. - NetworkTypeSriov // Network type sriov. - NetworkTypeOVN // Network type ovn. - NetworkTypePhysical // Network type physical. + NetworkTypeBridge NetworkType = iota // Network type bridge. + NetworkTypeMacvlan // Network type macvlan. + NetworkTypeSriov // Network type sriov. + NetworkTypeOVN // Network type ovn. + NetworkTypePhysical // Network type physical. + NetworkTypeWireguard // Network type wireguard. ) // NetworkNode represents a network node. @@ -693,6 +694,8 @@ func networkFillType(network *api.Network, netType NetworkType) { network.Type = "ovn" case NetworkTypePhysical: network.Type = "physical" + case NetworkTypeWireguard: + network.Type = "wireguard" default: network.Type = "" // Unknown } diff --git a/internal/server/device/device_load.go b/internal/server/device/device_load.go index 46c7f7bcec5..d9145451bae 100644 --- a/internal/server/device/device_load.go +++ b/internal/server/device/device_load.go @@ -38,6 +38,8 @@ func newByType(state *state.State, projectName string, conf deviceConfig.Device) dev = &nicBridged{} case "routed": dev = &nicRouted{} + case "wireguard": + dev = &nicWireguard{} case "macvlan": dev = &nicMACVLAN{} case "sriov": diff --git a/internal/server/device/nic_wireguard.go b/internal/server/device/nic_wireguard.go new file mode 100644 index 00000000000..9284a7fec0b --- /dev/null +++ b/internal/server/device/nic_wireguard.go @@ -0,0 +1,291 @@ +package device + +import ( + "errors" + "fmt" + "net" + "strings" + + "github.com/lxc/incus/v6/internal/server/instance" + "github.com/lxc/incus/v6/internal/server/instance/instancetype" + "github.com/lxc/incus/v6/internal/server/network" + "github.com/lxc/incus/v6/internal/server/project" + "github.com/lxc/incus/v6/shared/api" + "github.com/lxc/incus/v6/shared/util" + "github.com/lxc/incus/v6/shared/validate" +) + +// nicWireguard represents a WireGuard network interface device. +// It embeds nicRouted to inherit all the routed functionality. +type nicWireguard struct { + nicRouted +} + +// CanHotPlug returns whether the device can be managed whilst the instance is running. +func (d *nicWireguard) CanHotPlug() bool { + return true +} + +// UpdatableFields returns a list of fields that can be updated without triggering a device remove & add. +func (d *nicWireguard) UpdatableFields(oldDevice Type) []string { + // Check old and new device types match. + _, match := oldDevice.(*nicWireguard) + if !match { + return []string{} + } + + return []string{"limits.ingress", "limits.egress", "limits.max", "limits.priority"} +} + +// validateConfig checks the supplied config for correctness. +func (d *nicWireguard) validateConfig(instConf instance.ConfigReader) error { + if !instanceSupported(instConf.Type(), instancetype.Container, instancetype.VM) { + return ErrUnsupportedDevType + } + + err := d.isUniqueWithGatewayAutoMode(instConf) + if err != nil { + return err + } + + requiredFields := []string{} + optionalFields := []string{ + // gendoc:generate(entity=devices, group=nic_wireguard, key=name) + // + // --- + // type: string + // default: kernel assigned + // shortdesc: The name of the interface inside the instance + "name", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=network) + // + // --- + // type: string + // required: yes + // shortdesc: The managed WireGuard network to link the device to + "network", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=mtu) + // + // --- + // type: integer + // default: parent MTU + // shortdesc: The Maximum Transmit Unit (MTU) of the new interface + "mtu", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=hwaddr) + // + // --- + // type: string + // default: randomly assigned + // shortdesc: The MAC address of the new interface + "hwaddr", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=host_name) + // + // --- + // type: string + // default: randomly assigned + // shortdesc: The name of the interface on the host + "host_name", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=limits.ingress) + // + // --- + // type: string + // shortdesc: I/O limit in bit/s for incoming traffic (various suffixes supported, see {ref}`instances-limit-units`) + "limits.ingress", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=limits.egress) + // + // --- + // type: string + // shortdesc: I/O limit in bit/s for outgoing traffic (various suffixes supported, see {ref}`instances-limit-units`) + "limits.egress", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=limits.max) + // + // --- + // type: string + // shortdesc: I/O limit in bit/s for both incoming and outgoing traffic (same as setting both limits.ingress and limits.egress) + "limits.max", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=limits.priority) + // + // --- + // type: integer + // shortdesc: The priority for outgoing traffic, to be used by the kernel queuing discipline to prioritize network packets + "limits.priority", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv4.gateway) + // + // --- + // type: string + // default: auto + // shortdesc: Whether to add an automatic default IPv4 gateway (can be `auto` or `none`) + "ipv4.gateway", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv6.gateway) + // + // --- + // type: string + // default: auto + // shortdesc: Whether to add an automatic default IPv6 gateway (can be `auto` or `none`) + "ipv6.gateway", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv4.routes) + // + // --- + // type: string + // shortdesc: Comma-delimited list of IPv4 static routes to add on host to NIC + "ipv4.routes", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv6.routes) + // + // --- + // type: string + // shortdesc: Comma-delimited list of IPv6 static routes to add on host to NIC + "ipv6.routes", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv4.neighbor_probe) + // + // --- + // type: bool + // default: true + // shortdesc: Whether to probe the parent network for IPv4 address availability using ARP + "ipv4.neighbor_probe", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv6.neighbor_probe) + // + // --- + // type: bool + // default: true + // shortdesc: Whether to probe the parent network for IPv6 address availability using NDP + "ipv6.neighbor_probe", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv4.host_tables) + // + // --- + // type: string + // default: 254 + // shortdesc: Comma-delimited list of routing tables IDs to add IPv4 static routes to + "ipv4.host_tables", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=ipv6.host_tables) + // + // --- + // type: string + // default: 254 + // shortdesc: Comma-delimited list of routing tables IDs to add IPv6 static routes to + "ipv6.host_tables", + + // gendoc:generate(entity=devices, group=nic_wireguard, key=vrf) + // + // --- + // type: string + // shortdesc: The VRF on the host in which the host-side interface and routes are created + "vrf", + } + + rules := nicValidationRules(requiredFields, optionalFields, instConf) + + // Override ipv4.address and ipv6.address to support lists + rules["ipv4.address"] = validate.Optional(validate.IsListOf(validate.IsNetworkAddressV4)) + rules["ipv6.address"] = validate.Optional(validate.IsListOf(validate.IsNetworkAddressV6)) + + err = d.config.Validate(rules) + if err != nil { + return err + } + + // Detect duplicate IPs in config. + for _, key := range []string{"ipv4.address", "ipv6.address"} { + ips := make(map[string]struct{}) + + if d.config[key] != "" { + for _, addr := range util.SplitNTrimSpace(d.config[key], ",", -1, true) { + addr = strings.TrimSpace(addr) + _, dupe := ips[addr] + if dupe { + return fmt.Errorf("Duplicate address %q in %q", addr, key) + } + + ips[addr] = struct{}{} + } + } + } + + // Ensure that address is set if routes is set. + for _, keyPrefix := range []string{"ipv4", "ipv6"} { + if d.config[fmt.Sprintf("%s.routes", keyPrefix)] != "" && d.config[fmt.Sprintf("%s.address", keyPrefix)] == "" { + return fmt.Errorf("%s.routes requires %s.address to be set", keyPrefix, keyPrefix) + } + } + + // Network property is required for WireGuard + if d.config["network"] == "" { + return errors.New("Network property is required for WireGuard NIC") + } + + // Translate device's project name into a network project name. + networkProjectName, _, err := project.NetworkProject(d.state.DB.Cluster, instConf.Project().Name) + if err != nil { + return fmt.Errorf("Failed to translate device project into network project: %w", err) + } + + // Load the network + wgNet, err := network.LoadByName(d.state, networkProjectName, d.config["network"]) + if err != nil { + return fmt.Errorf("Error loading network config for %q: %w", d.config["network"], err) + } + + if wgNet.Status() != api.NetworkStatusCreated { + return errors.New("Specified network is not fully created") + } + + if wgNet.Type() != "wireguard" { + return fmt.Errorf("Specified network must be of type wireguard, got %q", wgNet.Type()) + } + + netConfig := wgNet.Config() + + // Get WireGuard interface name (from config or network name) + ifaceName := netConfig["interface"] + if ifaceName == "" { + ifaceName = d.config["network"] + } + + // Set parent to the WireGuard interface name (for routed NIC functionality) + d.config["parent"] = ifaceName + + // Generate IP address from network's address range if not already set + for _, keyPrefix := range []string{"ipv4", "ipv6"} { + addrKey := fmt.Sprintf("%s.address", keyPrefix) + netAddrKey := fmt.Sprintf("%s.address", keyPrefix) + + // Only generate IP if not already set + if d.config[addrKey] == "" && netConfig[netAddrKey] != "" { + // Parse the network's address to get subnet (handle multiple addresses) + addresses := util.SplitNTrimSpace(netConfig[netAddrKey], ",", -1, true) + if len(addresses) > 0 { + // Use the first address from the network's address list + networkIP, subnet, err := net.ParseCIDR(addresses[0]) + if err != nil { + return fmt.Errorf("Failed to parse network address %q: %w", addresses[0], err) + } + + // Generate a random IP from the subnet + // Avoid the network address and the WireGuard interface's own IP + randomIP, err := network.GenerateRandomIPFromSubnet(subnet, networkIP) + if err != nil { + return fmt.Errorf("Failed to generate IP address from network subnet: %w", err) + } + + d.config[addrKey] = randomIP.String() + } + } + } + + return nil +} diff --git a/internal/server/device/nictype/nictype.go b/internal/server/device/nictype/nictype.go index fbf2e1d0d68..33a61dedca5 100644 --- a/internal/server/device/nictype/nictype.go +++ b/internal/server/device/nictype/nictype.go @@ -50,6 +50,8 @@ func NICType(s *state.State, deviceProjectName string, d deviceConfig.Device) (s nicType = "ovn" case "physical": nicType = "physical" + case "wireguard": + nicType = "wireguard" default: return "", fmt.Errorf("Unrecognised NIC network type for network %q", d["network"]) } diff --git a/internal/server/metadata/configuration.json b/internal/server/metadata/configuration.json index fd551c0feae..06a5bf15989 100644 --- a/internal/server/metadata/configuration.json +++ b/internal/server/metadata/configuration.json @@ -1620,6 +1620,147 @@ } ] }, + "nic_wireguard": { + "keys": [ + { + "host_name": { + "default": "randomly assigned", + "longdesc": "", + "shortdesc": "The name of the interface on the host", + "type": "string" + } + }, + { + "hwaddr": { + "default": "randomly assigned", + "longdesc": "", + "shortdesc": "The MAC address of the new interface", + "type": "string" + } + }, + { + "ipv4.gateway": { + "default": "auto", + "longdesc": "", + "shortdesc": "Whether to add an automatic default IPv4 gateway (can be `auto` or `none`)", + "type": "string" + } + }, + { + "ipv4.host_tables": { + "default": "254", + "longdesc": "", + "shortdesc": "Comma-delimited list of routing tables IDs to add IPv4 static routes to", + "type": "string" + } + }, + { + "ipv4.neighbor_probe": { + "default": "true", + "longdesc": "", + "shortdesc": "Whether to probe the parent network for IPv4 address availability using ARP", + "type": "bool" + } + }, + { + "ipv4.routes": { + "longdesc": "", + "shortdesc": "Comma-delimited list of IPv4 static routes to add on host to NIC", + "type": "string" + } + }, + { + "ipv6.gateway": { + "default": "auto", + "longdesc": "", + "shortdesc": "Whether to add an automatic default IPv6 gateway (can be `auto` or `none`)", + "type": "string" + } + }, + { + "ipv6.host_tables": { + "default": "254", + "longdesc": "", + "shortdesc": "Comma-delimited list of routing tables IDs to add IPv6 static routes to", + "type": "string" + } + }, + { + "ipv6.neighbor_probe": { + "default": "true", + "longdesc": "", + "shortdesc": "Whether to probe the parent network for IPv6 address availability using NDP", + "type": "bool" + } + }, + { + "ipv6.routes": { + "longdesc": "", + "shortdesc": "Comma-delimited list of IPv6 static routes to add on host to NIC", + "type": "string" + } + }, + { + "limits.egress": { + "longdesc": "", + "shortdesc": "I/O limit in bit/s for outgoing traffic (various suffixes supported, see {ref}`instances-limit-units`)", + "type": "string" + } + }, + { + "limits.ingress": { + "longdesc": "", + "shortdesc": "I/O limit in bit/s for incoming traffic (various suffixes supported, see {ref}`instances-limit-units`)", + "type": "string" + } + }, + { + "limits.max": { + "longdesc": "", + "shortdesc": "I/O limit in bit/s for both incoming and outgoing traffic (same as setting both limits.ingress and limits.egress)", + "type": "string" + } + }, + { + "limits.priority": { + "longdesc": "", + "shortdesc": "The priority for outgoing traffic, to be used by the kernel queuing discipline to prioritize network packets", + "type": "integer" + } + }, + { + "mtu": { + "default": "parent MTU", + "longdesc": "", + "shortdesc": "The Maximum Transmit Unit (MTU) of the new interface", + "type": "integer" + } + }, + { + "name": { + "default": "kernel assigned", + "longdesc": "", + "shortdesc": "The name of the interface inside the instance", + "type": "string" + } + }, + { + "network": { + "longdesc": "", + "required": "yes", + "shortdesc": "The managed WireGuard network to link the device to", + "type": "string" + } + }, + { + "vrf": { + "longdesc": "", + "shortdesc": "The VRF on the host in which the host-side interface and routes are created", + "type": "string" + } + } + ] + }, "pci": { "keys": [ { @@ -4684,6 +4825,106 @@ ] } }, + "network_wireguard": { + "common": { + "keys": [ + { + "interface": { + "condition": "-", + "defaultdesc": "Network name", + "longdesc": "", + "shortdesc": "WireGuard interface name", + "type": "string" + } + }, + { + "ipv4.address": { + "condition": "-", + "longdesc": "", + "shortdesc": "Comma-separated list of IPv4 addresses and CIDR for the WireGuard interface (e.g., 10.0.0.1/24)", + "type": "string" + } + }, + { + "ipv6.address": { + "condition": "-", + "longdesc": "", + "shortdesc": "Comma-separated list of IPv6 addresses and CIDR for the WireGuard interface (e.g., 2001:db8::1/64)", + "type": "string" + } + }, + { + "listen_port": { + "condition": "-", + "defaultdesc": "Auto-assigned by WireGuard", + "longdesc": "", + "shortdesc": "UDP port to listen on. If not specified or set to 0, WireGuard will automatically assign an available port.", + "type": "integer" + } + }, + { + "mtu": { + "condition": "-", + "defaultdesc": "`1420`", + "longdesc": "", + "shortdesc": "The MTU of the WireGuard interface", + "type": "integer" + } + }, + { + "private_key": { + "condition": "-", + "longdesc": "", + "shortdesc": "WireGuard private key (base64 encoded). If not provided, one will be generated.", + "type": "string" + } + }, + { + "user.*": { + "longdesc": "", + "shortdesc": "User-provided free-form key/value pairs", + "type": "string" + } + } + ] + }, + "peers": { + "keys": [ + { + "peers.NAME.allowed_ips": { + "condition": "-", + "longdesc": "", + "shortdesc": "Allowed IPs for peer NAME (comma-separated CIDR addresses)", + "type": "string" + } + }, + { + "peers.NAME.endpoint": { + "condition": "-", + "longdesc": "", + "shortdesc": "Endpoint address for peer NAME (IP:port or hostname:port)", + "type": "string" + } + }, + { + "peers.NAME.persistent_keepalive": { + "condition": "-", + "longdesc": "", + "shortdesc": "Persistent keep-alive interval in seconds for peer NAME", + "type": "integer" + } + }, + { + "peers.NAME.public_key": { + "condition": "-", + "longdesc": "", + "shortdesc": "Public key of peer NAME", + "type": "string" + } + } + ] + } + }, "network_zone": { "common": { "keys": [ diff --git a/internal/server/network/driver_wireguard.go b/internal/server/network/driver_wireguard.go new file mode 100644 index 00000000000..d14784ad194 --- /dev/null +++ b/internal/server/network/driver_wireguard.go @@ -0,0 +1,633 @@ +package network + +import ( + "context" + "fmt" + "net" + "os/exec" + "slices" + "strconv" + "strings" + + "github.com/vishvananda/netlink" + + "github.com/lxc/incus/v6/internal/server/cluster/request" + "github.com/lxc/incus/v6/internal/server/db" + "github.com/lxc/incus/v6/internal/server/ip" + "github.com/lxc/incus/v6/shared/api" + "github.com/lxc/incus/v6/shared/logger" + "github.com/lxc/incus/v6/shared/revert" + "github.com/lxc/incus/v6/shared/util" + "github.com/lxc/incus/v6/shared/validate" +) + +// wireguard represents a wireguard network. +type wireguard struct { + common +} + +// DBType returns the network type DB ID. +func (n *wireguard) DBType() db.NetworkType { + return db.NetworkTypeWireguard +} + +// Config returns the network configuration, filtering out listen_port if it's empty or "0". +func (n *wireguard) Config() map[string]string { + config := n.common.Config() + + // Hide listen_port if it's empty or "0" + if config["listen_port"] == "" || config["listen_port"] == "0" { + // Create a new map without listen_port + filteredConfig := make(map[string]string, len(config)) + for k, v := range config { + if k != "listen_port" { + filteredConfig[k] = v + } + } + return filteredConfig + } + + return config +} + +// Validate network config. +func (n *wireguard) Validate(config map[string]string, clientType request.ClientType) error { + rules := map[string]func(value string) error{ + // gendoc:generate(entity=network_wireguard, group=common, key=interface) + // + // --- + // type: string + // condition: - + // defaultdesc: Network name + // shortdesc: WireGuard interface name + "interface": validate.Optional(validate.IsInterfaceName), + + // gendoc:generate(entity=network_wireguard, group=common, key=private_key) + // + // --- + // type: string + // condition: - + // shortdesc: WireGuard private key (base64 encoded). If not provided, one will be generated. + "private_key": validate.Optional(validate.IsNotEmpty), + + // gendoc:generate(entity=network_wireguard, group=common, key=listen_port) + // + // --- + // type: integer + // condition: - + // defaultdesc: Auto-assigned by WireGuard + // shortdesc: UDP port to listen on. If not specified or set to 0, WireGuard will automatically assign an available port. + "listen_port": validate.Optional(validate.IsInt64), + + // gendoc:generate(entity=network_wireguard, group=common, key=mtu) + // + // --- + // type: integer + // condition: - + // defaultdesc: `1420` + // shortdesc: The MTU of the WireGuard interface + "mtu": validate.Optional(validate.IsNetworkMTU), + + // gendoc:generate(entity=network_wireguard, group=common, key=ipv4.address) + // + // --- + // type: string + // condition: - + // shortdesc: Comma-separated list of IPv4 addresses and CIDR for the WireGuard interface (e.g., 10.0.0.1/24) + "ipv4.address": validate.Optional(validate.IsListOf(validate.IsNetworkAddressCIDR)), + + // gendoc:generate(entity=network_wireguard, group=common, key=ipv6.address) + // + // --- + // type: string + // condition: - + // shortdesc: Comma-separated list of IPv6 addresses and CIDR for the WireGuard interface (e.g., 2001:db8::1/64) + "ipv6.address": validate.Optional(validate.IsListOf(validate.IsNetworkAddressCIDR)), + + // gendoc:generate(entity=network_wireguard, group=peers, key=peers.NAME.public_key) + // + // --- + // type: string + // condition: - + // shortdesc: Public key of peer NAME + + // gendoc:generate(entity=network_wireguard,group=peers, key=peers.NAME.allowed_ips) + // + // --- + // type: string + // condition: - + // shortdesc: Allowed IPs for peer NAME (comma-separated CIDR addresses) + + // gendoc:generate(entity=network_wireguard, group=peers, key=peers.NAME.endpoint) + // + // --- + // type: string + // condition: - + // shortdesc: Endpoint address for peer NAME (IP:port or hostname:port) + + // gendoc:generate(entity=network_wireguard, group=peers, key=peers.NAME.persistent_keepalive) + // + // --- + // type: integer + // condition: - + // shortdesc: Persistent keep-alive interval in seconds for peer NAME + + // gendoc:generate(entity=network_wireguard, group=common, key=user.*) + // + // --- + // type: string + // shortdesc: User-provided free-form key/value pairs + } + + // Add validation rules for peer configurations + for key := range config { + if !strings.HasPrefix(key, "peers.") { + continue + } + + parts := strings.SplitN(key, ".", 3) + if len(parts) != 3 { + continue + } + + peerKey := parts[2] + + // Add the correct validation rule for the dynamic field based on last part of key. + switch peerKey { + case "public_key": + rules[key] = validate.Optional(validate.IsNotEmpty) + case "allowed_ips": + rules[key] = validate.Optional(validate.IsAny) + case "endpoint": + rules[key] = validate.Optional(validate.IsAny) + case "persistent_keepalive": + rules[key] = validate.Optional(validate.IsInt64) + } + } + + err := n.validate(config, rules) + if err != nil { + return err + } + + // Validate peer configurations + for key, value := range config { + if strings.HasPrefix(key, "peers.") { + parts := strings.SplitN(key, ".", 3) + if len(parts) == 3 { + peerName := parts[1] + peerKey := parts[2] + + switch peerKey { + case "public_key": + if value == "" { + return fmt.Errorf("Peer %q public_key cannot be empty", peerName) + } + // Basic validation for base64 key (44 chars for WireGuard keys) + if len(value) != 44 { + return fmt.Errorf("Peer %q public_key must be 44 characters (base64 encoded)", peerName) + } + + case "allowed_ips": + if value == "" { + return fmt.Errorf("Peer %q allowed_ips cannot be empty", peerName) + } + // Validate each CIDR in the comma-separated list + ips := util.SplitNTrimSpace(value, ",", -1, true) + for _, ipStr := range ips { + err := validate.IsNetworkAddressCIDR(ipStr) + if err != nil { + return fmt.Errorf("Peer %q allowed_ips contains invalid CIDR %q: %w", peerName, ipStr, err) + } + } + case "endpoint": + // Endpoint is optional, but if provided should be valid + if value != "" { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return fmt.Errorf("Peer %q endpoint must be in format IP:port or hostname:port", peerName) + } + + port, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil || port == 0 || port > 65535 { + return fmt.Errorf("Peer %q endpoint port must be a valid port number (1-65535)", peerName) + } + } + case "persistent_keepalive": + if value != "" { + err := validate.IsInt64(value) + if err != nil { + return fmt.Errorf("Peer %q persistent_keepalive must be an integer: %w", peerName, err) + } + } + } + } + } + } + + return nil +} + +// Create creates the WireGuard network. +func (n *wireguard) Create(clientType request.ClientType) error { + n.logger.Debug("Create", logger.Ctx{"clientType": clientType, "config": n.config}) + + // Check if wg command is available + _, err := exec.LookPath("wg") + if err != nil { + return fmt.Errorf("WireGuard tools not found. Please install wireguard-tools package") + } + + return nil +} + +// Delete deletes a network. +func (n *wireguard) Delete(clientType request.ClientType) error { + n.logger.Debug("Delete", logger.Ctx{"clientType": clientType}) + + err := n.Stop() + if err != nil { + return err + } + + return n.delete(clientType) +} + +// Rename renames a network. +func (n *wireguard) Rename(newName string) error { + n.logger.Debug("Rename", logger.Ctx{"newName": newName}) + + // Rename common steps. + err := n.rename(newName) + if err != nil { + return err + } + + return nil +} + +// Start starts the network. +func (n *wireguard) Start() error { + n.logger.Debug("Start") + + reverter := revert.New() + defer reverter.Fail() + + reverter.Add(func() { n.setUnavailable() }) + + err := n.setup() + if err != nil { + return err + } + + reverter.Success() + + // Ensure network is marked as available now its started. + n.setAvailable() + + return nil +} + +// setup creates and configures the WireGuard interface. +func (n *wireguard) setup() error { + n.logger.Debug("Setting up WireGuard network") + + // Determine interface name + ifaceName := n.config["interface"] + if ifaceName == "" { + ifaceName = n.name + } + + // Check if interface already exists + if !InterfaceExists(ifaceName) { + // Create WireGuard interface using ip command + cmd := exec.Command("ip", "link", "add", ifaceName, "type", "wireguard") + err := cmd.Run() + if err != nil { + return fmt.Errorf("Failed to create WireGuard interface %q: %w", ifaceName, err) + } + } + + // Set MTU if specified + if n.config["mtu"] != "" { + mtu, err := strconv.ParseUint(n.config["mtu"], 10, 32) + if err != nil { + return fmt.Errorf("Invalid MTU %q: %w", n.config["mtu"], err) + } + + link := &ip.Link{Name: ifaceName} + err = link.SetMTU(uint32(mtu)) + if err != nil { + return fmt.Errorf("Failed setting MTU %q on %q: %w", n.config["mtu"], ifaceName, err) + } + } else { + // Set default MTU for WireGuard (1420 is recommended) + link := &ip.Link{Name: ifaceName} + err := link.SetMTU(1420) + if err != nil { + n.logger.Warn("Failed to set default MTU 1420", logger.Ctx{"error": err}) + } + } + + // Configure WireGuard using wg command + err := n.configureWireGuard(ifaceName) + if err != nil { + return err + } + + // Bring interface up first (needed for WireGuard to assign port when using 0) + link := &ip.Link{Name: ifaceName} + err = link.SetUp() + if err != nil { + return fmt.Errorf("Failed to bring up WireGuard interface %q: %w", ifaceName, err) + } + + // Set IP addresses if specified (supports multiple addresses, IPv4 and IPv6) + for _, keyPrefix := range []string{"ipv4", "ipv6"} { + addrKey := fmt.Sprintf("%s.address", keyPrefix) + if n.config[addrKey] != "" { + addresses := util.SplitNTrimSpace(n.config[addrKey], ",", -1, true) + for _, addrStr := range addresses { + ipAddress, ipNet, err := net.ParseCIDR(addrStr) + if err != nil { + return fmt.Errorf("Failed to parse address %q: %w", addrStr, err) + } + // Create IPNet with the specific IP address (not the network address) + addr := &ip.Addr{ + DevName: ifaceName, + Address: &net.IPNet{ + IP: ipAddress, + Mask: ipNet.Mask, + }, + } + // Check if address already exists before adding + addressExists, err := n.addressExists(ifaceName, ipAddress) + if err != nil { + return fmt.Errorf("Failed to check if address exists on %q: %w", ifaceName, err) + } + + if !addressExists { + err = addr.Add() + if err != nil { + // Check if error is "file exists" (address already assigned) + if !strings.Contains(err.Error(), "file exists") && !strings.Contains(err.Error(), "already assigned") { + return fmt.Errorf("Failed to set address %q on %q: %w", addrStr, ifaceName, err) + } + + n.logger.Debug("Address already exists on interface, skipping", logger.Ctx{"address": addrStr, "interface": ifaceName}) + } + } + } + } + } + + return nil +} + +// configureWireGuard configures the WireGuard interface using the wg command. +func (n *wireguard) configureWireGuard(ifaceName string) error { + // Generate private key if not provided + privateKey := n.config["private_key"] + if privateKey == "" { + // Generate private key using wg genkey + cmd := exec.Command("wg", "genkey") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("Failed to generate WireGuard private key: %w", err) + } + + privateKey = strings.TrimSpace(string(output)) + + // Store the generated key in config + n.config["private_key"] = privateKey + err = n.state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + return tx.UpdateNetwork(ctx, n.project, n.name, n.description, n.config) + }) + if err != nil { + return fmt.Errorf("Failed to save generated private key: %w", err) + } + } + + // Set private key + cmd := exec.Command("wg", "set", ifaceName, "private-key", "/dev/stdin") + cmd.Stdin = strings.NewReader(privateKey) + err := cmd.Run() + if err != nil { + return fmt.Errorf("Failed to set WireGuard private key: %w", err) + } + + // Set listen port only if a valid port is specified (not empty and not "0") + listenPort := n.config["listen_port"] + if listenPort != "" && listenPort != "0" { + cmd = exec.Command("wg", "set", ifaceName, "listen-port", listenPort) + err = cmd.Run() + if err != nil { + return fmt.Errorf("Failed to set WireGuard listen port: %w", err) + } + } + + // Add peers + peers := make(map[string]map[string]string) + for key, value := range n.config { + if strings.HasPrefix(key, "peers.") { + parts := strings.SplitN(key, ".", 3) + if len(parts) == 3 { + peerName := parts[1] + peerKey := parts[2] + + if peers[peerName] == nil { + peers[peerName] = make(map[string]string) + } + + peers[peerName][peerKey] = value + } + } + } + + // Configure each peer + for peerName, peerConfig := range peers { + publicKey, ok := peerConfig["public_key"] + if !ok || publicKey == "" { + n.logger.Warn("Skipping peer without public_key", logger.Ctx{"peer": peerName}) + continue + } + + // Build wg set command for peer + args := []string{"set", ifaceName, "peer", publicKey} + + allowedIPs, ok := peerConfig["allowed_ips"] + if ok && allowedIPs != "" { + args = append(args, "allowed-ips", allowedIPs) + } + + endpoint, ok := peerConfig["endpoint"] + if ok && endpoint != "" { + args = append(args, "endpoint", endpoint) + } + + keepalive, ok := peerConfig["persistent_keepalive"] + if ok && keepalive != "" { + args = append(args, "persistent-keepalive", keepalive) + } + + cmd := exec.Command("wg", args...) + err := cmd.Run() + if err != nil { + return fmt.Errorf("Failed to configure peer %q: %w", peerName, err) + } + } + + return nil +} + +// Stop stops the network. +func (n *wireguard) Stop() error { + n.logger.Debug("Stop") + + // Determine interface name + ifaceName := n.config["interface"] + if ifaceName == "" { + ifaceName = n.name + } + + // Remove IP addresses if specified and interface exists (supports multiple addresses) + for _, keyPrefix := range []string{"ipv4", "ipv6"} { + addrKey := fmt.Sprintf("%s.address", keyPrefix) + if n.config[addrKey] != "" && InterfaceExists(ifaceName) { + addresses := util.SplitNTrimSpace(n.config[addrKey], ",", -1, true) + for _, addrStr := range addresses { + ipAddress, _, err := net.ParseCIDR(addrStr) + if err == nil { + err = n.removeAddress(ifaceName, ipAddress) + if err != nil { + n.logger.Warn("Failed to remove address from interface", logger.Ctx{"address": addrStr, "interface": ifaceName, "error": err}) + } + } + } + } + } + + // Remove WireGuard interface if it exists + if InterfaceExists(ifaceName) { + link := &ip.Link{Name: ifaceName} + err := link.Delete() + if err != nil { + return fmt.Errorf("Failed to remove WireGuard interface %q: %w", ifaceName, err) + } + } + + return nil +} + +// addressExists checks if the given IP address already exists on the interface. +func (n *wireguard) addressExists(ifaceName string, ipAddress net.IP) (bool, error) { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + return false, err + } + + addrs, err := iface.Addrs() + if err != nil { + return false, err + } + + for _, addr := range addrs { + addrIP, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + + if addrIP.Equal(ipAddress) { + return true, nil + } + } + + return false, nil +} + +// removeAddress removes the given IP address from the interface. +func (n *wireguard) removeAddress(ifaceName string, ipAddress net.IP) error { + link, err := netlink.LinkByName(ifaceName) + if err != nil { + return fmt.Errorf("Failed to find link %q: %w", ifaceName, err) + } + + // Get all addresses on the interface + addrs, err := netlink.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return fmt.Errorf("Failed to list addresses on %q: %w", ifaceName, err) + } + + // Find and remove the matching address + for _, addr := range addrs { + if addr.IP.Equal(ipAddress) { + err = netlink.AddrDel(link, &addr) + if err != nil { + // Ignore error if address doesn't exist + if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "no such") { + return fmt.Errorf("Failed to remove address %q from %q: %w", ipAddress.String(), ifaceName, err) + } + } + return nil + } + } + + return nil +} + +// Update updates the network. +func (n *wireguard) Update(newNetwork api.NetworkPut, targetNode string, clientType request.ClientType) error { + n.logger.Debug("Update", logger.Ctx{"clientType": clientType, "newNetwork": newNetwork}) + + dbUpdateNeeded, changedKeys, oldNetwork, err := n.configChanged(newNetwork) + if err != nil { + return err + } + + if !dbUpdateNeeded { + return nil // Nothing changed. + } + + // If the network as a whole has not had any previous creation attempts, or the node itself is still + // pending, then don't apply the new settings to the node, just to the database record (ready for the + // actual global create request to be initiated). + if n.Status() == api.NetworkStatusPending || n.LocalStatus() == api.NetworkStatusPending { + return n.update(newNetwork, targetNode, clientType) + } + + reverter := revert.New() + defer reverter.Fail() + + // Define a function which reverts everything. + reverter.Add(func() { + // Reset changes to all nodes and database. + _ = n.update(oldNetwork, targetNode, clientType) + }) + + // Check if interface name changed + interfaceChanged := slices.Contains(changedKeys, "interface") + + if interfaceChanged { + // Stop old interface + err = n.Stop() + if err != nil { + return err + } + } + + // Apply changes to all nodes and database. + err = n.update(newNetwork, targetNode, clientType) + if err != nil { + return err + } + + // Restart with new configuration + err = n.setup() + if err != nil { + return err + } + + reverter.Success() + + return nil +} diff --git a/internal/server/network/network_load.go b/internal/server/network/network_load.go index 6ac171376c3..b98a721cb8e 100644 --- a/internal/server/network/network_load.go +++ b/internal/server/network/network_load.go @@ -11,11 +11,12 @@ import ( ) var drivers = map[string]func() Network{ - "bridge": func() Network { return &bridge{} }, - "macvlan": func() Network { return &macvlan{} }, - "sriov": func() Network { return &sriov{} }, - "ovn": func() Network { return &ovn{} }, - "physical": func() Network { return &physical{} }, + "bridge": func() Network { return &bridge{} }, + "macvlan": func() Network { return &macvlan{} }, + "sriov": func() Network { return &sriov{} }, + "ovn": func() Network { return &ovn{} }, + "physical": func() Network { return &physical{} }, + "wireguard": func() Network { return &wireguard{} }, } // ProjectNetwork is a composite type of project name and network name. diff --git a/internal/server/network/network_utils.go b/internal/server/network/network_utils.go index 676979aa184..3e6d14cabee 100644 --- a/internal/server/network/network_utils.go +++ b/internal/server/network/network_utils.go @@ -1577,3 +1577,93 @@ func ipInRanges(ipAddr net.IP, ipRanges []iprange.Range) bool { return false } + +// GenerateRandomIPFromSubnet generates a random IP address from the given subnet, +// avoiding the network address and the excludeIP. +func GenerateRandomIPFromSubnet(subnet *net.IPNet, excludeIP net.IP) (net.IP, error) { + // Calculate subnet size + mask, bits := subnet.Mask.Size() + hostBits := bits - mask + if hostBits <= 1 { + return nil, fmt.Errorf("Subnet too small to generate random IP") + } + + // Calculate number of usable IPs (excluding network and broadcast) + numIPs := big.NewInt(1) + numIPs.Lsh(numIPs, uint(hostBits)) + numIPs.Sub(numIPs, big.NewInt(2)) // Subtract network and broadcast + + if numIPs.Cmp(big.NewInt(0)) <= 0 { + return nil, fmt.Errorf("No usable IPs in subnet") + } + + // Generate random offset (1 to numIPs, avoiding network and broadcast) + randomOffset, err := cryptoRand.Int(cryptoRand.Reader, numIPs) + if err != nil { + return nil, fmt.Errorf("Failed to generate random offset: %w", err) + } + + // Add 1 to skip network address + randomOffset.Add(randomOffset, big.NewInt(1)) + + // Calculate the IP + subnetIP := subnet.IP.To4() + if subnetIP == nil { + subnetIP = subnet.IP.To16() + } + + subnetBig := big.NewInt(0) + subnetBig.SetBytes(subnetIP) + subnetBig.Add(subnetBig, randomOffset) + + // Convert back to IP + var newIP net.IP + if subnet.IP.To4() != nil { + newIP = make(net.IP, 4) + ipInt := subnetBig.Uint64() + newIP[0] = byte(ipInt >> 24) + newIP[1] = byte(ipInt >> 16) + newIP[2] = byte(ipInt >> 8) + newIP[3] = byte(ipInt) + } else { + newIP = subnetBig.Bytes() + // Pad to 16 bytes for IPv6 + if len(newIP) < 16 { + padded := make(net.IP, 16) + copy(padded[16-len(newIP):], newIP) + newIP = padded + } + } + + // If the generated IP matches the exclude IP, try the next one + if newIP.Equal(excludeIP) { + randomOffset.Add(randomOffset, big.NewInt(1)) + if randomOffset.Cmp(numIPs) > 0 { + randomOffset.SetInt64(1) // Wrap around + } + + subnetBig.SetBytes(subnetIP) + subnetBig.Add(subnetBig, randomOffset) + if subnet.IP.To4() != nil { + ipInt := subnetBig.Uint64() + newIP[0] = byte(ipInt >> 24) + newIP[1] = byte(ipInt >> 16) + newIP[2] = byte(ipInt >> 8) + newIP[3] = byte(ipInt) + } else { + newIP = subnetBig.Bytes() + if len(newIP) < 16 { + padded := make(net.IP, 16) + copy(padded[16-len(newIP):], newIP) + newIP = padded + } + } + } + + // Verify the IP is within the subnet + if !subnet.Contains(newIP) { + return nil, fmt.Errorf("Generated IP %s is not within subnet %s", newIP.String(), subnet.String()) + } + + return newIP, nil +} diff --git a/internal/version/api.go b/internal/version/api.go index cc7a6237ef8..a27566cd4b0 100644 --- a/internal/version/api.go +++ b/internal/version/api.go @@ -225,6 +225,7 @@ var APIExtensions = []string{ "backup_override_name", "storage_rsync_compression", "network_type_physical", + "network_type_wireguard", "network_ovn_external_subnets", "network_ovn_nat", "network_ovn_external_routes_remove",