diff --git a/debian/control b/debian/control
index 72f7f0e8cdb..da93db44add 100644
--- a/debian/control
+++ b/debian/control
@@ -332,9 +332,9 @@ Depends:
kbd,
# End "system option keyboard-layout"
# For "container"
- podman (>=4.9.5),
- netavark,
- aardvark-dns,
+ podman (>=5.8),
+ netavark (>=1.14.0),
+ aardvark-dns (>=1.14.0),
# iptables is only used for containers now, not the the firewall CLI
iptables,
# End container
diff --git a/op-mode-definitions/container.xml.in b/op-mode-definitions/container.xml.in
index 3727864249b..0d3501ff877 100644
--- a/op-mode-definitions/container.xml.in
+++ b/op-mode-definitions/container.xml.in
@@ -133,6 +133,22 @@
+
+
+ Show host-side network interface used by each container
+
+
+ ${vyos_op_scripts_dir}/container.py show_interface
+
+
+
+ Show host-side network interface used by each container in JSON format
+
+
+ ${vyos_op_scripts_dir}/container.py show_interface --raw
+
+
+
Show logs from a given container
diff --git a/python/vyos/container.py b/python/vyos/container.py
index 475d796f2fc..b2dd306c47c 100644
--- a/python/vyos/container.py
+++ b/python/vyos/container.py
@@ -12,11 +12,27 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+from hashlib import sha256
+
from vyos.config import Config
from vyos.ifconfig import Interface
from vyos.utils.dict import dict_search
from vyos.utils.network import interface_exists
+def get_container_host_ifname(name: str) -> str:
+ """
+ Deterministic host-side veth interface name for a container's network
+ attachment (verify() only allows one network per container). Kept within
+ IFNAMSIZ and - thanks to the leading "veth-" (a hyphen can never appear in
+ a VyOS "vethN" interface name) - guaranteed to never collide with the
+ "virtual-ethernet" naming scheme..
+ """
+ prefix = f'veth-{name}'
+ if len(prefix) <= 15:
+ return prefix
+ digest = sha256(name.encode()).hexdigest()[:4]
+ return f'veth-{name[:5]}-{digest}'
+
def restart_network(config: Config) -> None:
"""
Start network and assign it to given VRF if requested.
diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py
index 16feb127753..fc9a4d11a26 100755
--- a/smoketest/scripts/cli/test_container.py
+++ b/smoketest/scripts/cli/test_container.py
@@ -264,6 +264,58 @@ def test_user_defined_mac(self):
n = cmd_to_json(['container', 'inspect', 'test2'])
self.assertEqual(n['NetworkSettings']['Networks']['bridge1']['MacAddress'], '02:00:00:00:00:02')
+ def test_long_name_host_interface_uniqueness(self):
+ # T7736: the deterministic host-side veth interface name derived
+ # from a container name is truncated to fit IFNAMSIZ. Two distinct
+ # but similarly-prefixed long names must not truncate to the same
+ # interface name - Podman would then refuse to attach the second
+ # container's network, and its systemd unit would fail to start.
+ net_name = 'longiftest'
+ prefix = '192.0.2.0/24'
+ name_1 = 'abcdefghij-1'
+ name_2 = 'abcdefghij-2'
+
+ self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
+ self.cli_set(base_path + ['name', name_1, 'image', busybox_image])
+ self.cli_set(base_path + ['name', name_1, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
+ self.cli_set(base_path + ['name', name_2, 'image', busybox_image])
+ self.cli_set(base_path + ['name', name_2, 'network', net_name, 'address', str(ip_interface(prefix).ip + 3)])
+ self.cli_commit()
+
+ # Both containers run a "conmon" process at once, so checking by
+ # process name alone can't distinguish which container it belongs
+ # to - verify each container's own recorded PID is still alive
+ for name in (name_1, name_2):
+ pid = 0
+ with open(PROCESS_PIDFILE.format(name)) as f:
+ pid = int(f.read())
+ self.assertTrue(os.path.exists(f'/proc/{pid}'))
+
+ def test_colliding_host_interface_names(self):
+ # T7736: the host-side veth name is "veth--" for
+ # long container names - two distinct names can still (rarely) hash
+ # to the same result. These two are a confirmed real collision
+ # (both produce "veth-aaaa9-4ded") - verify() must reject the
+ # commit with a clear error instead of leaving it to Podman to fail
+ # obscurely when the second container's network attachment clashes
+ # with the first's interface name.
+ net_name = 'collidetest'
+ prefix = '192.0.2.0/24'
+ name_1 = 'aaaa9000005'
+ name_2 = 'aaaa9000336'
+
+ self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
+ self.cli_set(base_path + ['name', name_1, 'image', busybox_image])
+ self.cli_set(base_path + ['name', name_1, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
+ self.cli_set(base_path + ['name', name_2, 'image', busybox_image])
+ self.cli_set(base_path + ['name', name_2, 'network', net_name, 'address', str(ip_interface(prefix).ip + 3)])
+
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_delete(base_path + ['name', name_2])
+ self.cli_commit()
+
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 91f44d2d853..15bec36ec44 100755
--- a/src/conf_mode/container.py
+++ b/src/conf_mode/container.py
@@ -29,6 +29,7 @@
from vyos.configdict import node_changed
from vyos.configdict import is_node_changed
from vyos.configverify import verify_vrf
+from vyos.container import get_container_host_ifname
from vyos.container import restart_network
from vyos.utils.configfs import delete_cli_node
from vyos.utils.configfs import add_cli_node
@@ -122,6 +123,7 @@ def verify(container):
net_dict = {}
net_dict['mac'] = {}
net_dict['address'] = {}
+ net_dict['host_ifname'] = {}
for name, container_config in container['name'].items():
# Container image is a mandatory option
@@ -158,6 +160,19 @@ def verify(container):
if network_name not in container.get('network', {}):
raise ConfigError(f'Container network "{network_name}" does not exist!')
+ # T7736: two distinct (long) container names could truncate to
+ # the same host_interface_name - not applicable to macvlan networks,
+ # they attach without a paired host veth
+ network_type = dict_search(f'{network_name}.type', container['network'])
+ if dict_search('macvlan', network_type) is None:
+ host_ifname = get_container_host_ifname(name)
+ if host_ifname in net_dict['host_ifname']:
+ raise ConfigError(
+ f'Container "{name}" and "{net_dict["host_ifname"][host_ifname]}" '
+ f'both generate the host interface name "{host_ifname}" - please '
+ f'use less similar container names!')
+ net_dict['host_ifname'][host_ifname] = name
+
if 'name_server' in container_config and 'no_name_server' not in container['network'][network_name]:
raise ConfigError(f'Setting name server has no effect when attached container network has DNS enabled!')
@@ -361,7 +376,7 @@ def verify(container):
return None
-def generate_run_arguments(name, container_config, host_ident):
+def generate_run_arguments(name, container_config, host_ident, network_config):
image = container_config['image']
cpu_quota = container_config['cpu_quota']
memory = container_config['memory']
@@ -511,9 +526,19 @@ def generate_run_arguments(name, container_config, host_ident):
else:
ip_param = ''
addr_info = ''
- networks = ",".join(container_config['network'])
+ network_opts = []
for network in container_config['network']:
network_name = network
+ # T7736: give the host-side veth a name that can never collide
+ # with a VyOS "virtual-ethernet vethN" interface.
+ type_config = dict_search(f'{network}.type', network_config)
+ is_macvlan = dict_search('macvlan', type_config) is not None
+ net_opt = network
+ if not is_macvlan:
+ ifname = get_container_host_ifname(name)
+ net_opt += f':host_interface_name={ifname}'
+ network_opts.append(net_opt)
+
if 'address' not in container_config['network'][network]:
continue
for address in container_config['network'][network]['address']:
@@ -524,6 +549,8 @@ def generate_run_arguments(name, container_config, host_ident):
addr_info = ''.join(container_config['network'][network]['address'])
+ networks = ' '.join(f'--network {opt}' for opt in network_opts)
+
get_mac = dict_search(f'network.{network_name}.mac', container_config)
if get_mac == 'auto' or get_mac is None:
mac_add = gen_mac(name, addr_info, host_ident)
@@ -546,7 +573,7 @@ def generate_run_arguments(name, container_config, host_ident):
delete_cli_node(mac_config_path)
add_cli_node(mac_config_path, value=mac_add)
- net = f'--net {networks} {ip_param} {mac_address}'
+ net = f'{networks} {ip_param} {mac_address}'
return f'{container_base_cmd} {healthcheck} {net} {entrypoint} {image} {command} {command_arguments}'.strip()
@@ -626,12 +653,13 @@ def generate(container):
if 'name' in container:
host_ident = get_host_identity()
+ network_config = container.get('network', {})
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, host_ident)
+ run_args = generate_run_arguments(name, container_config, host_ident, network_config)
render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, },
formatter=lambda _: _.replace(""", '"').replace("'", "'"))
diff --git a/src/op_mode/container.py b/src/op_mode/container.py
index 0948a0d84cd..6b733e8d6a2 100755
--- a/src/op_mode/container.py
+++ b/src/op_mode/container.py
@@ -21,6 +21,8 @@
import subprocess
from pathlib import Path
+from tabulate import tabulate
+
from vyos.defaults import directories
from vyos.utils.process import cmdl
from vyos.utils.process import rc_cmd
@@ -163,6 +165,43 @@ def show_network(raw: bool):
else:
return cmdl(command.split())
+def show_interface(raw: bool):
+ """ Show the deterministic host-side veth interface name (T7736) VyOS
+ assigns to each configured container's network attachment """
+ from vyos.configquery import ConfigTreeQuery
+ from vyos.container import get_container_host_ifname
+ from vyos.utils.dict import dict_search
+
+ conf = ConfigTreeQuery()
+ container = conf.get_config_dict(['container'], key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ data = []
+ for name, container_config in container.get('name', {}).items():
+ if 'allow_host_networks' in container_config:
+ data.append({'name': name, 'network': None, 'interface': None})
+ continue
+ if 'network' not in container_config:
+ continue
+
+ network_name = list(container_config['network'])[0]
+ network_type = dict_search(f'network.{network_name}.type', container)
+ is_macvlan = dict_search('macvlan', network_type) is not None
+ interface = None if is_macvlan else get_container_host_ifname(name)
+ data.append({'name': name, 'network': network_name, 'interface': interface})
+
+ if raw:
+ return data
+
+ if not data:
+ return 'No containers configured!'
+
+ headers = ['Container', 'Network', 'Host Interface']
+ rows = [[d['name'], d['network'] or 'host', d['interface'] or 'n/a'] for d in data]
+ return tabulate(rows, headers)
+
def restart(name: str):
from vyos.utils.process import rc_cmd
from vyos.config import Config