diff --git a/docs/development.md b/docs/development.md index 13ce7c3..60d2de1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,8 +143,15 @@ The following example uses the qemu driver, and connects using vmnet-run: [ 16.630] INFO VM is ready at test-vmnet-helper.local ``` -VMs use DHCP by default. To assign a static IP address, restrict the DHCP range, -then select an address outside of that range: +### Static IP addressing + +VMs use DHCP by default. To assign a static IP address, the host interface +subnet must be defined. This can be done in two ways: + +**In DHCP mode:** + +Set a specific DHCP range, then assign an address on the same subnet. +**--start-address** and **--ip-address** must be different: ```console % ./run test \ @@ -158,9 +165,20 @@ then select an address outside of that range: > Setting `--ip-address` to a value inside the DHCP range may work, but may > cause conflicts. -> [!NOTE] -> `--ip-address` must be difrerent from `--start-address`, but in the same -> subnet. +**When --network-id is set:** + +In host mode only, **--network-id** disables DHCP. Assign a specific address +and subnet to the host, then a different address in the same subnet to the +guest: + +```console +% ./run test \ + --operation-mode=host \ + --network-id=009D22BF-E40F-4251-A58F-DAC0B4E1250F \ + --host-ip-address 192.168.200.1 \ + --host-subnet-mask 255.255.255.0 \ + --ip-address 192.168.200.2 +``` When changing a VM's IP address or switching to DHCP, the instance ID and host key will be reset. Remove the old host key before you ssh again: diff --git a/docs/integration.md b/docs/integration.md index f53bb5a..a509ab7 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -180,7 +180,8 @@ Allows the vmnet interface to communicate with other vmnet interfaces that are in host mode and also with the native host. The network can be configured using the -[network address options](#network-address-options). +[network address options](#network-address-options) and the +[network id options](#network-id-options). ### --operation-mode=shared @@ -335,6 +336,48 @@ cases and much worse performance in other cases. See the > You must use both **--enable-tso** and **--enable-checksum-offload** when > using krunkit **offloading=on** virtio-net option. +## Network ID options + +In host mode, the **--network-id** can be set to a UUID. Any interfaces +started with the same identifier can communicate with each other, but are +isolated from other host mode interfaces. This also disables DHCP on the +network. + +```console +% vmnet-helper --socket ./test.sock --operation-mode host --network-id C6E763C8-F8E9-4CC4-9280-F2E40ABC5B73 +INFO [main] running vmnet-helper v0.12.0-24-g4964378 on macOS 26.5.2 +INFO [main] running as uid: 501 gid: 20 +INFO [main] using bulk_forwarding: true +{"vmnet_write_max_packets":256,"vmnet_read_max_packets":256,"vmnet_subnet_mask":"255.255.255.0","vmnet_mtu":1500,"vmnet_end_address":"192.168.128.254","vmnet_start_address":"192.168.128.1","vmnet_interface_id":"E496A990-DB40-453B-85E3-1F758908EE06","vmnet_max_packet_size":1514,"vmnet_mac_address":"1e:2a:95:42:af:9e"} +INFO [main] started vmnet interface +INFO [main] waiting for client on "./test.sock" +``` + +> [!NOTE] +> On macOS 15 or earlier, no address is assigned to the host interface when +> **--network-id** is set. **vmnet_start_address** will be **0.0.0.0**. If +> you need a host address, see the host address options below. + +Since DHCP is disabled, the [network address options](#network-address-options) +are not available. To control the address and subnet mask assigned to the host +interface, use **--host-ip-address** and **--host-subnet-mask**. vmnet returns +the host address as **vmnet_start_address**. + +```console +% vmnet-helper \ + --socket ./test.sock \ + --operation-mode host \ + --network-id C6E763C8-F8E9-4CC4-9280-F2E40ABC5B73 \ + --host-ip-address 192.168.200.12 \ + --host-subnet-mask 255.255.255.0 +INFO [main] running vmnet-helper v0.12.0-24-g4964378 on macOS 26.5.2 +INFO [main] running as uid: 501 gid: 20 +INFO [main] using bulk_forwarding: true +{"vmnet_write_max_packets":256,"vmnet_read_max_packets":256,"vmnet_subnet_mask":"255.255.255.0","vmnet_mtu":1500,"vmnet_end_address":"192.168.200.254","vmnet_start_address":"192.168.200.12","vmnet_interface_id":"345CD3A2-9327-4BBE-B0D8-4424B30A0F19","vmnet_max_packet_size":1514,"vmnet_mac_address":"be:82:e0:83:ab:59"} +INFO [main] started vmnet interface +INFO [main] waiting for client on "./test.sock" +``` + ## Stopping the interface Terminate the vmnet-helper process gracefully. Send a SIGTERM or SIGINT diff --git a/programs/helper.c b/programs/helper.c index 9004756..e91620a 100644 --- a/programs/helper.c +++ b/programs/helper.c @@ -300,6 +300,7 @@ static const char *mode_name(operating_modes_t mode) // Start interface with the specified operation mode and options. static void start_interface_with_options(void) { + char network_id[37] = "(unset)"; char interface_id[37] = "(unset)"; if (!uuid_is_null(options.interface_id)) { uuid_unparse(options.interface_id, interface_id); @@ -308,8 +309,12 @@ static void start_interface_with_options(void) switch (options.operation_mode) { case VMNET_SHARED_MODE: case VMNET_HOST_MODE: + if (!uuid_is_null(options.network_id)) { + uuid_unparse(options.network_id, network_id); + } DEBUGF("[main] starting interface mode '%s' interface-id '%s' " "start-address '%s' end-address '%s' subnet-mask '%s' " + "network-id '%s' host-ip-address '%s' host-subnet-mask '%s' " "enable-tso %s enable-checksum-offload %s " "enable-isolation %s", mode_name(options.operation_mode), @@ -317,6 +322,9 @@ static void start_interface_with_options(void) options.start_address, options.end_address, options.subnet_mask, + network_id, + options.host_ip_address, + options.host_subnet_mask, bool_str(options.enable_tso), bool_str(options.enable_checksum_offload), bool_str(options.enable_isolation)); @@ -342,6 +350,12 @@ static void start_interface_with_options(void) switch (options.operation_mode) { case VMNET_SHARED_MODE: + if (options.start_address != NULL) { + xpc_dictionary_set_string(desc, vmnet_start_address_key, options.start_address); + xpc_dictionary_set_string(desc, vmnet_end_address_key, options.end_address); + xpc_dictionary_set_string(desc, vmnet_subnet_mask_key, options.subnet_mask); + } + xpc_dictionary_set_bool(desc, vmnet_enable_isolation_key, options.enable_isolation); case VMNET_HOST_MODE: if (options.start_address != NULL) { xpc_dictionary_set_string(desc, vmnet_start_address_key, options.start_address); @@ -349,6 +363,13 @@ static void start_interface_with_options(void) xpc_dictionary_set_string(desc, vmnet_subnet_mask_key, options.subnet_mask); } xpc_dictionary_set_bool(desc, vmnet_enable_isolation_key, options.enable_isolation); + if (!uuid_is_null(options.network_id)) { + xpc_dictionary_set_uuid(desc, vmnet_network_identifier_key, options.network_id); + if (options.host_ip_address != NULL && options.host_subnet_mask != NULL) { + xpc_dictionary_set_string(desc, vmnet_host_ip_address_key, options.host_ip_address); + xpc_dictionary_set_string(desc, vmnet_host_subnet_mask_key, options.host_subnet_mask); + } + } break; case VMNET_BRIDGED_MODE: xpc_dictionary_set_string(desc, vmnet_shared_interface_name_key, options.shared_interface); diff --git a/programs/options.c b/programs/options.c index f21dfdc..c45835c 100644 --- a/programs/options.c +++ b/programs/options.c @@ -30,6 +30,8 @@ static void usage(int code) " vmnet-helper (--fd FD|--socket SOCKET) [--interface-id UUID]\n" " [--operation-mode shared|bridged|host] [--shared-interface NAME]\n" " [--start-address ADDR] [--end-address ADDR] [--subnet-mask MASK]\n" +" [--network-id UUID] [--host-ip-address ADDR]\n" +" [--host-subnet-mask MASK]\n" " [--enable-tso] [--enable-checksum-offload] [--enable-isolation]\n" " [--network NAME] [--list-shared-interfaces]\n" " [--stats-interval SECONDS]\n" @@ -40,7 +42,8 @@ static void usage(int code) " With --network, vmnet-helper joins a network managed by vmnet-broker.\n" "\n" " --network is mutually exclusive with: --operation-mode, --shared-interface,\n" -" --start-address, --end-address, --subnet-mask.\n" +" --start-address, --end-address, --subnet-mask, --network-id, --host-ip-address,\n" +" --host-subnet-mask.\n" "\n" " --network requires macOS 26 or later.\n" "\n"; @@ -54,6 +57,9 @@ enum { OPT_START_ADDRESS, OPT_END_ADDRESS, OPT_SUBNET_MASK, + OPT_NETWORK_ID, + OPT_HOST_IP_ADDRESS, + OPT_HOST_SUBNET_MASK, OPT_ENABLE_TSO, OPT_ENABLE_CHECKSUM_OFFLOAD, OPT_ENABLE_ISOLATION, @@ -74,6 +80,9 @@ static struct option long_options[] = { {"start-address", required_argument, 0, OPT_START_ADDRESS}, {"end-address", required_argument, 0, OPT_END_ADDRESS}, {"subnet-mask", required_argument, 0, OPT_SUBNET_MASK}, + {"network-id", required_argument, 0, OPT_NETWORK_ID}, + {"host-ip-address", required_argument, 0, OPT_HOST_IP_ADDRESS}, + {"host-subnet-mask", required_argument, 0, OPT_HOST_SUBNET_MASK}, {"enable-tso", no_argument, 0, OPT_ENABLE_TSO}, {"enable-checksum-offload", no_argument, 0, OPT_ENABLE_CHECKSUM_OFFLOAD}, {"enable-isolation", no_argument, 0, OPT_ENABLE_ISOLATION}, @@ -154,6 +163,14 @@ static void parse_interface_id(const char *arg, uuid_t uuid) } } +static void parse_network_id(const char *arg, uuid_t uuid) +{ + if (uuid_parse(arg, uuid) < 0) { + ERRORF("Invalid network-id: \"%s\"", arg); + exit(EXIT_FAILURE); + } +} + static void parse_operation_mode(const char *arg, const char *name, uint32_t *mode) { if (strcmp(arg, "shared") == 0) { @@ -205,6 +222,25 @@ static void validate_network_options(struct options *opts) } } +static void validate_network_id_options(struct options *opts) +{ + int host_address_options_set = (opts->host_ip_address != NULL) + (opts->host_subnet_mask != NULL); + int dhcp_options_set = (opts->start_address != NULL) + (opts->end_address != NULL) + + (opts->subnet_mask != NULL); + if (host_address_options_set != 0 && uuid_is_null(opts->network_id)) { + ERROR("--host-ip-address and --host-subnet-mask require --network-id"); + exit(EXIT_FAILURE); + } + if (host_address_options_set != 0 && host_address_options_set != 2) { + ERROR("--host-ip-address and --host-subnet-mask must be given together, or all omitted"); + exit(EXIT_FAILURE); + } + if (!uuid_is_null(opts->network_id) && dhcp_options_set != 0) { + ERROR("--network-id cannot be used with --start-address, --end-address, --subnet-mask"); + exit(EXIT_FAILURE); + } +} + void parse_options(struct options *opts, int argc, char **argv) { const char *optname; @@ -248,6 +284,15 @@ void parse_options(struct options *opts, int argc, char **argv) case OPT_SUBNET_MASK: parse_address(optarg, optname, &opts->subnet_mask); break; + case OPT_NETWORK_ID: + parse_network_id(optarg, opts->network_id); + break; + case OPT_HOST_IP_ADDRESS: + parse_address(optarg, optname, &opts->host_ip_address); + break; + case OPT_HOST_SUBNET_MASK: + parse_address(optarg, optname, &opts->host_subnet_mask); + break; case OPT_ENABLE_TSO: opts->enable_tso = true; break; @@ -304,6 +349,18 @@ void parse_options(struct options *opts, int argc, char **argv) ERROR("Conflicting arguments: --network cannot be used with --subnet-mask"); exit(EXIT_FAILURE); } + if (!uuid_is_null(opts->network_id)) { + ERROR("Conflicting arguments: --network cannot be used with --network-id"); + exit(EXIT_FAILURE); + } + if (opts->host_ip_address != NULL) { + ERROR("Conflicting arguments: --network cannot be used with --host-ip-address"); + exit(EXIT_FAILURE); + } + if (opts->host_subnet_mask != NULL) { + ERROR("Conflicting arguments: --network cannot be used with --host-subnet-mask"); + exit(EXIT_FAILURE); + } } else { // Apply defaults and validate when not using vmnet-broker network. if (opts->operation_mode == 0) { @@ -312,10 +369,37 @@ void parse_options(struct options *opts, int argc, char **argv) switch (opts->operation_mode) { case VMNET_SHARED_MODE: + validate_network_options(opts); + if (!uuid_is_null(opts->network_id)) { + ERROR("--network-id cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } + if (opts->host_ip_address != NULL) { + ERROR("--host-ip-address cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } + if (opts->host_subnet_mask != NULL) { + ERROR("--host-subnet-mask cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } + break; case VMNET_HOST_MODE: validate_network_options(opts); + validate_network_id_options(opts); break; case VMNET_BRIDGED_MODE: + if (!uuid_is_null(opts->network_id)) { + ERROR("--network-id cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } + if (opts->host_ip_address != NULL) { + ERROR("--host-ip-address cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } + if (opts->host_subnet_mask != NULL) { + ERROR("--host-subnet-mask cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } if (opts->shared_interface == NULL) { ERROR("Missing argument: shared-interface is required for operation-mode=bridged"); exit(EXIT_FAILURE); diff --git a/programs/options.h b/programs/options.h index 7c81c86..9818719 100644 --- a/programs/options.h +++ b/programs/options.h @@ -15,6 +15,9 @@ struct options { const char *start_address; const char *end_address; const char *subnet_mask; + uuid_t network_id; + const char *host_ip_address; + const char *host_subnet_mask; const char *shared_interface; const char *network_name; bool enable_isolation; diff --git a/programs/run.c b/programs/run.c index f4354f8..6926c52 100644 --- a/programs/run.c +++ b/programs/run.c @@ -27,6 +27,9 @@ struct client_options { char *start_address; char *end_address; char *subnet_mask; + char *network_id; + char *host_ip_address; + char *host_subnet_mask; char *shared_interface; char *network_name; char *stats_interval; @@ -66,6 +69,9 @@ enum { OPT_START_ADDRESS, OPT_END_ADDRESS, OPT_SUBNET_MASK, + OPT_NETWORK_ID, + OPT_HOST_IP_ADDRESS, + OPT_HOST_SUBNET_MASK, OPT_ENABLE_TSO, OPT_ENABLE_CHECKSUM_OFFLOAD, OPT_ENABLE_ISOLATION, @@ -84,6 +90,9 @@ static struct option long_options[] = { {"start-address", required_argument, 0, OPT_START_ADDRESS}, {"end-address", required_argument, 0, OPT_END_ADDRESS}, {"subnet-mask", required_argument, 0, OPT_SUBNET_MASK}, + {"network-id", required_argument, 0, OPT_NETWORK_ID}, + {"host-ip-address", required_argument, 0, OPT_HOST_IP_ADDRESS}, + {"host-subnet-mask", required_argument, 0, OPT_HOST_SUBNET_MASK}, {"enable-tso", no_argument, 0, OPT_ENABLE_TSO}, {"enable-checksum-offload", no_argument, 0, OPT_ENABLE_CHECKSUM_OFFLOAD}, {"enable-isolation", no_argument, 0, OPT_ENABLE_ISOLATION}, @@ -105,6 +114,8 @@ static void usage(int code) " vmnet-run [--interface-id UUID] [--operation-mode shared|bridged|host]\n" " [--start-address ADDR] [--end-address ADDR]\n" " [--subnet-mask MASK] [--shared-interface NAME]\n" +" [--network-id UUID] [--host-ip-address ADDR]\n" +" [--host-subnet-mask MASK]\n" " [--enable-tso] [--enable-checksum-offload]\n" " [--enable-isolation] [--network NAME]\n" " [--stats-interval SECONDS]\n" @@ -116,7 +127,8 @@ static void usage(int code) " With --network, vmnet-helper joins a network managed by vmnet-broker.\n" "\n" " --network is mutually exclusive with: --operation-mode, --shared-interface,\n" -" --start-address, --end-address, --subnet-mask.\n" +" --start-address, --end-address, --subnet-mask, --network-id, --host-ip-address,\n" +" --host-subnet-mask." "\n" " --network requires macOS 26 or later.\n" "\n"; @@ -194,6 +206,21 @@ static void build_helper_argv(void) append_helper_arg(options.subnet_mask); } + if (options.network_id) { + append_helper_arg("--network-id"); + append_helper_arg(options.network_id); + } + + if (options.host_ip_address) { + append_helper_arg("--host-ip-address"); + append_helper_arg(options.host_ip_address); + } + + if (options.host_subnet_mask) { + append_helper_arg("--host-subnet-mask"); + append_helper_arg(options.host_subnet_mask); + } + if (options.shared_interface) { append_helper_arg("--shared-interface"); append_helper_arg(options.shared_interface); @@ -250,6 +277,15 @@ static void validate_interface_id(const char *arg) } } +static void validate_network_id(const char *arg) +{ + uuid_t uuid; + if (uuid_parse(arg, uuid) < 0) { + ERRORF("[runner] invalid network-id: \"%s\"", arg); + exit(EXIT_FAILURE); + } +} + static void validate_operation_mode(const char *arg) { if (!is_shared(arg) && !is_host(arg) && !is_bridged(arg)) { @@ -311,6 +347,18 @@ static void parse_options(int argc, char **argv) validate_address(optarg, optname); options.subnet_mask = optarg; break; + case OPT_NETWORK_ID: + validate_network_id(optarg); + options.network_id = optarg; + break; + case OPT_HOST_IP_ADDRESS: + validate_address(optarg, optname); + options.host_ip_address = optarg; + break; + case OPT_HOST_SUBNET_MASK: + validate_address(optarg, optname); + options.host_subnet_mask = optarg; + break; case OPT_ENABLE_TSO: options.enable_tso = true; break; @@ -364,17 +412,54 @@ static void parse_options(int argc, char **argv) ERROR("[runner] conflicting arguments: --network cannot be used with --subnet-mask"); exit(EXIT_FAILURE); } + if (options.network_id != NULL) { + ERROR("[runner] conflicting arguments: --network cannot be used with --network-id"); + exit(EXIT_FAILURE); + } + if (options.host_ip_address != NULL) { + ERROR("[runner] conflicting arguments: --network cannot be used with --host-ip-address"); + exit(EXIT_FAILURE); + } + if (options.host_subnet_mask != NULL) { + ERROR("[runner] conflicting arguments: --network cannot be used with --host-subnet-mask"); + exit(EXIT_FAILURE); + } } else if (is_bridged(options.operation_mode)) { if (options.shared_interface == NULL) { ERROR("[runner] missing argument: shared-interface is required for operation-mode=bridged"); exit(EXIT_FAILURE); } + if (options.network_id != NULL) { + ERROR("[runner] conflicting arguments: --network-id cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } + if (options.host_ip_address != NULL) { + ERROR("[runner] conflicting arguments: --host-ip-address cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } + if (options.host_subnet_mask != NULL) { + ERROR("[runner] conflicting arguments: --host-subnet-mask cannot be used with operation-mode=bridged"); + exit(EXIT_FAILURE); + } // TODO: Validate that isolation doesn't work with bridged mode. if (options.enable_isolation) { ERROR("[runner] conflicting arguments: enable-isolation not compatible with operation-mode=bridged"); exit(EXIT_FAILURE); } + } else if (is_shared(options.operation_mode)) { + if (options.network_id != NULL) { + ERROR("[runner] conflicting arguments: --network-id cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } + if (options.host_ip_address != NULL) { + ERROR("[runner] conflicting arguments: --host-ip-address cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } + if (options.host_subnet_mask != NULL) { + ERROR("[runner] conflicting arguments: --host-subnet-mask cannot be used with operation-mode=shared"); + exit(EXIT_FAILURE); + } } // The rest of the arguments are the command arguments. diff --git a/run b/run index df18705..fc5afb7 100755 --- a/run +++ b/run @@ -68,6 +68,31 @@ def main(): type=testing.validate.subnet_mask, help="The IPv4 subnet mask (string) to use on the interface.", ) + p.add_argument( + "--network-id", + type=testing.validate.network_id, + help=( + "The identifier (UUID) of the host network to use on the interface " + "(host operation-mode only)." + ), + ) + p.add_argument( + "--host-ip-address", + type=testing.validate.private_ipv4_address, + help=( + "The IPv4 address (string) to assign to the host's interface. " + "The address must be in the private IP range (RFC 1918). " + "Requires --network-id and --host-subnet-mask." + ), + ) + p.add_argument( + "--host-subnet-mask", + type=testing.validate.subnet_mask, + help=( + "The IPv4 subnet mask (string) to assign to the host's interface. " + "Requires --network-id and --host-ip-address." + ), + ) p.add_argument( "--shared-interface", choices=shared_interfaces, @@ -174,6 +199,7 @@ def main(): setup_logging(args.verbose) testing.validate.network_options(p, args) + testing.validate.ip_address_options(p, args) testing.validate.operation_mode(p, args) signal.signal(signal.SIGTERM, terminate) diff --git a/testing/cidata.py b/testing/cidata.py index 79dee66..9e32e21 100644 --- a/testing/cidata.py +++ b/testing/cidata.py @@ -171,22 +171,24 @@ def create_network_config(vm): }, }, } - dhcp_data = { - "dhcp-identifier": "mac", - "dhcp4-overrides": { - "use-dns": False, - }, - } if vm.args.ip_address: + mask = vm.args.subnet_mask or vm.args.host_subnet_mask data["ethernets"]["eth0"]["addresses"] = [ - ipaddress.IPv4Interface( - (vm.args.ip_address, vm.args.subnet_mask) - ).with_prefixlen + ipaddress.IPv4Interface((vm.args.ip_address, mask)).with_prefixlen ] data["ethernets"]["eth0"]["routes"] = [ - {"to": "default", "via": str(vm.args.start_address)} + { + "to": "default", + "via": str(vm.args.start_address or vm.args.host_ip_address), + } ] else: + dhcp_data = { + "dhcp-identifier": "mac", + "dhcp4-overrides": { + "use-dns": False, + }, + } data["ethernets"]["eth0"].update(dhcp_data) return data diff --git a/testing/helper.py b/testing/helper.py index a2f6fb9..5526108 100644 --- a/testing/helper.py +++ b/testing/helper.py @@ -65,6 +65,9 @@ def __init__( self.start_address = args.start_address self.end_address = args.end_address self.subnet_mask = args.subnet_mask + self.network_id = args.network_id + self.host_ip_address = args.host_ip_address + self.host_subnet_mask = args.host_subnet_mask self.shared_interface = args.shared_interface self.network_name = args.network_name self.enable_isolation = args.enable_isolation @@ -159,6 +162,15 @@ def _build_command(self, interface_id): if self.subnet_mask: cmd.append(f"--subnet-mask={self.subnet_mask}") + if self.network_id: + cmd.append(f"--network-id={self.network_id}") + + if self.host_ip_address: + cmd.append(f"--host-ip-address={self.host_ip_address}") + + if self.host_subnet_mask: + cmd.append(f"--host-subnet-mask={self.host_subnet_mask}") + if self.enable_isolation: cmd.append("--enable-isolation") diff --git a/testing/helper_test.py b/testing/helper_test.py index 17b1e5b..59b6e8d 100644 --- a/testing/helper_test.py +++ b/testing/helper_test.py @@ -25,6 +25,7 @@ import socket import time from datetime import datetime +import uuid from types import SimpleNamespace import pytest @@ -274,6 +275,25 @@ def test_host_mode_isolated(self, tmp_path): ) as (h, sock): self.check_interface(h.interface) + def test_host_mode_network_id(self): + """ + Test starting helper in host mode with network id + """ + with run_helper(operation_mode="host", network_id=uuid.uuid4()) as (h, sock): + self.check_interface(h.interface) + + def test_host_mode_network_id_with_host_address(self): + """ + Test starting the helper in host mode with a host address set. + """ + with run_helper( + operation_mode="host", + network_id=uuid.uuid4(), + host_ip_address="192.168.200.1", + host_subnet_mask="255.255.255.0", + ) as (h, sock): + self.check_interface(h.interface) + def test_no_interface_id(self, tmp_path): """ Test starting helper without --interface-id. vmnet should assign @@ -321,6 +341,30 @@ def test_ping_gateway(self, tmp_path): gateway_ip = find_gateway_ip(h.interface) ping(h, sock, gateway_mac, gateway_ip) + @pytest.mark.skipif(not MACOS_26, reason="host doesn't get an IP on macOS <26") + def test_ping_host_network_id(self): + """ + Test ICMP ping to host when --network-id is set, but not host address is set. + """ + with run_helper(operation_mode="host", network_id=uuid.uuid4()) as (h, sock): + host_mac = arp_resolve(h, sock) + host_ip = find_gateway_ip(h.interface) + ping(h, sock, host_mac, host_ip) + + def test_ping_host_network_id_with_host_address(self): + """ + Test ICMP ping to host when --network-id is set, and a host address is set. + """ + with run_helper( + operation_mode="host", + network_id=uuid.uuid4(), + host_ip_address="192.168.200.1", + host_subnet_mask="255.255.255.0", + ) as (h, sock): + host_mac = arp_resolve(h, sock) + host_ip = find_gateway_ip(h.interface) + ping(h, sock, host_mac, host_ip) + def test_ping_external_via_nat(self, tmp_path): """ Test ICMP ping to external IP via NAT @@ -475,6 +519,9 @@ def run_helper( start_address=None, end_address=None, subnet_mask=None, + network_id=None, + host_ip_address=None, + host_subnet_mask=None, shared_interface=None, network_name=None, enable_isolation=False, @@ -502,6 +549,9 @@ def run_helper( start_address=start_address, end_address=end_address, subnet_mask=subnet_mask, + network_id=network_id, + host_ip_address=host_ip_address, + host_subnet_mask=host_subnet_mask, shared_interface=shared_interface, network_name=network_name, enable_isolation=enable_isolation, diff --git a/testing/validate.py b/testing/validate.py index 7d5cc40..2365177 100644 --- a/testing/validate.py +++ b/testing/validate.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import ipaddress +import uuid def ip_list(s): @@ -25,6 +26,16 @@ def subnet_mask(s): return s +def network_id(s): + """ + Raises ValueError if 's' is not a hexadecimal UUID. + + Returns the unmodified string expected by vmnet-helper. + """ + uuid.UUID(s) + return s + + def _one_dhcp_option_set(args): """ Returns True if one or more DHCP option is set. @@ -44,6 +55,30 @@ def _dhcp_options(p, args): p.error( "--start-address, --end-address, --subnet-mask must all be set or all omitted" ) + if _all_dhcp_options_set(args): + # vmnet does not enforce the order of --start-address and --end-address. + network = ipaddress.IPv4Interface( + (args.start_address, args.subnet_mask) + ).network + if args.end_address not in network: + p.error("--start-address and --end-address must be in the same subnet") + # --ip-address inside the DHCP range may cause conflicts, but works. + if args.ip_address: + if args.ip_address not in network: + p.error( + "--ip-address, --start-address and --end-address must be in the same subnet" + ) + # Reserve --start-address, since it gets assigned to the host. + if args.ip_address == args.start_address: + p.error("--ip-address must be different from --start-address") + + +def _one_host_ip_option_set(args): + return args.host_ip_address or args.host_subnet_mask + + +def _all_host_ip_options_set(args): + return args.host_ip_address and args.host_subnet_mask and args.network_id def _bridged_mode(p, args): @@ -51,14 +86,49 @@ def _bridged_mode(p, args): p.error("--shared-interface required for --operation-mode=bridged") if args.enable_isolation: p.error("--enable-isolation not compatible with --operation-mode=bridged") + if args.network_id: + p.error("--network-id is not compatible with --operation-mode=bridged") + if _one_host_ip_option_set(args): + p.error( + "--host-ip-address and --host-subnet-mask are not compatible with " + "--operation-mode=bridged" + ) def _shared_mode(p, args): _dhcp_options(p, args) + if args.network_id: + p.error("--network-id is not compatible with --operation-mode=shared") + if _one_host_ip_option_set(args): + p.error( + "--host-ip-address and --host-subnet-mask are not compatible with " + "--operation-mode=shared" + ) def _host_mode(p, args): _dhcp_options(p, args) + if _one_host_ip_option_set(args) and not args.network_id: + p.error("--host-ip-address and --host-subnet-mask require --network-id") + if args.network_id: + if _one_host_ip_option_set(args) and not _all_host_ip_options_set(args): + p.error( + "--host-ip-address and --host-subnet-mask must all be set, or all omitted" + ) + if _one_dhcp_option_set(args): + p.error( + "--start-address, --end-address, --subnet-mask cannot be set with --network-id" + ) + if _all_host_ip_options_set(args) and args.ip_address: + network = ipaddress.IPv4Interface( + (args.host_ip_address, args.host_subnet_mask) + ).network + # The host and guest addresses must be on the same subnet + if args.ip_address not in network: + p.error("--ip-address must be in the same subnet as --host-ip-address") + # The host and guest addresses must be different + if args.ip_address == args.host_ip_address: + p.error("--ip-address must be different from --host-ip-address") def operation_mode(p, args): @@ -86,26 +156,21 @@ def network_options(p, args): if args.ip_address: p.error("--network cannot be used with --ip-address") - if args.ip_address and not _all_dhcp_options_set(args): - p.error("--ip-address requires --start-address, --end-address, --subnet-mask") - if _all_dhcp_options_set(args): - # vmnet does not enforce the order of --start-address and --end-address. - network = ipaddress.IPv4Interface( - (args.start_address, args.subnet_mask) - ).network - if args.end_address not in network: - p.error("--start-address and --end-address must be in the same subnet") - # --ip-address inside the DHCP range may cause conflicts, but works. - if args.ip_address: - if args.ip_address not in network: - p.error( - "--ip-address, --start-address and --end-address " - "must be in the same subnet", - ) - # Only reserve --start-address, since it gets assigned to the host. - if args.ip_address == args.start_address: - p.error("--ip-address must be different from --start-address") +def ip_address_options(p, args): + """ + Raises an error if the arguments required by --ip-address are not set. + + Without knowing the host interface subnet, we cannot validate --ip-address, and we + cannot determine the static address prefix length to pass to cloud-init. + """ + if args.ip_address and not ( + _all_dhcp_options_set(args) or _all_host_ip_options_set(args) + ): + p.error( + "--ip-address requires either --start-address, --end-address, --subnet-mask, or " + "--network-id, --host-ip-address, --host-subnet-mask." + ) _RFC1918_NETWORKS = [ diff --git a/testing/vm.py b/testing/vm.py index a717b8d..7433226 100644 --- a/testing/vm.py +++ b/testing/vm.py @@ -43,7 +43,6 @@ def __init__(self, args, mac_address, fd=None, socket=None, runner=None): self.memory = args.memory self.distro = args.distro self.dns_servers = args.dns_servers - self.ip_address = args.ip_address self.busy_poll = args.busy_poll self.serial = store.vm_path(self.vm_name, "serial.log") self.enable_offloading = args.enable_offloading @@ -285,6 +284,12 @@ def runner_command(self, vm_command): cmd.append(f"--end-address={self.args.end_address}") if self.args.subnet_mask: cmd.append(f"--subnet-mask={self.args.subnet_mask}") + if self.args.network_id: + cmd.append(f"--network-id={self.args.network_id}") + if self.args.host_ip_address: + cmd.append(f"--host-ip-address={self.args.host_ip_address}") + if self.args.host_subnet_mask: + cmd.append(f"--host-subnet-mask={self.args.host_subnet_mask}") if self.args.enable_isolation: cmd.append("--enable-isolation") if self.args.shared_interface: