Skip to content

Commit 636f74e

Browse files
authored
Merge pull request #5352 from c-po/container-veth
container: T7736: give container veths a deterministic host_interface_name
2 parents 02ab781 + 1373026 commit 636f74e

6 files changed

Lines changed: 158 additions & 7 deletions

File tree

debian/control

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,9 @@ Depends:
332332
kbd,
333333
# End "system option keyboard-layout"
334334
# For "container"
335-
podman (>=4.9.5),
336-
netavark,
337-
aardvark-dns,
335+
podman (>=5.8),
336+
netavark (>=1.14.0),
337+
aardvark-dns (>=1.14.0),
338338
# iptables is only used for containers now, not the the firewall CLI
339339
iptables,
340340
# End container

op-mode-definitions/container.xml.in

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@
133133
</node>
134134
</children>
135135
</node>
136+
<node name="interface">
137+
<properties>
138+
<help>Show host-side network interface used by each container</help>
139+
</properties>
140+
<!-- no admin check -->
141+
<command>${vyos_op_scripts_dir}/container.py show_interface</command>
142+
<children>
143+
<node name="json">
144+
<properties>
145+
<help>Show host-side network interface used by each container in JSON format</help>
146+
</properties>
147+
<!-- no admin check -->
148+
<command>${vyos_op_scripts_dir}/container.py show_interface --raw</command>
149+
</node>
150+
</children>
151+
</node>
136152
<tagNode name="log">
137153
<properties>
138154
<help>Show logs from a given container</help>

python/vyos/container.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,27 @@
1212
# You should have received a copy of the GNU General Public License
1313
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1414

15+
from hashlib import sha256
16+
1517
from vyos.config import Config
1618
from vyos.ifconfig import Interface
1719
from vyos.utils.dict import dict_search
1820
from vyos.utils.network import interface_exists
1921

