diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in
index f20fd76902..d4efc08be8 100644
--- a/interface-definitions/container.xml.in
+++ b/interface-definitions/container.xml.in
@@ -566,6 +566,24 @@
#include
#include
+
+
+ Gateway address to use for this network
+
+ ipv4
+ IPv4 gateway address
+
+
+ ipv6
+ IPv6 gateway address
+
+
+
+
+
+
+
+
Prefix which allocated to that network
@@ -590,6 +608,62 @@
+
+
+ Network type (default: bridge)
+
+
+
+
+ Bridge network
+
+
+
+
+
+ MACVLAN network
+
+
+
+
+ MACVLAN mode
+
+ bridge private vepa
+
+
+ bridge
+ Containers act as separate hosts on the parent network
+
+
+ private
+ Containers are isolated from the host and each other
+
+
+ vepa
+ Containers send all traffic through the parent switch for forwarding
+
+
+ bridge|private|vepa
+
+ Invalid mode
+
+
+
+
+ Parent network interface
+
+
+
+
+ ((bond|br|eth)[0-9]+(\.[0-9]+)?)
+
+ Invalid parent interface
+
+
+
+
+
+
#include
diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py
index 93f780d8b1..e6b838cdce 100644
--- a/python/vyos/utils/network.py
+++ b/python/vyos/utils/network.py
@@ -13,8 +13,10 @@
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see .
+import hashlib
from socket import AF_INET
from socket import AF_INET6
+from vyos.utils.process import cmd
def _are_same_ip(one, two):
from socket import inet_pton
@@ -48,6 +50,49 @@ def is_netns_interface(interface, netns):
return True
return False
+def get_host_identity() -> str:
+ """
+ Build a stable host identity string for deterministic MAC generation.
+
+ Combines:
+ • The system's HardwareUUID (from /sys/class/dmi/id/product_uuid)
+ • The system hostname
+
+ Both are normalized (lowercase, dashes removed in UUID) and joined with a colon.
+
+ Returns:
+ str: A string ":", used as part of the host-specific seed when
+ generating deterministic MAC addresses.
+ """
+ uuid = cmd(f"cat /sys/class/dmi/id/product_uuid").strip().replace("-", "").lower()
+ host = cmd("hostname").strip().lower()
+ return f"{uuid}:{host}"
+
+def gen_mac(name: str, addr: str, ident: str) -> str:
+ """
+ Generate a deterministic locally-administered MAC address.
+
+ The MAC is derived from:
+ • Host identity (UUID + hostname)
+ • Container name
+ • Concatenated address string (IPv4 and/or IPv6 addresses)
+
+ A SHA-256 digest is computed from the combined string. The first 5 bytes
+ of the digest are used, prefixed with 0x02 to mark the address as
+ locally-administered and unicast.
+
+ Args:
+ name (str): Container name to differentiate MACs.
+ addr (str): Concatenated list of container addresses (IPv4/IPv6).
+
+ Returns:
+ str: Deterministic MAC address in standard "xx:xx:xx:xx:xx:xx" format.
+ """
+ h = hashlib.sha256(f"{ident}:{name}:{addr}".encode()).hexdigest()
+ # 0x02 = locally-administered, unicast
+ b = [0x02] + [int(h[i:i+2], 16) for i in range(0, 10, 2)] # 5 bytes = 40 bits
+ return ":".join(f"{x:02x}" for x in b)
+
def get_netns_all() -> list:
from json import loads
from vyos.utils.process import cmd
diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py
index 7590ed1cde..8642073089 100755
--- a/smoketest/scripts/cli/test_container.py
+++ b/smoketest/scripts/cli/test_container.py
@@ -165,6 +165,64 @@ def test_cpu_limit(self):
# Check for running process
self.assertEqual(process_named_running(PROCESS_NAME), pid)
+ def test_network_types(self):
+ self.cli_set(['interfaces', 'ethernet', 'eth0', 'vif', '100'])
+ self.cli_set(['interfaces', 'ethernet', 'eth0', 'vif', '101'])
+
+ # MACVLAN Networks
+ self.cli_set(base_path + ['network', 'macvlan1', 'prefix', '10.0.0.0/24'])
+ self.cli_set(base_path + ['network', 'macvlan1', 'type', 'macvlan', 'parent', 'eth0'])
+ self.cli_set(base_path + ['network', 'macvlan1', 'type', 'macvlan', 'mode', 'bridge'])
+ self.cli_set(base_path + ['network', 'macvlan2', 'prefix', '10.0.100.0/24'])
+ self.cli_set(base_path + ['network', 'macvlan2', 'gateway', '10.0.100.5'])
+ self.cli_set(base_path + ['network', 'macvlan2', 'type', 'macvlan', 'parent', 'eth0.100'])
+ self.cli_set(base_path + ['network', 'macvlan2', 'type', 'macvlan', 'mode', 'private'])
+ self.cli_set(base_path + ['network', 'macvlan3', 'prefix', '2001::/64'])
+ self.cli_set(base_path + ['network', 'macvlan3', 'type', 'macvlan', 'parent', 'eth0.101'])
+ self.cli_set(base_path + ['network', 'macvlan3', 'type', 'macvlan', 'mode', 'vepa'])
+
+ # Bridge Network
+ self.cli_set(base_path + ['network', 'bridge1', 'prefix', '10.0.1.0/24'])
+ self.cli_set(base_path + ['network', 'bridge1', 'type', 'bridge'])
+
+ # Bridge Network before T7186; default network type is bridge
+ self.cli_set(base_path + ['network', 'bridge2', 'prefix', '10.0.2.0/24'])
+
+ self.cli_commit()
+
+ n = cmd_to_json(f'sudo podman network inspect macvlan1')
+ self.assertEqual(n['driver'], 'macvlan')
+ self.assertEqual(n['network_interface'], 'eth0')
+ self.assertEqual(n['options']['mode'], 'bridge')
+ self.assertEqual(n['subnets'][0]['subnet'], '10.0.0.0/24')
+ self.assertEqual(n['subnets'][0]['gateway'], '10.0.0.1')
+
+ n = cmd_to_json(f'sudo podman network inspect macvlan2')
+ self.assertEqual(n['driver'], 'macvlan')
+ self.assertEqual(n['network_interface'], 'eth0.100')
+ self.assertEqual(n['options']['mode'], 'private')
+ self.assertEqual(n['subnets'][0]['subnet'], '10.0.100.0/24')
+ self.assertEqual(n['subnets'][0]['gateway'], '10.0.100.5')
+
+ n = cmd_to_json(f'sudo podman network inspect macvlan3')
+ self.assertEqual(n['driver'], 'macvlan')
+ self.assertEqual(n['network_interface'], 'eth0.101')
+ self.assertEqual(n['options']['mode'], 'vepa')
+ self.assertEqual(n['subnets'][0]['subnet'], '2001::/64')
+ self.assertEqual(n['subnets'][0]['gateway'], '2001::1')
+
+ n = cmd_to_json(f'sudo podman network inspect bridge1')
+ self.assertEqual(n['driver'], 'bridge')
+ self.assertEqual(n['network_interface'], 'pod-bridge1')
+ self.assertEqual(n['subnets'][0]['subnet'], '10.0.1.0/24')
+ self.assertEqual(n['subnets'][0]['gateway'], '10.0.1.1')
+
+ n = cmd_to_json(f'sudo podman network inspect bridge2')
+ self.assertEqual(n['driver'], 'bridge')
+ self.assertEqual(n['network_interface'], 'pod-bridge2')
+ self.assertEqual(n['subnets'][0]['subnet'], '10.0.2.0/24')
+ self.assertEqual(n['subnets'][0]['gateway'], '10.0.2.1')
+
def test_ipv4_network(self):
prefix = '192.0.2.0/24'
base_name = 'ipv4'
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py
index 4ec9b8849b..8950f857e9 100755
--- a/src/conf_mode/container.py
+++ b/src/conf_mode/container.py
@@ -32,9 +32,12 @@
from vyos.ifconfig import Interface
from vyos.utils.cpu import get_core_count
from vyos.utils.file import write_file
+from vyos.utils.dict import dict_search
from vyos.utils.process import call
from vyos.utils.process import cmd
from vyos.utils.process import run
+from vyos.utils.network import gen_mac
+from vyos.utils.network import get_host_identity
from vyos.utils.network import interface_exists
from vyos.template import bracketize_ipv6
from vyos.template import inc_ip
@@ -260,22 +263,59 @@ def verify(container):
# Add new network
if 'network' in container:
for network, network_config in container['network'].items():
- v4_prefix = 0
- v6_prefix = 0
+ net_dict = {'ipv4_pfx_len': 0, 'ipv6_pfx_len': 0, 'ipv4_gateway_len': 0, 'ipv6_gateway_len': 0}
+
# If ipv4-prefix not defined for user-defined network
if 'prefix' not in network_config:
raise ConfigError(f'prefix for network "{network}" must be defined!')
for prefix in network_config['prefix']:
if is_ipv4(prefix):
- v4_prefix += 1
+ net_dict['ipv4_pfx_len'] += 1
+ net_dict['ipv4_prefix'] = prefix
elif is_ipv6(prefix):
- v6_prefix += 1
-
- if v4_prefix > 1:
+ net_dict['ipv6_pfx_len'] += 1
+ net_dict['ipv6_prefix'] = prefix
+
+ for gateway in network_config.get('gateway', []):
+ if is_ipv4(gateway):
+ net_dict['ipv4_gateway_len'] += 1
+ net_dict['ipv4_gateway'] = gateway
+ elif is_ipv6(gateway):
+ net_dict['ipv6_gateway_len'] += 1
+ net_dict['ipv6_gateway'] = gateway
+
+ if net_dict['ipv4_pfx_len'] > 1:
raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!')
- if v6_prefix > 1:
+ if net_dict['ipv6_pfx_len'] > 1:
raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!')
+ if net_dict['ipv4_gateway_len'] > 1:
+ raise ConfigError(f'Only one IPv4 gateway can be defined for network "{network}"!')
+ if net_dict['ipv6_gateway_len'] > 1:
+ raise ConfigError(f'Only one IPv6 gateway can be defined for network "{network}"!')
+
+ if net_dict.get('ipv4_prefix') and net_dict.get('ipv4_gateway'):
+ if ip_address(net_dict['ipv4_gateway']) not in ip_network(net_dict['ipv4_prefix']):
+ raise ConfigError(f'IPv4 gateway "{net_dict["ipv4_gateway"]}" is not in the IPv4 prefix "{net_dict["ipv4_prefix"]}"!')
+ if net_dict.get('ipv6_prefix') and net_dict.get('ipv6_gateway'):
+ if ip_address(net_dict['ipv6_gateway']) not in ip_network(net_dict['ipv6_prefix']):
+ raise ConfigError(f'IPv6 gateway "{net_dict["ipv6_gateway"]}" is not in the IPv6 prefix "{net_dict["ipv6_prefix"]}"!')
+ if net_dict.get('ipv4_gateway') and not net_dict.get('ipv4_prefix'):
+ raise ConfigError(f'IPv4 gateway configured but no IPv4 prefix defined for network "{network}"!')
+ if net_dict.get('ipv6_gateway') and not net_dict.get('ipv6_prefix'):
+ raise ConfigError(f'IPv6 gateway configured but no IPv6 prefix defined for network "{network}"!')
+
+ type_config = dict_search('type', network_config)
+ if dict_search('macvlan', type_config):
+ parent = dict_search('macvlan.parent', type_config)
+ if not parent:
+ raise ConfigError(f'MACVLAN networks must have a parent interface!')
+ if not interface_exists(parent):
+ raise ConfigError(f'MACVLAN parent interface "{parent}" does not exist!')
+ if not dict_search('macvlan.mode', type_config):
+ raise ConfigError(f'MACVLAN networks must have a mode configured!')
+ if dict_search('vrf', network_config):
+ raise ConfigError(f'MACVLAN networks do not support direct VRF assignment!')
# Verify VRF exists
verify_vrf(network_config)
@@ -304,7 +344,7 @@ def verify(container):
return None
-def generate_run_arguments(name, container_config):
+def generate_run_arguments(name, container_config, host_ident):
image = container_config['image']
cpu_quota = container_config['cpu_quota']
memory = container_config['memory']
@@ -430,6 +470,7 @@ def generate_run_arguments(name, container_config):
return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip()
ip_param = ''
+ addr_info = ''
networks = ",".join(container_config['network'])
for network in container_config['network']:
if 'address' not in container_config['network'][network]:
@@ -440,7 +481,11 @@ def generate_run_arguments(name, container_config):
else:
ip_param += f' --ip {address}'
- return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip()
+ addr_info = ''.join(container_config['network'][network]['address'])
+
+ mac_address = f'--mac-address {gen_mac(name, addr_info, host_ident)}'
+
+ return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {mac_address} {entrypoint} {image} {command} {command_arguments}'.strip()
def generate(container):
@@ -453,11 +498,22 @@ def generate(container):
if 'network' in container:
for network, network_config in container['network'].items():
+ type_config = dict_search('type', network_config)
+ if dict_search('macvlan', type_config):
+ net_interface = dict_search('macvlan.parent', type_config)
+ driver = 'macvlan'
+ mode = dict_search('macvlan.mode', type_config)
+ elif dict_search('bridge', type_config) is not None:
+ net_interface = f'pod-{network}'
+ driver = 'bridge'
+ else:
+ net_interface = f'pod-{network}'
+ driver = 'bridge'
tmp = {
'name': network,
'id': sha256(f'{network}'.encode()).hexdigest(),
- 'driver': 'bridge',
- 'network_interface': f'pod-{network}',
+ 'driver': driver,
+ 'network_interface': net_interface,
'subnets': [],
'ipv6_enabled': False,
'internal': False,
@@ -466,6 +522,7 @@ def generate(container):
'driver': 'host-local'
},
'options': {
+ **({'mode': mode} if driver == 'macvlan' else {}),
'mtu': '1500'
}
}
@@ -477,11 +534,26 @@ def generate(container):
tmp['options']['mtu'] = network_config['mtu']
for prefix in network_config['prefix']:
- net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)}
- tmp['subnets'].append(net)
+ gateway4, gateway6 = None, None
+ if dict_search('gateway', network_config):
+ for gw in network_config['gateway']:
+ if is_ipv6(gw):
+ gateway6 = gw
+ else:
+ gateway4 = gw
+
+ if is_ipv6(prefix) and not gateway6:
+ gateway6 = inc_ip(prefix, 1)
+ elif not gateway4:
+ gateway4 = inc_ip(prefix, 1)
if is_ipv6(prefix):
tmp['ipv6_enabled'] = True
+ net = {'subnet': prefix, 'gateway': gateway6}
+ else:
+ net = {'subnet': prefix, 'gateway': gateway4}
+
+ tmp['subnets'].append(net)
write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2))
@@ -490,12 +562,13 @@ def generate(container):
render(config_storage, 'container/storage.conf.j2', container)
if 'name' in container:
+ host_ident = get_host_identity()
for name, container_config in container['name'].items():
if 'disable' in container_config:
continue
file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service')
- run_args = generate_run_arguments(name, container_config)
+ run_args = generate_run_arguments(name, container_config, host_ident)
render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, },
formater=lambda _: _.replace(""", '"').replace("'", "'"))
@@ -553,14 +626,16 @@ def apply(container):
# the network interface in advance
if 'network' in container:
for network, network_config in container['network'].items():
- network_name = f'pod-{network}'
- # T5147: Networks are started only as soon as there is a consumer.
- # If only a network is created in the first place, no need to assign
- # it to a VRF as there's no consumer, yet.
- if interface_exists(network_name):
- tmp = Interface(network_name)
- tmp.set_vrf(network_config.get('vrf', ''))
- tmp.add_ipv6_eui64_address('fe80::/64')
+ type_config = dict_search('type', network_config)
+ if not dict_search('macvlan', type_config):
+ network_name = f'pod-{network}'
+ # T5147: Networks are started only as soon as there is a consumer.
+ # If only a network is created in the first place, no need to assign
+ # it to a VRF as there's no consumer, yet.
+ if interface_exists(network_name):
+ tmp = Interface(network_name)
+ tmp.set_vrf(network_config.get('vrf', ''))
+ tmp.add_ipv6_eui64_address('fe80::/64')
return None