22+
def get_container_host_ifname(name: str) -> str:
23+
"""
24+
Deterministic host-side veth interface name for a container's network
25+
attachment (verify() only allows one network per container). Kept within
26+
IFNAMSIZ and - thanks to the leading "veth-" (a hyphen can never appear in
27+
a VyOS "vethN" interface name) - guaranteed to never collide with the
28+
"virtual-ethernet" naming scheme..
29+
"""
30+
prefix = f'veth-{name}'
31+
if len(prefix) <= 15:
32+
return prefix
33+
digest = sha256(name.encode()).hexdigest()[:4]
34+
return f'veth-{name[:5]}-{digest}'
35+
2036
def restart_network(config: Config) -> None:
2137
"""
2238
Start network and assign it to given VRF if requested.

smoketest/scripts/cli/test_container.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,58 @@ def test_user_defined_mac(self):
264264
n = cmd_to_json(['container', 'inspect', 'test2'])
265265
self.assertEqual(n['NetworkSettings']['Networks']['bridge1']['MacAddress'], '02:00:00:00:00:02')
266266

267+
def test_long_name_host_interface_uniqueness(self):
268+
# T7736: the deterministic host-side veth interface name derived
269+
# from a container name is truncated to fit IFNAMSIZ. Two distinct
270+
# but similarly-prefixed long names must not truncate to the same
271+
# interface name - Podman would then refuse to attach the second
272+
# container's network, and its systemd unit would fail to start.
273+
net_name = 'longiftest'
274+
prefix = '192.0.2.0/24'
275+
name_1 = 'abcdefghij-1'
276+
name_2 = 'abcdefghij-2'
277+
278+
self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
279+
self.cli_set(base_path + ['name', name_1, 'image', busybox_image])
280+
self.cli_set(base_path + ['name', name_1, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
281+
self.cli_set(base_path + ['name', name_2, 'image', busybox_image])
282+
self.cli_set(base_path + ['name', name_2, 'network', net_name, 'address', str(ip_interface(prefix).ip + 3)])
283+
self.cli_commit()
284+
285+
# Both containers run a "conmon" process at once, so checking by
286+
# process name alone can't distinguish which container it belongs
287+
# to - verify each container's own recorded PID is still alive
288+
for name in (name_1, name_2):
289+
pid = 0
290+
with open(PROCESS_PIDFILE.format(name)) as f:
291+
pid = int(f.read())
292+
self.assertTrue(os.path.exists(f'/proc/{pid}'))
293+
294+
def test_colliding_host_interface_names(self):
295+
# T7736: the host-side veth name is "veth-<name[:5]>-<hash[:4]>" for
296+
# long container names - two distinct names can still (rarely) hash
297+
# to the same result. These two are a confirmed real collision
298+
# (both produce "veth-aaaa9-4ded") - verify() must reject the
299+
# commit with a clear error instead of leaving it to Podman to fail
300+
# obscurely when the second container's network attachment clashes
301+
# with the first's interface name.
302+
net_name = 'collidetest'
303+
prefix = '192.0.2.0/24'
304+
name_1 = 'aaaa9000005'
305+
name_2 = 'aaaa9000336'
306+
307+
self.cli_set(base_path + ['network', net_name, 'prefix', prefix])
308+
self.cli_set(base_path + ['name', name_1, 'image', busybox_image])
309+
self.cli_set(base_path + ['name', name_1, 'network', net_name, 'address', str(ip_interface(prefix).ip + 2)])
310+
self.cli_set(base_path + ['name', name_2, 'image', busybox_image])
311+
self.cli_set(base_path + ['name', name_2, 'network', net_name, 'address', str(ip_interface(prefix).ip + 3)])
312+
313+
with self.assertRaises(ConfigSessionError):
314+
self.cli_commit()
315+
316+
self.cli_delete(base_path + ['name', name_2])
317+
self.cli_commit()
318+
267319
def test_ipv4_network(self):
268320
prefix = '192.0.2.0/24'
269321
base_name = 'ipv4'

src/conf_mode/container.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from vyos.configdict import node_changed
3030
from vyos.configdict import is_node_changed
3131
from vyos.configverify import verify_vrf
32+
from vyos.container import get_container_host_ifname
3233
from vyos.container import restart_network
3334
from vyos.utils.configfs import delete_cli_node
3435
from vyos.utils.configfs import add_cli_node
@@ -122,6 +123,7 @@ def verify(container):
122123
net_dict = {}
123124
net_dict['mac'] = {}
124125
net_dict['address'] = {}
126+
net_dict['host_ifname'] = {}
125127

126128
for name, container_config in container['name'].items():
127129
# Container image is a mandatory option
@@ -158,6 +160,19 @@ def verify(container):
158160
if network_name not in container.get('network', {}):
159161
raise ConfigError(f'Container network "{network_name}" does not exist!')
160162

163+
# T7736: two distinct (long) container names could truncate to
164+
# the same host_interface_name - not applicable to macvlan networks,
165+
# they attach without a paired host veth
166+
network_type = dict_search(f'{network_name}.type', container['network'])
167+
if dict_search('macvlan', network_type) is None:
168+
host_ifname = get_container_host_ifname(name)
169+
if host_ifname in net_dict['host_ifname']:
170+
raise ConfigError(
171+
f'Container "{name}" and "{net_dict["host_ifname"][host_ifname]}" '
172+
f'both generate the host interface name "{host_ifname}" - please '
173+
f'use less similar container names!')
174+
net_dict['host_ifname'][host_ifname] = name
175+
161176
if 'name_server' in container_config and 'no_name_server' not in container['network'][network_name]:
162177
raise ConfigError(f'Setting name server has no effect when attached container network has DNS enabled!')
163178

@@ -361,7 +376,7 @@ def verify(container):
361376
return None
362377

363378

364-
def generate_run_arguments(name, container_config, host_ident):
379+
def generate_run_arguments(name, container_config, host_ident, network_config):
365380
image = container_config['image']
366381
cpu_quota = container_config['cpu_quota']
367382
memory = container_config['memory']
@@ -511,9 +526,19 @@ def generate_run_arguments(name, container_config, host_ident):
511526
else:
512527
ip_param = ''
513528
addr_info = ''
514-
networks = ",".join(container_config['network'])
529+
network_opts = []
515530
for network in container_config['network']:
516531
network_name = network
532+
# T7736: give the host-side veth a name that can never collide
533+
# with a VyOS "virtual-ethernet vethN" interface.
534+
type_config = dict_search(f'{network}.type', network_config)
535+
is_macvlan = dict_search('macvlan', type_config) is not None
536+
net_opt = network
537+
if not is_macvlan:
538+
ifname = get_container_host_ifname(name)
539+
net_opt += f':host_interface_name={ifname}'
540+
network_opts.append(net_opt)
541+
517542
if 'address' not in container_config['network'][network]:
518543
continue
519544
for address in container_config['network'][network]['address']:
@@ -524,6 +549,8 @@ def generate_run_arguments(name, container_config, host_ident):
524549

525550
addr_info = ''.join(container_config['network'][network]['address'])
526551

552+
networks = ' '.join(f'--network {opt}' for opt in network_opts)
553+
527554
get_mac = dict_search(f'network.{network_name}.mac', container_config)
528555
if get_mac == 'auto' or get_mac is None:
529556
mac_add = gen_mac(name, addr_info, host_ident)
@@ -546,7 +573,7 @@ def generate_run_arguments(name, container_config, host_ident):
546573
delete_cli_node(mac_config_path)
547574
add_cli_node(mac_config_path, value=mac_add)
548575

549-
net = f'--net {networks} {ip_param} {mac_address}'
576+
net = f'{networks} {ip_param} {mac_address}'
550577

551578
return f'{container_base_cmd} {healthcheck} {net} {entrypoint} {image} {command} {command_arguments}'.strip()
552579

@@ -626,12 +653,13 @@ def generate(container):
626653

627654
if 'name' in container:
628655
host_ident = get_host_identity()
656+
network_config = container.get('network', {})
629657
for name, container_config in container['name'].items():
630658
if 'disable' in container_config:
631659
continue
632660

633661
file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service')
634-
run_args = generate_run_arguments(name, container_config, host_ident)
662+
run_args = generate_run_arguments(name, container_config, host_ident, network_config)
635663
render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, },
636664
formatter=lambda _: _.replace("&quot;", '"').replace("&apos;", "'"))
637665

src/op_mode/container.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import subprocess
2222

2323
from pathlib import Path
24+
from tabulate import tabulate
25+
2426
from vyos.defaults import directories
2527
from vyos.utils.process import cmdl
2628
from vyos.utils.process import rc_cmd
@@ -163,6 +165,43 @@ def show_network(raw: bool):
163165
else:
164166
return cmdl(command.split())
165167

168+
def show_interface(raw: bool):
169+
""" Show the deterministic host-side veth interface name (T7736) VyOS
170+
assigns to each configured container's network attachment """
171+
from vyos.configquery import ConfigTreeQuery
172+
from vyos.container import get_container_host_ifname
173+
from vyos.utils.dict import dict_search
174+
175+
conf = ConfigTreeQuery()
176+
container = conf.get_config_dict(['container'], key_mangling=('-', '_'),
177+
no_tag_node_value_mangle=True,
178+
get_first_key=True,
179+
with_recursive_defaults=True)
180+
181+
data = []
182+
for name, container_config in container.get('name', {}).items():
183+
if 'allow_host_networks' in container_config:
184+
data.append({'name': name, 'network': None, 'interface': None})
185+
continue
186+
if 'network' not in container_config:
187+
continue
188+
189+
network_name = list(container_config['network'])[0]
190+
network_type = dict_search(f'network.{network_name}.type', container)
191+
is_macvlan = dict_search('macvlan', network_type) is not None
192+
interface = None if is_macvlan else get_container_host_ifname(name)
193+
data.append({'name': name, 'network': network_name, 'interface': interface})
194+
195+
if raw:
196+
return data
197+
198+
if not data:
199+
return 'No containers configured!'
200+
201+
headers = ['Container', 'Network', 'Host Interface']
202+
rows = [[d['name'], d['network'] or 'host', d['interface'] or 'n/a'] for d in data]
203+
return tabulate(rows, headers)
204+
166205
def restart(name: str):
167206
from vyos.utils.process import rc_cmd
168207
from vyos.config import Config

0 commit comments

Comments
 (0)