From eecd0d8c7605fac14d7315f7f902a06881353f6b Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Wed, 24 Sep 2025 19:20:28 -0400 Subject: [PATCH 1/7] zerotier: T6455: Add ZeroTier support --- data/templates/zerotier/devicemap.j2 | 3 + data/templates/zerotier/local.conf.j2 | 167 +++++ data/templates/zerotier/systemd-unit.j2 | 14 + debian/control | 3 + .../interfaces_zerotier.xml.in | 625 ++++++++++++++++++ .../show-interfaces-zerotier.xml.in | 296 +++++++++ python/vyos/ifconfig/__init__.py | 1 + python/vyos/ifconfig/section.py | 4 + python/vyos/ifconfig/zerotier.py | 28 + python/vyos/template.py | 46 ++ smoketest/scripts/cli/test_zerotier.py | 297 +++++++++ src/completion/list_zt_bonds.sh | 7 + src/conf_mode/interfaces_bridge.py | 3 + src/conf_mode/interfaces_zerotier.py | 350 ++++++++++ src/helpers/strip-private.py | 11 +- src/op_mode/zerotier.py | 275 ++++++++ src/validators/ip-port | 61 ++ 17 files changed, 2190 insertions(+), 1 deletion(-) create mode 100644 data/templates/zerotier/devicemap.j2 create mode 100644 data/templates/zerotier/local.conf.j2 create mode 100644 data/templates/zerotier/systemd-unit.j2 create mode 100644 interface-definitions/interfaces_zerotier.xml.in create mode 100644 op-mode-definitions/show-interfaces-zerotier.xml.in create mode 100644 python/vyos/ifconfig/zerotier.py create mode 100644 smoketest/scripts/cli/test_zerotier.py create mode 100644 src/completion/list_zt_bonds.sh create mode 100644 src/conf_mode/interfaces_zerotier.py create mode 100644 src/op_mode/zerotier.py create mode 100644 src/validators/ip-port diff --git a/data/templates/zerotier/devicemap.j2 b/data/templates/zerotier/devicemap.j2 new file mode 100644 index 0000000000..b3a5079291 --- /dev/null +++ b/data/templates/zerotier/devicemap.j2 @@ -0,0 +1,3 @@ +{% for net in network_id %} +{{ net }}={{ interface }}.{{ net[:5] }} +{% endfor %} diff --git a/data/templates/zerotier/local.conf.j2 b/data/templates/zerotier/local.conf.j2 new file mode 100644 index 0000000000..fbdc93c3d6 --- /dev/null +++ b/data/templates/zerotier/local.conf.j2 @@ -0,0 +1,167 @@ +{ + "physical": { +{% if network_config is vyos_defined %} +{% for network, network_conf in network_config.items() %} + "{{ network }}": { +{% if network_conf.mtu is vyos_defined %} + "mtu": {{ network_conf.mtu }}, +{% endif %} +{% if network_conf.blacklist is vyos_defined %} + "blacklist": {{ 'true' if network_conf.blacklist is vyos_defined }}, +{% endif %} + }, +{% endfor %} +{% endif %} + }, + "virtual": { +{% if peer_config is vyos_defined %} +{% for peer, peer_conf in peer_config.items() %} + "{{ peer }}": { +{% if peer_conf.try is vyos_defined %} + "try": {{ peer_conf.try | tojson }}, +{% endif %} +{% if peer_conf.blacklist is vyos_defined %} + "blacklist": {{ peer_conf.blacklist | tojson }}, +{% endif %} + }, +{% endfor %} +{% endif %} + }, + "settings": { +{% if allow_mgmt_from is vyos_defined %} + "allowManagementFrom": {{ allow_mgmt_from | tojson }}, +{% endif %} + +{% if bonding_policy is vyos_defined %} + "defaultBondingPolicy": {{ bonding_policy | tojson }}, +{% endif %} + +{% if custom_policy is vyos_defined %} + "policies": { +{% for policy, policy_config in custom_policy.items() %} + "{{ policy }}": { +{% if policy_config.base_policy is vyos_defined %} + "basePolicy": {{ policy_config.base_policy | tojson }}, +{% endif %} +{% if policy_config.failover_interval is vyos_defined %} + "failoverInterval": {{ policy_config.failover_interval }}, +{% endif %} +{% if policy_config.down_delay is vyos_defined %} + "downDelay": {{ policy_config.down_delay }}, +{% endif %} +{% if policy_config.up_delay is vyos_defined %} + "upDelay": {{ policy_config.up_delay }}, +{% endif %} +{% if policy_config.link_select_method is vyos_defined %} + "linkSelectMethod": {{ policy_config.link_select_method | tojson }}, +{% endif %} +{% if policy_config.links is vyos_defined %} + "links": { +{% for link, link_config in policy_config.links.items() %} + "{{ link }}": { +{% if link_config.mode is vyos_defined %} + "mode": {{ link_config.mode | tojson }}, +{% endif %} +{% if link_config.capacity is vyos_defined %} + "capacity": {{ link_config.capacity }}, +{% endif %} +{% if link_config.ip_pref is vyos_defined %} + "ipvPref": {{ link_config.ip_pref }}, +{% endif %} +{% if link_config.failover_to is vyos_defined %} + "failoverTo": {{ link_config.failover_to | tojson }}, +{% endif %} + }, +{% endfor %} + }, +{% endif %} +{% if policy_config.link_quality is vyos_defined %} + "linkQuality": { +{% if policy_config.link_quality.latency_weight is vyos_defined %} + "lat_weight": {{ (policy_config.link_quality.latency_weight | float) }}, +{% endif %} +{% if policy_config.link_quality.variance_weight is vyos_defined %} + "pdv_weight": {{ (policy_config.link_quality.variance_weight | float) }}, +{% endif %} +{% if policy_config.link_quality.max_latency is vyos_defined %} + "lat_max": {{ (policy_config.link_quality.max_latency | float) }}, +{% endif %} +{% if policy_config.link_quality.max_variance is vyos_defined %} + "pdv_max": {{ (policy_config.link_quality.max_variance | float) }}, +{% endif %} + }, +{% endif %} + }, +{% endfor %} + }, +{% endif %} + +{% if disable_port_mapping is vyos_defined %} + "portMappingEnabled": {{ 'false' if disable_port_mapping is vyos_defined }}, +{% endif %} + +{% if disable_secondary_port is vyos_defined %} + "allowSecondaryPort": {{ 'false' if disable_secondary_port is vyos_defined }}, +{% endif %} + +{% if disable_tcp_fallback is vyos_defined %} + "allowTcpFallbackRelay": {{ 'false' if disable_tcp_fallback is vyos_defined }}, +{% endif %} + +{% if force_tcp_relay is vyos_defined %} + "forceTcpRelay": {{ 'true' if force_tcp_relay is vyos_defined }}, +{% endif %} + +{% if interface_blacklist is vyos_defined %} + "interfacePrefixBlacklist": {{ interface_blacklist | tojson }}, +{% endif %} + +{% if listen_address is vyos_defined %} + "bind": {{ listen_address | tojson }}, +{% endif %} + +{% if low_bandwidth_mode is vyos_defined %} + "lowBandwidthMode": {{ 'true' if low_bandwidth_mode is vyos_defined }}, +{% endif %} + +{% if multicore_options.enabled is vyos_defined %} + "multicoreEnabled": {{ 'true' if multicore_options.enabled is vyos_defined }}, +{% endif %} + +{% if multicore_options.core_count is vyos_defined %} + "concurrency": {{ multicore_options.core_count }}, +{% endif %} + +{% if multicore_options.cpu_pinning is vyos_defined %} + "cpuPinningEnabled": {{ 'true' if multicore_options.cpu_pinning is vyos_defined }}, +{% endif %} + +{% if multipath_mode is vyos_defined %} + "multipathMode": {{ multipath_mode }}, +{% endif %} + +{% if peer_specific_bonds is vyos_defined %} + "peerSpecificBonds": { +{% for peer, peer_specific_conf in peer_specific_bonds.items() %} + "{{ peer }}": {{ peer_specific_conf.bonding_policy | tojson }}, +{% endfor %} + }, +{% endif %} + +{% if tcp_relay is vyos_defined %} + "tcpFallbackRelay": {{ tcp_relay | tojson }}, +{% endif %} + +{% if primary.port is vyos_defined %} + "primaryPort": {{ primary.port }}, +{% endif %} + +{% if secondary.port is vyos_defined %} + "secondaryPort": {{ secondary.port }}, +{% endif %} + +{% if tertiary.port is vyos_defined %} + "tertiaryPort": {{ tertiary.port }}, +{% endif %} + } +} diff --git a/data/templates/zerotier/systemd-unit.j2 b/data/templates/zerotier/systemd-unit.j2 new file mode 100644 index 0000000000..c5749a8ded --- /dev/null +++ b/data/templates/zerotier/systemd-unit.j2 @@ -0,0 +1,14 @@ +[Unit] +Description=ZeroTier interface {{ name }} +After=network-online.target +Wants=network-online.target + +[Service] +Type=forking +PIDFile=/config/vyos-generated-zerotier/{{ name }}/zerotier-one.pid +ExecStart=/usr/sbin/zerotier-one -d /config/vyos-generated-zerotier/{{ name }} +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/debian/control b/debian/control index c2128c8198..120397d80a 100644 --- a/debian/control +++ b/debian/control @@ -184,6 +184,9 @@ Depends: # For "interfaces sstpc" sstp-client, # End "interfaces sstpc" +# For "interfaces zerotier" + zerotier-one, +# End "interfaces zerotier" # For "protocols *" frr (>= 10.2), frr-pythontools, diff --git a/interface-definitions/interfaces_zerotier.xml.in b/interface-definitions/interfaces_zerotier.xml.in new file mode 100644 index 0000000000..ed53e33193 --- /dev/null +++ b/interface-definitions/interfaces_zerotier.xml.in @@ -0,0 +1,625 @@ + + + + + + + ZeroTier Interface + 305 + + zt.{1,8} + + ZeroTier interface must be named ztN; cannot exceed 9 characters + + ztN + Zerotier interface name + + + + #include + #include + #include + + + Allow management from specified subnets + + + + Value must be valid IPv4 network + + ipv4net + IPv4 Network + + + ipv6net + IPv6 Network + + + + + + + Bonding policy to be applied + + active-backup broadcast balance-rr balance-xor balance-aware + ${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-3} custom-policy + + + active-backup + Use only one primary link at a time and failover to another designated link + + + broadcast + Duplicate traffic across all available links at all times + + + balance-rr + Stripe packets across multiple links (not for use with TCP.) + + + balance-xor + Hash flows to specific links + + + balance-aware + Auto-balance flows across links + + + + + + ZeroTier controller API key + + u32:api key + ZeroTier controller API key + + + + + + Zerotier controller URL to use for API calls + + u32:api url + ZeroTier controller API URL + + + + + + User created ZeroTier bonding policy + + u32:policy name + ZeroTier bonding policy name + + + + + + Base bonding policy + + active-backup broadcast balance-rr balance-xor balance-aware + + + (active-backup|broadcast|balance-rr|balance-xor|balance-aware) + + Value must be a pre-defined policy (e.g. active-backup) + + active-backup + Use only one primary link at a time and failover to another designated link + + + broadcast + Duplicate traffic across all available links at all times + + + balance-rr + Stripe packets across multiple links (not for use with TCP.) + + + balance-xor + Hash flows to specific links + + + balance-aware + Auto-balance flows across links + + + + + + Time for path to become unavailable (ms) + + + + Value must be between 0-65535 + + u32:0-65535 + Time in ms + + + + + + Time for link failover after failure detection (ms) + + + + Value must be between 0-65535 + + u32:0-65535 + Time in ms + + + + + + Criteria for link failover + + + + + Importance of latency vs variance + + + + Value must be between 0.0-1.0 (leading 0 is required if < 1; e.g. 0.5) + + u32:0.0-1.0 + Latency and variance weight must equal 1 + + + + + + Max latency before failing over (ms) + + + + Value must be between 0-10000 + + u32:0-10000 + Maximum latency in milliseconds + + + + + + Max variance before failing over + + + + Value must be between 0-10000 + + u32:0-10000 + Maximum variance (similar to jitter) + + + + + + Importance of variance vs latency + + + + Value must be between 0.0-1.0 (leading 0 is required if < 1; e.g. 0.5) + + u32:0.0-1 + Latency and variance weight must equal 1 + + + + + + + + Determine when links are failed over + + always better failure optimize + + + (always|better|failure|optimize) + + Value must be always, better, failure, or optimize + + always + Primary link recovers if available + + + better + Primary link recovers if it is best + + + failure + Primary link recovers if active link fails + + + optimize + Primary like can change if better link exists + + + + + + Link specific bonding configuration + + + + + u32:interface + Interface to apply bonding configuration + + + + + + Weigh the amount of traffic sent across this link + + + + Value must be between 0-4294967295 + + u32:1-4294967295 + Arbitrary bandwidth value + + + + + + Determine which link should be used next + + + + + Interface Name + Interface to failover to + + + + + + IP version preference for paths on a link + + (0|4|6|46|64) + + Value must be between 0-1000000 + + 0 + No version preference + + + 4 + Use only IPv4 + + + 6 + Use only IPv6 + + + 46 + Prefer IPv4 over IPv6 + + + 64 + Prefer IPv6 over IPv4 + + + + + + Determine when a link should be used + + primary spare + + + (primary|spare) + + Value must be either primary or spare + + primary + Interface will be used by default + + + spare + Interface will only be used when other links fail + + + + + + + + Time for path to become available (ms) + + + + Value must be between 0-65535 + + u32:0-65535 + Time in ms + + + + + + + + Enable uPnP and NAT-PMP port mapping + + + + + + Allow secondary port for ZeroTier service + + + + + + Allow falling back to TCP Relay if UDP fails + + + + + + Force the use of a TCP Relay + + + + + + Prevent binding of ZeroTier service to interfaces + + u32:interface prefix + Interface prefix (e.g. eth,br,wg,vxlan,etc...) + + + + + + + Enable low-bandwidth-mode (limits control traffic) + + + + + + Enable multicore processing + + + + + Enable/Disable multicore processing + + + + + + Number of cores to use + + + + Value must be between 2-(max cores) + + u32:2-(max cores) + Number of cores to use + + + + + + Enable/Disable CPU pinning + + + + + + + + Multipath load-balancing mode + + (0|1|2) + + Value must be 0, 1, or 2 + + 0 + Disable multipath + + + 1 + Multipah random mode + + + 2 + Multipath proportional mode + + + + + + Network specific ZeroTier config + + + + + ipv4net + IPv4 Network + + + ipv6net + IPv6 Network + + + + + + Prevent ZeroTier service from binding to specified subnet + + + + + + Set ZeroTier MTU for specified network path + + + + MTU must be between 1000 and 9000 + + u32:1000-9000 + Maximum Transmission Unit in bytes + + + + + + + + ZeroTier Network ID to join (required) + + ^[a-f0-9]{16}$ + + Network ID must be a 16-digit hexadecimal value + + u32:16-digit hex + Zerotier network-id + + + + + + + Apply bonding policies per peer + + ^[A-Fa-f0-9]{10}$ + + Peer address must be 10-digit hexadecimal value + + u32:10-digit hex + ZeroTier peer ID + + + + + + Policy to be applied to specified peer + + active-backup broadcast balance-rr balance-xor balance-aware + ${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-5} custom-policy + + + active-backup + Use only one primary link at a time and failover to another designated link + + + broadcast + Duplicate traffic across all available links at all times + + + balance-rr + Stripe packets across multiple links (not for use with TCP.) + + + balance-xor + Hash flows to specific links + + + balance-aware + Auto-balance flows across links + + + + + + + + Peer specific ZeroTier config + + ^[a-f0-9]{10}$ + + Node address must be a 10-digit hexadecimal value + + u32:10-digit hex + ZeroTier peer node ID + + + + + + Blacklist path for specific peer + + + + + ipv4net + IPv4 Network + + + ipv6net + IPv6 Network + + + + + + + Static peer config for reachablity when upstream service is not available + + + + + u32:x.x.x.x/p + IPv4/Port of peer (e.g. 10.0.0.1/9993) + + + u32:x:x:x:x:x:x:x/p + IPv6/Port of peer (e.g. 2001:db8::1/9993) + + + + + + + + + Primary port for ZeroTier service (required) + + + #include + + + + + Secondary port for ZeroTier service + + + #include + + + + + Tertiary port for ZeroTier service + + + #include + + + + + Define the IP/Port of a TCP Relay + + + + + u32:x.x.x.x/p + IPv4/Port of TCP Relay (e.g. 10.0.0.1/443) + + + u32:x:x:x:x:x:x:x/p + IPv6/Port of TCP Relay (e.g. 2001:db8::1/443) + + + + + + + + diff --git a/op-mode-definitions/show-interfaces-zerotier.xml.in b/op-mode-definitions/show-interfaces-zerotier.xml.in new file mode 100644 index 0000000000..cf778b9e4f --- /dev/null +++ b/op-mode-definitions/show-interfaces-zerotier.xml.in @@ -0,0 +1,296 @@ + + + + + + + + + Show ZeroTier interface information + + + + + Show ZeroTier information for given interface + + interfaces zerotier + + + + + + Show ZeroTier bonding information + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command="bond list" + + + + Show ZeroTier bonding information for given interface + + + + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command="bond $6 show" + + + + Show ZeroTier bonding information for given interface in JSON format + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command="bond $6 show" --return-json + + + + + + Show ZeroTier bonding information in JSON format + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command="bond list" --return-json + + + + + + Show ZeroTier interface information + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command info + + + + Show ZeroTier interface information in JSON format + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command info --return-json + + + + + + Show joined ZeroTier networks + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command listnetworks + + + + Show joined ZeroTier networks in JSON format + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command listnetworks --return-json + + + + + + Show connected ZeroTier peers + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command peers + + + + Show connected ZeroTier peers in JSON format + + sudo ${vyos_op_scripts_dir}/zerotier.py show --interface $4 --command peers --return-json + + + + + + Show all ZeroTier peers from controller (requires API key) + + sudo ${vyos_op_scripts_dir}/zerotier.py show_peers --interface $4 + + + + Show all ZeroTier peers from controller (requires API key) + + sudo ${vyos_op_scripts_dir}/zerotier.py show_peers --interface $4 --detail + + + + + + Show connected ZeroTier peers from controller (requires API key) + + sudo ${vyos_op_scripts_dir}/zerotier.py show_peers --interface $4 --peers-detail + + + + Show connected ZeroTier peers from controller (requires API key) + + sudo ${vyos_op_scripts_dir}/zerotier.py show_peers --interface $4 --peers-detail --detail + + + + + + + + + + + + + + + + Delete ZeroTier interface config directory + + + + + Delete ZeroTier interface config directory + + + + + ${vyos_op_scripts_dir}/zerotier.py delete_config --interface $4 + + + + + + + + + + Restart ZeroTier interface + + + + + Restart ZeroTier interface + + interfaces zerotier + + + ${vyos_op_scripts_dir}/zerotier.py restart --interface $4 + + + + + + + + + + Set ZeroTier interface operational parameters + + + + + Set ZeroTier interface operational parameters + + interfaces zerotier + + + + + + Set ZeroTier network ID + + interfaces zerotier $4 network-id + + + + + + Enable/Disable ZeroTier's ability to receive default route from controller + + + + + Disable receiving default route from ZeroTier controller + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowDefault --state 0 + + + + Enable receiving default route from ZeroTier controller + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowDefault --state 1 + + + + + + Enable/Disable ZeroTier's ability to receive public IP from controller + + + + + Disable public IP on ZeroTier interface + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowGlobal --state 0 + + + + Enable public IP on ZeroTier interface + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowGlobal --state 1 + + + + + + Enable/Disable ZeroTier's ability to receive IP/routes from controller + + + + + Disable managed IP/routes on ZeroTier interface + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowManaged --state 0 + + + + Enable managed IP/routes on ZeroTier interface + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowManaged --state 1 + + + + + + Enable/Disable ZeroTier's ability to manage DNS resolution + + + + + Disable ZeroTier's ability to manage DNS resolution + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowDns --state 0 + + + + Enable ZeroTier's ability to manage DNS resolution + + sudo ${vyos_op_scripts_dir}/zerotier.py set --interface $4 --network-id $6 --allowed allowDns --state 1 + + + + + + + + + + + + + + + + Import/restore ZeroTier interface config + + + + + Archived ZeroTier config directory + + + + + ${vyos_op_scripts_dir}/zerotier.py import_config --path $4 + + + + + + diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py index 7838fa9a2a..05f7ce4970 100644 --- a/python/vyos/ifconfig/__init__.py +++ b/python/vyos/ifconfig/__init__.py @@ -39,3 +39,4 @@ from vyos.ifconfig.veth import VethIf from vyos.ifconfig.wwan import WWANIf from vyos.ifconfig.sstpc import SSTPCIf +from vyos.ifconfig.zerotier import ZeroTierIf diff --git a/python/vyos/ifconfig/section.py b/python/vyos/ifconfig/section.py index 2014947367..f93590e003 100644 --- a/python/vyos/ifconfig/section.py +++ b/python/vyos/ifconfig/section.py @@ -52,6 +52,10 @@ def _basename(cls, name, vlan, vrrp): name: name of the interface vlan: if vlan is True, do not stop at the vlan number """ + # ZeroTier interfaces special handling; interfaces follow . (e.g. zt0.a1b2c) + if name.startswith('zt'): + name = re.sub(r'\d+.*$', '', name) + if vrrp: name = re.sub(r'\d(\d|v|\.)*$', '', name) elif vlan: diff --git a/python/vyos/ifconfig/zerotier.py b/python/vyos/ifconfig/zerotier.py new file mode 100644 index 0000000000..65808f73c9 --- /dev/null +++ b/python/vyos/ifconfig/zerotier.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from vyos.ifconfig.interface import Interface + +@Interface.register +class ZeroTierIf(Interface): + iftype = 'zerotier' + definition = { + **Interface.definition, + **{ + 'section': 'zerotier', + 'prefixes': ['zt', ], + }, + } diff --git a/python/vyos/template.py b/python/vyos/template.py index 824d421361..103c848b3f 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -124,6 +124,47 @@ def register_clever_function(name, func=None): _CLEVER_FUNCTIONS[name] = func return func +def rm_json_trail_comma(json_string: str) -> str: + """ + Remove trailing commas from otherwise valid JSON text. + + This function operates line-by-line and strips a trailing comma from + any line if the next non-empty line begins with '}' or ']'. It is a + lightweight fix intended for JSON that is already structurally valid + except for illegal trailing commas (e.g. after the last element in + an object or array). + + Important: + ---------- + - The input must already be valid JSON apart from trailing commas. + - It assumes braces/brackets are on their own lines (e.g. no `,]`). + - It does not attempt full JSON validation or handle commas inside strings. + + Parameters + ---------- + json_string : str + JSON text with potential trailing commas. + + Returns + ------- + str + Cleaned JSON text with trailing commas removed, suitable for + correcting JSON created from a Jinja template. + """ + + string_to_lines = [line for line in json_string.split('\n') if line.strip()] + + for line in range(len(string_to_lines) - 1): + # Strip trailing spaces/tabs/newlines before checking + if string_to_lines[line].rstrip().endswith(','): + # If the next line (ignoring indentation) starts with } or ], + # then the comma at the end of this line is invalid → remove it. + if string_to_lines[line + 1].lstrip().startswith(('}', ']')): + string_to_lines[line] = string_to_lines[line].rstrip().rstrip(',') + + return '\n'.join(string_to_lines) + + def render_to_string(template, content, formater=None, location=None): """Render a template from the template directory, raise on any errors. @@ -155,6 +196,7 @@ def render( user=None, group=None, location=None, + rm_trail_comma=False, ): """Render a template from the template directory to a file, raise on any errors. @@ -175,6 +217,10 @@ def render( # Remove any trailing character and always add a new line at the end rendered = rendered.rstrip() + "\n" + # Remove trailing commas from otherwise valid JSON text + if rm_trail_comma: + rendered = rm_json_trail_comma(rendered) + # Write to file with open(destination, "w") as file: chmod(file.fileno(), permission) diff --git a/smoketest/scripts/cli/test_zerotier.py b/smoketest/scripts/cli/test_zerotier.py new file mode 100644 index 0000000000..2d9f17ac79 --- /dev/null +++ b/smoketest/scripts/cli/test_zerotier.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import json + +import unittest +from pathlib import Path +from typing import Any + + +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_args + +# Base config path for this feature +base_path = ['interfaces', 'zerotier'] +config_directory = Path('/config/vyos-generated-zerotier') +unit_path = Path('/run/systemd/system') + +class TestInterfacesZerotier(VyOSUnitTestSHIM.TestCase): + @classmethod + def setUpClass(cls): + super(TestInterfacesZerotier, cls).setUpClass() + cls.cli_delete(cls, base_path) + if not config_directory.exists(): + config_directory.mkdir(parents=True, exist_ok=True) + + @classmethod + def tearDownClass(cls): + cls.cli_delete(cls, base_path) + super(TestInterfacesZerotier, cls).tearDownClass() + + def tearDown(self): + self.cli_delete(base_path) + self.cli_delete(['firewall']) + self.cli_commit() + + def load_json(self, interface: str = 'zt1') -> dict[str, Any]: + """ + Load and validate a ZeroTier local.conf file for a given interface. + + Args: + interface (str, optional): ZeroTier interface name used to resolve + the config directory (default: 'zt1'). + + Returns: + dict[str, Any]: Parsed JSON contents of local.conf. + + Raises: + AssertionError: If the file contents are not valid JSON (via assertTrue). + """ + tmp_config_directory = config_directory / interface + tmp_local_conf_file = tmp_config_directory / 'local.conf' + local_conf = tmp_local_conf_file.read_text() + + try: + local_conf_output = json.loads(local_conf) + valid_json = True + except Exception: + valid_json = False + + self.assertTrue(valid_json) + + return local_conf_output + + def validate_zt(self, local_conf: dict, key: str, expected, info_path='settings', interface='zt1'): + """ + Validate a ZeroTier setting in both a parsed local.conf dictionary and + the runtime status reported by zerotier-cli. + + Args: + local_conf (dict): Parsed JSON contents of local.conf. + key (str): One or more nested keys (unpacked by dict_search_args) + representing the setting to validate. + expected: Expected value for the setting (bool, int, str, etc.). + info_path (str, optional): Base path in local.conf under which + the key resides (default: 'settings'). + interface (str, optional): ZeroTier interface name used to resolve + the config directory (default: 'zt1'). + """ + tmp_config_directory = config_directory / interface + + self.assertEqual(dict_search_args(local_conf, info_path, *key), expected) + + # Load and check zerotier-cli status + status = json.loads(cmd(f"zerotier-cli -j -D{tmp_config_directory} info")) + self.assertEqual(dict_search_args(status, 'config', info_path, *key), expected) + + + def test_basic(self): + authtoken = config_directory / 'zt1' / 'authtoken.secret' + unit_file_path = unit_path / 'vyos-zerotier-zt1.service' + network_file = config_directory / 'zt1' / 'networks.d' / '0123456789abcdef.conf' + tmp_config_directory = config_directory / 'zt1' + + self.cli_set(base_path + ['zt1', 'primary','port', '9993']) + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.assertTrue(unit_file_path.exists()) + self.assertTrue(authtoken.exists()) + self.assertTrue(network_file.exists()) + + status = json.loads(cmd(f'zerotier-cli -j -D{tmp_config_directory} info')) + self.assertNotEqual(dict_search('online', status), None) + + self.assertEqual(dict_search('config.settings.primaryPort', status), 9993) + + def test_bind(self): + tmp_config_directory = config_directory / 'zt1' + + self.cli_set(['interfaces', 'dummy', 'dum0', 'address', '192.168.1.1/24']) + self.cli_set(['interfaces', 'dummy', 'dum1', 'address', '192.168.2.1/24']) + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt1', 'listen-address', '192.168.1.1']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['bind'], ['192.168.1.1']) + + def test_custom_bonding_policy(self): + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt3', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt3', 'primary', 'port', '9995']) + + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'base-policy', 'active-backup']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'link-select-method', 'always']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'failover-interval', '1000']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'up-delay', '1000']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'down-delay', '1000']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'links', 'eth0', 'mode', 'primary']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'links', 'eth1', 'mode', 'spare']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'AB', 'links', 'eth2', 'mode', 'spare']) + + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'base-policy', 'balance-aware']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'failover-interval', '1000']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'link-quality', 'latency-weight', '0.5']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'link-quality', 'variance-weight', '0.5']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'link-quality', 'max-latency', '500']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'link-quality', 'max-variance', '20']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'links', 'eth0', 'capacity', '1000000']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'BA', 'links', 'eth1', 'capacity', '250000']) + + self.cli_set(base_path + ['zt1', 'custom-policy', 'RR', 'base-policy', 'balance-rr']) + + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['policies', 'AB', 'basePolicy'], 'active-backup') + self.validate_zt(local_conf, ['policies', 'AB', 'linkSelectMethod'], 'always') + self.validate_zt(local_conf, ['policies', 'AB', 'failoverInterval'], 1000) + self.validate_zt(local_conf, ['policies', 'AB', 'upDelay'], 1000) + self.validate_zt(local_conf, ['policies', 'AB', 'downDelay'], 1000) + self.validate_zt(local_conf, ['policies', 'AB', 'links', 'eth0', 'mode'], 'primary') + self.validate_zt(local_conf, ['policies', 'AB', 'links', 'eth1', 'mode'], 'spare') + self.validate_zt(local_conf, ['policies', 'AB', 'links', 'eth2', 'mode'], 'spare') + + self.validate_zt(local_conf, ['policies', 'BA', 'basePolicy'], 'balance-aware') + self.validate_zt(local_conf, ['policies', 'BA', 'failoverInterval'], 1000) + self.validate_zt(local_conf, ['policies', 'BA', 'linkQuality', 'lat_weight'], 0.5) + self.validate_zt(local_conf, ['policies', 'BA', 'linkQuality', 'pdv_weight'], 0.5) + self.validate_zt(local_conf, ['policies', 'BA', 'linkQuality', 'lat_max'], 500) + self.validate_zt(local_conf, ['policies', 'BA', 'linkQuality', 'pdv_max'], 20) + self.validate_zt(local_conf, ['policies', 'BA', 'links', 'eth0', 'capacity'], 1000000) + self.validate_zt(local_conf, ['policies', 'BA', 'links', 'eth1', 'capacity'], 250000) + + self.validate_zt(local_conf, ['policies', 'RR', 'basePolicy'], 'balance-rr') + + def test_custom_ports(self): + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt1', 'primary', 'port', '9995']) + self.cli_set(base_path + ['zt1', 'secondary', 'port', '9996']) + self.cli_set(base_path + ['zt1', 'tertiary', 'port', '9997']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['primaryPort'], 9995) + self.validate_zt(local_conf, ['secondaryPort'], 9996) + self.validate_zt(local_conf, ['tertiaryPort'], 9997) + + def test_generic_local_conf(self): + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + + self.cli_set(base_path + ['zt1', 'allow-mgmt-from', '192.168.1.0/24']) + self.cli_set(base_path + ['zt1', 'bonding-policy', 'active-backup']) + self.cli_set(base_path + ['zt1', 'disable-port-mapping']) + self.cli_set(base_path + ['zt1', 'disable-secondary-port']) + self.cli_set(base_path + ['zt1', 'disable-tcp-fallback']) + self.cli_set(base_path + ['zt1', 'force-tcp-relay']) + self.cli_set(base_path + ['zt1', 'low-bandwidth-mode']) + self.cli_set(base_path + ['zt1', 'multicore-options', 'enabled']) + self.cli_set(base_path + ['zt1', 'multicore-options', 'core-count','2']) + self.cli_set(base_path + ['zt1', 'multicore-options', 'cpu-pinning']) + self.cli_set(base_path + ['zt1', 'multipath-mode', '2']) + self.cli_set(base_path + ['zt1', 'tcp-relay', '192.168.0.1/443']) + + self.cli_set(base_path + ['zt1', 'network-config', '10.0.0.0/24', 'blacklist']) + self.cli_set(base_path + ['zt1', 'network-config', '10.0.0.0/24', 'mtu', '1328']) + + self.cli_set(base_path + ['zt1', 'peer-config', '0123456789', 'blacklist', '10.0.1.0/24']) + self.cli_set(base_path + ['zt1', 'peer-config', '0123456789', 'try', '10.0.3.1/9993']) + self.cli_set(base_path + ['zt1', 'peer-config', '0123456789', 'try', '10.0.3.2/9993']) + + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['allowSecondaryPort'], False) + self.validate_zt(local_conf, ['allowTcpFallbackRelay'], False) + self.validate_zt(local_conf, ['concurrency'], 2) + self.validate_zt(local_conf, ['cpuPinningEnabled'], True) + self.validate_zt(local_conf, ['forceTcpRelay'], True) + self.validate_zt(local_conf, ['lowBandwidthMode'], True) + self.validate_zt(local_conf, ['multicoreEnabled'], True) + self.validate_zt(local_conf, ['multipathMode'], 2) + self.validate_zt(local_conf, ['portMappingEnabled'], False) + self.validate_zt(local_conf, ['tcpFallbackRelay'], '192.168.0.1/443') + self.validate_zt(local_conf, ['allowManagementFrom'], ['192.168.1.0/24']) + self.validate_zt(local_conf, ['defaultBondingPolicy'], 'active-backup') + self.validate_zt(local_conf, ['10.0.0.0/24', 'blacklist'], True, info_path='physical') + self.validate_zt(local_conf, ['10.0.0.0/24', 'mtu'], 1328, info_path='physical') + self.validate_zt(local_conf, ['0123456789', 'blacklist'], ['10.0.1.0/24'], info_path='virtual') + self.validate_zt(local_conf, ['0123456789', 'try'], ['10.0.3.1/9993', '10.0.3.2/9993'], info_path='virtual') + + def test_interface_blacklist(self): + self.cli_set(['interfaces', 'ethernet', 'eth0', 'address', '192.168.1.1/24']) + self.cli_set(['interfaces', 'dummy', 'dum1', 'address', '192.168.2.1/24']) + self.cli_set(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop', '192.168.1.1']) + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt1', 'interface-blacklist', 'dum']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['interfacePrefixBlacklist'], ['dum']) + + def test_multiple_interfaces(self): + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt2', 'primary', 'port', '9994']) + self.cli_set(base_path + ['zt2', 'network-id', '123456789abcdef0']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json('zt1') + self.validate_zt(local_conf, ['primaryPort'], 9993, interface='zt1') + + local_conf = self.load_json('zt2') + self.validate_zt(local_conf, ['primaryPort'], 9994, interface='zt2') + + def test_peer_specific_bonds(self): + self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) + self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'custome_policy1', 'base-policy', 'active-backup']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'custome_policy1', 'link-select-method', 'always']) + self.cli_set(base_path + ['zt1', 'peer-specific-bonds', '0123456789', 'bonding-policy', 'balance-rr']) + self.cli_set(base_path + ['zt1', 'peer-specific-bonds', '1234567890', 'bonding-policy', 'custome_policy1']) + self.cli_commit() + + # Load and check local.conf; ensure valid JSON + local_conf = self.load_json() + + self.validate_zt(local_conf, ['peerSpecificBonds', '0123456789'], 'balance-rr') + self.validate_zt(local_conf, ['peerSpecificBonds', '1234567890'], 'custome_policy1') + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/completion/list_zt_bonds.sh b/src/completion/list_zt_bonds.sh new file mode 100644 index 0000000000..4139318f04 --- /dev/null +++ b/src/completion/list_zt_bonds.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +INTERFACE="$1" + +# Run the command +sudo zerotier-cli -j -D"/config/vyos-generated-zerotier/$INTERFACE" bond list 2>/dev/null \ + | jq -r -e '.[] | select(.isBonded == true) | .address' 2>/dev/null diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 8cb0c515ad..2a6a39c6af 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -175,6 +175,9 @@ def verify(bridge): if 'bpdu_guard' in interface_config and 'root_guard' in interface_config: raise ConfigError(error_msg + 'bpdu-guard and root-guard cannot be configured at the same time!') + if interface.startswith('zt'): + raise ConfigError(error_msg + 'ZeroTier interfaces are not supported on a bridge!') + if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py new file mode 100644 index 0000000000..de26f69e2f --- /dev/null +++ b/src/conf_mode/interfaces_zerotier.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +import time + +from pathlib import Path + +from vyos.base import Warning +from vyos.config import Config +from vyos import ConfigError +from vyos.template import render +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_search_recursive +from vyos.utils.dict import dict_set_nested +from vyos.configdict import node_changed +from vyos.utils.network import interface_exists +from vyos.configdiff import Diff +from vyos.configdiff import get_config_diff + +zerotier_config = Path('/config/vyos-generated-zerotier') +systemd_unit_path = Path('/run/systemd/system') +controller_api_key = Path('/config/vyos-zerotier/zt_controller_api_key.secret') + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['interfaces','zerotier'] + zerotier = { + 'interfaces': conf.get_config_dict( + base, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True, + ) + } + + # If the base node changed, an interface was deleted + tmp = node_changed(conf, base) + if tmp: + zerotier['interface_remove'] = tmp + + # Check if an interface config changed at all + expand_nodes = Diff.ADD | Diff.DELETE + tmp = node_changed(conf, base, recursive=True, expand_nodes=expand_nodes) + if tmp: + zerotier['interface_changed'] = tmp + + # Get all children that may have changed for an interface + diff = get_config_diff(conf, key_mangling=('-', '_')) + tmp = diff.get_child_nodes_diff(base, expand_nodes=expand_nodes, recursive=True) + diff_dict = {} + + # Restart is only required when a listening port//ip or network id is changed + for section in ("delete", "add"): + for iface, changes in tmp.get(section, {}).items(): + network_config = dict_search('network_config', changes) + peer_config = dict_search('peer_config', changes) + if network_config: + for _, search_result in dict_search_recursive(network_config, 'blacklist'): + if search_result: + diff_dict.setdefault(iface, set()).update(changes.keys()) + elif peer_config: + for _, search_result in dict_search_recursive(peer_config, 'blacklist'): + if search_result: + diff_dict.setdefault(iface, set()).update(changes.keys()) + else: + diff_dict.setdefault(iface, set()).update(changes.keys()) + + # Track which network-ids were removed. + if section == 'delete' and 'network_id' in changes: + netids = changes['network_id'] + if isinstance(netids, list): + dict_set_nested(f'networks_removed.{iface}', netids, zerotier) + else: + dict_set_nested(f'networks_removed.{iface}', [netids], zerotier) + + for interface, interface_config in zerotier['interfaces'].items(): + # If an interface is disabled, treat it as a removal. + if 'disable' in interface_config: + zerotier.setdefault('interface_remove', []).append(interface) + + # Restart is only required when a listening port//ip or network id is changed + if interface in diff_dict: + restart_keys = { + 'network_id', 'primary', 'secondary', 'tertiary', 'interface_blacklist', + 'disable_secondary_port', 'listen_address', 'network_config', 'peer_config' + } + if diff_dict[interface] & restart_keys: + zerotier.setdefault('restart_required', set()).update([interface]) + elif 'disable' in diff_dict[interface]: + pass + else: + zerotier.setdefault('no_restart_required', set()).update([interface]) + + return zerotier + + +def verify(config): + ports = {} + + for interface, interface_config in config['interfaces'].items(): + # Define ports (if configured; otherwise None) + primary_port = dict_search('primary.port', interface_config) + secondary_port = dict_search('secondary.port', interface_config) + tertiary_port = dict_search('tertiary.port', interface_config) + + # Primary port is required + if not primary_port: + raise ConfigError("Primary Port must be configured") + + # Network ID must be configured + if not dict_search('network_id', interface_config): + raise ConfigError("Network ID must be configured") + + # Check for secondary port when allow-secondary-port is false + if secondary_port and dict_search('disable_secondary_port', interface_config) is not None: + raise ConfigError("Secondary port cannot be set when disable-secondary-port is configured") + + # Multicore must be enabled when cpu-pinning or core-count is configured + multicore_enabled = dict_search('multicore_options.enabled', interface_config) + if any([dict_search('multicore_options.core_count', interface_config), + dict_search('multicore_options.cpu_pinning', interface_config) is not None]): + if multicore_enabled is None: + raise ConfigError("Multicore must be enabled when cpu-pinning or core-count is configured") + + # controller-api-key must be configured if controller-api-url is set + api_key = dict_search('controller_api_key', interface_config) + api_url = dict_search('controller_api_url', interface_config) + if api_url and not api_key: + raise ConfigError("controller-api-key must be configured if controller-api-url is set") + + # Check for duplicate ports + for port in filter(None, (primary_port, secondary_port, tertiary_port)): + if port in ports: + raise ConfigError(f"Port {port} already assigned to interface {dict_search(port, ports)}") + ports[port] = interface + + # Check if user defined bonding policy is configured + pre_defined_policies = ('active-backup', 'broadcast', 'balance-rr', 'balance-xor', 'balance-aware') + bonding_policy = dict_search('bonding_policy', interface_config, '') + custom_policies = dict_search('custom_policy', interface_config) + if bonding_policy and bonding_policy not in pre_defined_policies: + if custom_policies: + if bonding_policy not in custom_policies: + raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") + else: + raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") + + # Check if user defined bonding policy is configured + peer_specific_data = dict_search('peer_specific_bonds', interface_config, {}) + for node, node_config in peer_specific_data.items(): + peer_specific_bonding_policy = dict_search('bonding_policy', node_config) + if peer_specific_bonding_policy not in pre_defined_policies: + if custom_policies and peer_specific_bonding_policy not in custom_policies: + raise ConfigError(f"Custom bonding policy {peer_specific_bonding_policy} is not configured") + + if custom_policies: + for policy_name, policy_config in custom_policies.items(): + # policy name cannot have the name of a base policy + if policy_name in pre_defined_policies: + raise ConfigError(f"Policy name cannot be the same as a predefined policy") + + base_policy = dict_search('base_policy', policy_config) + + # Base policy must be set for custom bonding policy + if not base_policy: + raise ConfigError(f"Base policy must be set for custom bonding policy {policy_name}") + + # link-select-method is only valid for active-backup + if dict_search('link_select_method', policy_config) and "active-backup" not in base_policy: + raise ConfigError("link-select-method is only valid for active-backup bonding policy") + + links = dict_search('links', policy_config) + if links: + primary_count = 0 + for link, link_config in links.items(): + # Check if link exists + if not interface_exists(link): + Warning(f"Interface {link} does not exist") + + # capacity is only valid for balance-aware + if dict_search('capacity', link_config) and "balance-aware" not in base_policy: + raise ConfigError("capacity is only valid for balance-aware bonding policy") + + # mode has no effect for broadcast bonding policy + if dict_search('mode', link_config) and "broadcast" in base_policy: + raise ConfigError("mode is not valid for broadcast bonding policy") + + failover_to = dict_search('failover_to', link_config) + if failover_to: + # Make sure not failing over to self + if failover_to == link: + raise ConfigError("Cannot fail over to the same link") + + # Check if the interface to failover-to exists + if not interface_exists(failover_to): + Warning(f"Interface {failover_to} does not exist") + + # active-backup bonding policy may only have one primary link + if "active-backup" in base_policy: + if dict_search('mode', link_config) == 'primary': + primary_count += 1 + if primary_count > 1: + raise ConfigError("active-backup bonding policy must have only one primary link") + + link_quality = dict_search('link_quality', policy_config) + if link_quality: + # link-quality is only valid for balance-aware + if "balance-aware" not in base_policy: + raise ConfigError("link-quality is only valid for balance-aware bonding policy") + + lat_weight = dict_search('link_quality.latency_weight', policy_config) + pdv_weight = dict_search('link_quality.variance_weight', policy_config) + + # Check if latency-weight or variance-weight is set, both must be set + if any([lat_weight, pdv_weight]) and not all([lat_weight, pdv_weight]): + raise ConfigError("If latency-weight or variance-weight is set, both must be set") + + # Check if latency-weight and variance-weight add up to 1 + if float(lat_weight) + float(pdv_weight) != 1: + raise ConfigError("Latency-weight and variance-weight must equal 1") + + +def generate(config): + for interface, interface_config in config['interfaces'].items(): + # If an interface wasn't changed, don't generate anything new. + if interface not in config['interface_changed']: + continue + + network_id = dict_search('network_id', interface_config) + + # Generate systemd unit file + unit_path = systemd_unit_path / f'vyos-zerotier-{interface}.service' + if not unit_path.exists(): # <- don't create if it already exists + render(str(unit_path), 'zerotier/systemd-unit.j2', {"name": interface}) + + # Create interface directory + iface_dir = zerotier_config / interface + if not iface_dir.exists(): # <- don't create if it already exists + iface_dir.mkdir(parents=True, exist_ok=True) + + # Generate local.conf file + local_conf_path = iface_dir / 'local.conf' + render(str(local_conf_path), 'zerotier/local.conf.j2', config['interfaces'][interface], rm_trail_comma=True) # <- always create + + # Create networks.d directory if it doesn't exist + network_conf_dir = iface_dir /'networks.d' + if not network_conf_dir.exists(): # <- don't create if it already exists + network_conf_dir.mkdir(parents=True, exist_ok=True) + + # Generate network.conf file + for network in network_id: + network_conf_path = network_conf_dir / f'{network}.conf' + if not network_conf_path.exists(): + network_conf_path.touch(exist_ok=True) + + # Generate devicemap (maps network-ids to interfaces) + device_map_path = iface_dir / 'devicemap' + render(str(device_map_path), 'zerotier/devicemap.j2', {"interface": interface, "network_id": network_id}) # <- always create + + return config + + +def apply(config): + removed_interfaces = dict_search('interface_remove', config) + networks_removed = dict_search('networks_removed', config) + restart_required = dict_search('restart_required', config, []) + no_restart_required = dict_search('no_restart_required', config, []) + + # Stop and disable interfaces that were removed. + if removed_interfaces: + for interface in removed_interfaces: + unit_path = systemd_unit_path / f'vyos-zerotier-{interface}.service' + if unit_path.exists(): + call(f'systemctl --no-block --quiet stop vyos-zerotier-{interface}.service') + call(f'systemctl --no-block --quiet disable vyos-zerotier-{interface}.service') + unit_path.unlink(missing_ok=True) + + # Remove network.conf files that were removed. + if networks_removed: + for interface, networks in networks_removed.items(): + for network in networks: + network_conf_path = zerotier_config / interface / 'networks.d' / f'{network}.conf' + network_local_conf_path = zerotier_config / interface / f'{network}.local.conf' + network_conf_path.unlink(missing_ok=True) + network_local_conf_path.unlink(missing_ok=True) + + call('systemctl daemon-reload') + interfaces_changed = [] + for interface, interface_config in config['interfaces'].items(): + # If an interface was removed, this was handled above. + if removed_interfaces and interface in removed_interfaces: + continue + + # If an interface wasn't changed, don't restart it. + if interface not in config['interface_changed']: + continue + + interfaces_changed.append(interface) + + # Restart the interface if a restart is required. Enable and start + # the interface if it's a new interface or was disabled. + if restart_required and interface in restart_required: + call(f'systemctl --no-block --quiet restart vyos-zerotier-{interface}.service') + # If an interface wasn't changed, don't restart it. + elif no_restart_required and interface in no_restart_required: + continue + else: + call(f'systemctl --quiet enable vyos-zerotier-{interface}.service') + call(f'systemctl --no-block --quiet start vyos-zerotier-{interface}.service') + + # Give the interfaces time to start + timeout = 15 + interval = 1 + for interface in interfaces_changed: + end = time.monotonic() + timeout + while time.monotonic() < end: + int_exists = cmd(f'sudo ip link show') + if f'{interface}.' in int_exists: + break + time.sleep(interval) + +try: + c = get_config() + verify(c) + generate(c) + apply(c) +except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index 71b7c079a8..64e1746321 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -35,6 +35,8 @@ parser.add_argument('--asn', action='store_true', help='strip off BGP ASNs') parser.add_argument('--snmp', action='store_true', help='strip off SNMP location information') parser.add_argument('--lldp', action='store_true', help='strip off LLDP location information') +parser.add_argument('--zt_node', action='store_true', help='strip off SNMP location information') +parser.add_argument('--zt_network', action='store_true', help='strip off LLDP location information') address_preserval = parser.add_mutually_exclusive_group() address_preserval.add_argument('--address', action='store_true', help='strip off all IPv4 and IPv6 addresses') @@ -95,7 +97,7 @@ def strip_lines(rules: tuple) -> None: args = parser.parse_args() # Strict mode is the default and the absence of loose mode implies presence of strict mode. if not args.loose: - args.mac = args.domain = args.hostname = args.username = args.dhcp = args.asn = args.snmp = args.lldp = True + args.mac = args.domain = args.hostname = args.username = args.dhcp = args.asn = args.snmp = args.lldp = args.zt_node = args.zt_network = True if not args.public_address and not args.keep_address: args.address = True elif not args.address and not args.public_address: @@ -123,6 +125,8 @@ def strip_lines(rules: tuple) -> None: (True, re.compile(r'md5-key \S+'), 'md5-key xxxxxx'), # Strip WireGuard private-key (True, re.compile(r'private-key \S+'), 'private-key xxxxxx'), + # Strip ZeroTier api-key + (True, re.compile(r'controller-api-key \S+'), 'controller-api-key xxxxxx'), # Strip MAC addresses (args.mac, re.compile(r'([0-9a-fA-F]{2}\:){5}([0-9a-fA-F]{2}((\:{0,1})){3})'), r'xx:xx:xx:xx:xx:\2'), @@ -149,5 +153,10 @@ def strip_lines(rules: tuple) -> None: # Strip SNMP location (args.snmp, re.compile(r'(location) \S+'), r'\1 xxxxxx'), + + # Strip ZeroTier information + (args.zt_network, re.compile(r'([A-Fa-f0-9]{5})[A-Fa-f0-9]{11}'), r'\1' + 'x' * 11), + (args.zt_node, re.compile(r'([A-Fa-f0-9]{3})[A-Fa-f0-9]{7}'), r'\1' + 'x' * 7), + ] strip_lines(stripping_rules) diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py new file mode 100644 index 0000000000..0acc5ff962 --- /dev/null +++ b/src/op_mode/zerotier.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import requests +import sys +import typing +import shutil + +from datetime import datetime +from tabulate import tabulate +from pathlib import Path + +import vyos.opmode +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.configquery import op_mode_config_dict +from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_set_nested + +zt_config_path = Path('/config/vyos-generated-zerotier') + +def detailed_output(dataset, headers): + for data in dataset: + adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action + transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char + + print(tabulate(transformed_rule, tablefmt="presto")) + print() + + +def _get_zt_cli_data(interface: str, + command: str, + raw: bool, + return_json: bool): + command = f"zerotier-cli -D/config/vyos-generated-zerotier/{interface} {command}" + if raw or return_json: + command += " -j" + + if raw: + rc, tmp = rc_cmd(command) + if rc != 0: + raise vyos.opmode.Error(f"Command execution failed") + return json.loads(tmp) + elif return_json: + rc, tmp = rc_cmd(command) + if rc != 0: + raise vyos.opmode.Error(f"Command execution failed") + return json.loads(tmp) + else: + rc, tmp = rc_cmd(command) + if rc != 0: + raise vyos.opmode.Error(f"Command execution failed") + + return tmp + + +def zt_api(url, api_token, api_type): + # Create the headers for API calls + if api_type == "service": + headers = { + "X-ZT1-Auth": api_token + } + elif api_type == "central": + headers = { + 'Authorization': f'token {api_token}' + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() # Raises HTTPError for bad responses (4xx and 5xx) + return response + except requests.exceptions.HTTPError as http_err: + raise vyos.opmode.Error(f'HTTP error occurred: {http_err}') + except Exception as err: + raise vyos.opmode.Error(f'Other error occurred: {err}') + + +def show(raw: bool, + return_json: bool, + interface: typing.Optional[str], + command: typing.Optional[str]): + + rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') + if rc != 0: + raise vyos.opmode.Error(f"ZeroTier service is not active for interface {interface}") + + cli_data = _get_zt_cli_data(interface, command, raw, return_json); + + if raw: + return {'zerotier': cli_data} + elif return_json: + return cli_data + else: + return cli_data + + +def show_peers(raw: bool, + interface: typing.Optional[str], + peers_detail: bool, + detail: bool): + + localNodeList = [] + controllerNodeList = [] + controllerNetworkList = [] + + peer_dict = op_mode_config_dict(['interfaces', 'zerotier', interface], key_mangling=('-', '_'), get_first_key=True) + primary_port = dict_search('primary.port', peer_dict) + + # peers-all and peers-detail does API calls to ZeroTier Central and requires API key to be configured + api_token = dict_search('controller_api_key', peer_dict) + if not api_token: + raise vyos.opmode.Error("This command requires a ZeroTier Central API key to be configured") + + # Use ZeroTier Central API by default; allow for custom controller URL + controller_url = dict_search('controller_api_url', peer_dict) + if not controller_url: + controller_url = 'https://api.zerotier.com/api/v1' + + # Generate a list to filter by nodes with an active connection + if peers_detail: + # Get the api token for local API call + token_path = zt_config_path / interface / 'authtoken.secret' + if not token_path.exists(): + raise vyos.opmode.Error( + f"authtoken.secret not found! This should have been created when creating an interface. Does {interface} exist" + ) + + authtoken = token_path.read_text() + + network_data = zt_api(f'http://127.0.0.1:{primary_port}/peer', authtoken, 'service').json() + for peers in network_data: + localNodeList.append(peers['address']) + + # Get list of all networks in a ZeroTier controller + network_data = zt_api(f'{controller_url}/network', api_token, 'central').json() + for networks in network_data: + controllerNetworkList.append(networks['id']) + + raw_dict = {} + for controllerNode in controllerNetworkList: + network_data = zt_api(f'{controller_url}/network/{controllerNode}/member', api_token, 'central').json() + for member in network_data: + if peers_detail: + if localNodeList and member['nodeId'] in localNodeList: + if raw: + dict_set_nested(f'zerotier.networks.{controllerNode}.members.{member["nodeId"]}', + member , + raw_dict) + continue + + controllerNodeList.append([ + dict_search('name', member), + dict_search('nodeId', member), + dict_search('description', member), + '\n'.join(dict_search('config.ipAssignments', member)), + dict_search('networkId', member), + dict_search('physicalAddress', member), + *([datetime.fromtimestamp(dict_search('lastSeen', member)/1000).strftime("%d %b %Y %H:%M")] if detail else []), + *([dict_search('clientVersion', member)] if detail else []), + *([dict_search('config.authorized', member)] if detail else []) + ]) + else: + if raw: + dict_set_nested(f'zerotier.networks.{controllerNode}.members.{member["nodeId"]}', + member , + raw_dict) + continue + + controllerNodeList.append([ + dict_search('name', member), + dict_search('nodeId', member), + dict_search('description', member), + '\n'.join(dict_search('config.ipAssignments', member)), + dict_search('networkId', member), + dict_search('physicalAddress', member) + ]) + + if raw: + return raw_dict + + if detail: + headers = ['Name', 'NodeID', 'Description', 'ZeroTier IP', 'Network', 'Public IP', 'Last Seen', 'Version', 'Authorized'] + + sorted_list = sorted(controllerNodeList, key=lambda x: x[0].lower()) + detailed_output(sorted_list, headers) + else: + headers = ['Name', 'NodeID', 'Description', 'ZeroTier IP', 'Network', 'Public IP'] + + sorted_list = sorted(controllerNodeList, key=lambda x: x[0].lower()) + print(tabulate(sorted_list, headers)) + + +def set(raw: bool, + allowed: str, + interface: str, + network_id: str, + state: str): + + rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') + if rc != 0: + raise vyos.opmode.Error(f"ZeroTier service is not active for interface {interface}") + + interface_path = zt_config_path / interface + + cmd(f"zerotier-cli -D{interface_path} set {network_id} {allowed}={state}") + + +def restart(interface: str): + rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') + if rc != 0: + raise vyos.opmode.Error(f"Failed to restart {interface}. Does {interface} exist?") + + cmd(f'systemctl --no-block restart vyos-zerotier-{interface}') + +def delete_config(interface: str): + rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') + if rc == 0: + raise vyos.opmode.Error(f"Interface {interface} is active. Unable to delete config directory") + + config_path = zt_config_path / interface + if not config_path.exists(): + raise vyos.opmode.Error(f"Config directory does not exist; nothing to archive") + + if any(config_path.iterdir()): + archive_path = zt_config_path / 'archive' / f'{interface}-{datetime.now().strftime("%Y%m%d-%H%M%S")}' + shutil.move(config_path, archive_path) + else: + raise vyos.opmode.Error(f"Config directory is empty; nothing to archive") + + if any(archive_path.iterdir()): + print(f"Archive created at {archive_path}") + else: + raise vyos.opmode.Error(f"Failed to create archive") + + +def import_config(path: str): + archive_path = zt_config_path / 'archive' / path + config_path = zt_config_path / path.split('-')[0] + + if config_path.exists(): + raise vyos.opmode.Error(f"Config directory already exists; cannot import config") + + if archive_path.exists(): + shutil.move(archive_path, config_path) + else: + raise vyos.opmode.Error(f"Archive not found") + + if config_path.exists(): + print(f"Config imported from {archive_path} to {config_path}") + else: + raise vyos.opmode.Error(f"Failed to import config") + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/validators/ip-port b/src/validators/ip-port new file mode 100644 index 0000000000..005af87f2c --- /dev/null +++ b/src/validators/ip-port @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# This script validates that a '/' separated IP/Port contains a valid IP and Port. + +import sys +import ipaddress + +def validate_ip_port(ip_port: str) -> None: + """ + Validate an input string in the form "IP/Port". + + - Splits the string into an IP address and port number. + - Verifies the IP is a valid IPv4 or IPv6 address. + - Verifies the port is an integer between 1 and 65535. + - Exits with code 0 if valid, otherwise prints an error message + to stderr and terminates the program. + """ + # Ensure format is correct: must be "ip/port" + parts = ip_port.split('/') + if len(parts) != 2: + sys.exit("Error: Input must be in the form IP/Port") + ip, port_str = parts + + # Validate IP + try: + ipaddress.ip_address(ip) + except ValueError: + sys.exit(f"Error: '{ip}' is not a valid IPv4/IPv6 address") + + # Validate Port + try: + port = int(port_str) + except ValueError: + sys.exit(f"Error: '{port_str}' is not a valid integer port") + + if not (1 <= port <= 65535): + sys.exit(f"Error: Port '{port}' must be between 1 and 65535") + + # If we reach here, everything is valid + sys.exit(0) + + +if __name__ == '__main__': + if len(sys.argv) > 1: + validate_ip_port(sys.argv[1]) + else: + sys.exit("Error: No IP/Port string provided") From 184fb7fd15a15205cd4303f717414b4364a4902f Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Wed, 24 Sep 2025 19:42:30 -0400 Subject: [PATCH 2/7] zerotier: T6455: JSON output formatting --- src/op_mode/zerotier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py index 0acc5ff962..a86fb03226 100644 --- a/src/op_mode/zerotier.py +++ b/src/op_mode/zerotier.py @@ -103,7 +103,7 @@ def show(raw: bool, if raw: return {'zerotier': cli_data} elif return_json: - return cli_data + return json.dumps(cli_data, indent=4) else: return cli_data From 0d53a3af223af2783fce193702bf34d0d10d7b17 Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Thu, 25 Sep 2025 07:05:45 -0400 Subject: [PATCH 3/7] zerotier: T6455: Copilot changes --- interface-definitions/interfaces_zerotier.xml.in | 6 +++--- smoketest/scripts/cli/test_zerotier.py | 8 ++++---- src/conf_mode/interfaces_zerotier.py | 2 +- src/helpers/strip-private.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/interface-definitions/interfaces_zerotier.xml.in b/interface-definitions/interfaces_zerotier.xml.in index ed53e33193..d430fea198 100644 --- a/interface-definitions/interfaces_zerotier.xml.in +++ b/interface-definitions/interfaces_zerotier.xml.in @@ -234,7 +234,7 @@ optimize - Primary like can change if better link exists + Primary link can change if better link exists @@ -281,7 +281,7 @@ (0|4|6|46|64) - Value must be between 0-1000000 + Value must be 0, 4, 6, 46, or 64 0 No version preference @@ -426,7 +426,7 @@ 1 - Multipah random mode + Multipath random mode 2 diff --git a/smoketest/scripts/cli/test_zerotier.py b/smoketest/scripts/cli/test_zerotier.py index 2d9f17ac79..3fd6940eab 100644 --- a/smoketest/scripts/cli/test_zerotier.py +++ b/smoketest/scripts/cli/test_zerotier.py @@ -281,17 +281,17 @@ def test_multiple_interfaces(self): def test_peer_specific_bonds(self): self.cli_set(base_path + ['zt1', 'network-id', '0123456789abcdef']) self.cli_set(base_path + ['zt1', 'primary', 'port', '9993']) - self.cli_set(base_path + ['zt1', 'custom-policy', 'custome_policy1', 'base-policy', 'active-backup']) - self.cli_set(base_path + ['zt1', 'custom-policy', 'custome_policy1', 'link-select-method', 'always']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'custom_policy1', 'base-policy', 'active-backup']) + self.cli_set(base_path + ['zt1', 'custom-policy', 'custom_policy1', 'link-select-method', 'always']) self.cli_set(base_path + ['zt1', 'peer-specific-bonds', '0123456789', 'bonding-policy', 'balance-rr']) - self.cli_set(base_path + ['zt1', 'peer-specific-bonds', '1234567890', 'bonding-policy', 'custome_policy1']) + self.cli_set(base_path + ['zt1', 'peer-specific-bonds', '1234567890', 'bonding-policy', 'custom_policy1']) self.cli_commit() # Load and check local.conf; ensure valid JSON local_conf = self.load_json() self.validate_zt(local_conf, ['peerSpecificBonds', '0123456789'], 'balance-rr') - self.validate_zt(local_conf, ['peerSpecificBonds', '1234567890'], 'custome_policy1') + self.validate_zt(local_conf, ['peerSpecificBonds', '1234567890'], 'custom_policy1') if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py index de26f69e2f..9372387c77 100644 --- a/src/conf_mode/interfaces_zerotier.py +++ b/src/conf_mode/interfaces_zerotier.py @@ -335,7 +335,7 @@ def apply(config): for interface in interfaces_changed: end = time.monotonic() + timeout while time.monotonic() < end: - int_exists = cmd(f'sudo ip link show') + int_exists = cmd(f'ip link show') if f'{interface}.' in int_exists: break time.sleep(interval) diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index 64e1746321..cac25f392a 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -35,8 +35,8 @@ parser.add_argument('--asn', action='store_true', help='strip off BGP ASNs') parser.add_argument('--snmp', action='store_true', help='strip off SNMP location information') parser.add_argument('--lldp', action='store_true', help='strip off LLDP location information') -parser.add_argument('--zt_node', action='store_true', help='strip off SNMP location information') -parser.add_argument('--zt_network', action='store_true', help='strip off LLDP location information') +parser.add_argument('--zt_node', action='store_true', help='strip off all but the first 3 characters of the node ID') +parser.add_argument('--zt_network', action='store_true', help='strip off all but the first 5 characters of the network ID') address_preserval = parser.add_mutually_exclusive_group() address_preserval.add_argument('--address', action='store_true', help='strip off all IPv4 and IPv6 addresses') From 8dcf7afa425becbff19ec45a5c477800e5211d80 Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Sat, 27 Sep 2025 19:54:25 -0400 Subject: [PATCH 4/7] zerotier: T6455: Add handling for zerotier as a bridge member --- python/vyos/utils/network.py | 13 ++++++++++ src/conf_mode/interfaces_bridge.py | 3 --- src/conf_mode/interfaces_zerotier.py | 37 +++++++++++++++++++--------- src/op_mode/zerotier.py | 26 +++++++++++++++++++ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index e6b838cdce..54023eddb2 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -252,6 +252,19 @@ def get_bridge_fdb(interface): tmp = loads(cmd(f'bridge -j fdb show dev {interface}')) return tmp +def get_bridge_master(ifname: str) -> str: + """ + Return the bridge master for a given network interface. + + Args: + ifname (str): The name of the interface to check (e.g., "zt1.abcde"). + + Returns: + str: The name of the bridge this interface belongs to (e.g., "br0"), + or an empty string if the interface is not part of any bridge. + """ + return cmd(f'basename "$(readlink -f /sys/class/net/{ifname}/brport/bridge 2>/dev/null)"').strip() + def get_all_vrfs(): """ Return a dictionary of all system wide known VRF instances """ from json import loads diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 2a6a39c6af..8cb0c515ad 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -175,9 +175,6 @@ def verify(bridge): if 'bpdu_guard' in interface_config and 'root_guard' in interface_config: raise ConfigError(error_msg + 'bpdu-guard and root-guard cannot be configured at the same time!') - if interface.startswith('zt'): - raise ConfigError(error_msg + 'ZeroTier interfaces are not supported on a bridge!') - if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py index 9372387c77..a463760678 100644 --- a/src/conf_mode/interfaces_zerotier.py +++ b/src/conf_mode/interfaces_zerotier.py @@ -19,19 +19,21 @@ from pathlib import Path +from vyos import ConfigError from vyos.base import Warning from vyos.config import Config -from vyos import ConfigError +from vyos.configdiff import Diff +from vyos.configdiff import get_config_diff from vyos.template import render from vyos.utils.process import call from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_recursive from vyos.utils.dict import dict_set_nested from vyos.configdict import node_changed +from vyos.utils.network import get_bridge_master from vyos.utils.network import interface_exists -from vyos.configdiff import Diff -from vyos.configdiff import get_config_diff zerotier_config = Path('/config/vyos-generated-zerotier') systemd_unit_path = Path('/run/systemd/system') @@ -306,7 +308,7 @@ def apply(config): network_local_conf_path.unlink(missing_ok=True) call('systemctl daemon-reload') - interfaces_changed = [] + interfaces_changed = {} for interface, interface_config in config['interfaces'].items(): # If an interface was removed, this was handled above. if removed_interfaces and interface in removed_interfaces: @@ -316,7 +318,11 @@ def apply(config): if interface not in config['interface_changed']: continue - interfaces_changed.append(interface) + interfaces_changed[interface] = {} + + # Check if the interface is a bridge member + for network in interface_config['network_id']: + interfaces_changed[interface][f'{interface}.{network[:5]}'] = get_bridge_master(f'{interface}.{network[:5]}') # Restart the interface if a restart is required. Enable and start # the interface if it's a new interface or was disabled. @@ -330,15 +336,22 @@ def apply(config): call(f'systemctl --no-block --quiet start vyos-zerotier-{interface}.service') # Give the interfaces time to start - timeout = 15 + timeout = 10 interval = 1 - for interface in interfaces_changed: - end = time.monotonic() + timeout - while time.monotonic() < end: - int_exists = cmd(f'ip link show') - if f'{interface}.' in int_exists: + for _, int_config in interfaces_changed.items(): + for interface, is_member in int_config.items(): + end = time.monotonic() + timeout + while time.monotonic() < end: + rc, output = rc_cmd(f'ip link show dev {interface}') + if rc != 0: + time.sleep(interval) + continue break - time.sleep(interval) + + # After a restart, the interface would be removed as a bridge member. + # Re-add the interface as a bridge member + if is_member: + cmd(f'ip link set {interface} master {is_member}') try: c = get_config() diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py index a86fb03226..95608c83fc 100644 --- a/src/op_mode/zerotier.py +++ b/src/op_mode/zerotier.py @@ -19,6 +19,7 @@ import sys import typing import shutil +import time from datetime import datetime from tabulate import tabulate @@ -30,6 +31,7 @@ from vyos.configquery import op_mode_config_dict from vyos.utils.dict import dict_search from vyos.utils.dict import dict_set_nested +from vyos.utils.network import get_bridge_master zt_config_path = Path('/config/vyos-generated-zerotier') @@ -220,12 +222,36 @@ def set(raw: bool, def restart(interface: str): + networks = op_mode_config_dict(['interfaces', 'zerotier', interface], key_mangling=('-', '_'), get_first_key=True).get('network_id', []) + sub_int_list = {} + + # Check if the interface is a bridge member + for network in networks: + sub_int_list[f'{interface}.{network[:5]}'] = get_bridge_master(f'{interface}.{network[:5]}') + rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') if rc != 0: raise vyos.opmode.Error(f"Failed to restart {interface}. Does {interface} exist?") cmd(f'systemctl --no-block restart vyos-zerotier-{interface}') + # Give the interfaces time to start + timeout = 10 + interval = 1 + for restart_int, is_member in sub_int_list.items(): + end = time.monotonic() + timeout + while time.monotonic() < end: + rc, output = rc_cmd(f'ip link show dev {restart_int}') + if rc != 0: + time.sleep(interval) + continue + break + # After a restart, the interface would be removed as a bridge member. + # Re-add the interface as a bridge member + if is_member: + cmd(f'ip link set {restart_int} master {is_member}') + + def delete_config(interface: str): rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') if rc == 0: From 7f52f379f670fc1373ff557a12f318bc68099de7 Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Sun, 28 Sep 2025 00:43:26 -0400 Subject: [PATCH 5/7] zerotier: T6455: Add handling for zerotier as an MPLS interface --- .../include/constraint/interface-name.xml.i | 2 +- python/vyos/utils/network.py | 12 ++++++++++ src/conf_mode/interfaces_zerotier.py | 22 +++++++++++++++---- src/op_mode/zerotier.py | 22 ++++++++++++++++--- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/interface-definitions/include/constraint/interface-name.xml.i b/interface-definitions/include/constraint/interface-name.xml.i index f64ea86f52..4b3335026d 100644 --- a/interface-definitions/include/constraint/interface-name.xml.i +++ b/interface-definitions/include/constraint/interface-name.xml.i @@ -1,4 +1,4 @@ -(bond|br|dum|en|ersp|eth|gnv|ifb|ipoe|lan|l2tp|l2tpeth|macsec|peth|ppp|pppoe|pptp|sstp|sstpc|tun|veth|vpptap|vpptun|vti|vtun|vxlan|wg|wlan|wwan)[0-9]+(.\d+)?|pod-[-_a-zA-Z0-9]{1,11}|lo +(bond|br|dum|en|ersp|eth|gnv|ifb|ipoe|lan|l2tp|l2tpeth|macsec|peth|ppp|pppoe|pptp|sstp|sstpc|tun|veth|vpptap|vpptun|vti|vtun|vxlan|wg|wlan|wwan)[0-9]+(\.\d+)?|zt[0-9]+(\.[A-Za-z0-9]+)?|pod-[-_a-zA-Z0-9]{1,11}|lo diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 54023eddb2..74a94aa519 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -265,6 +265,18 @@ def get_bridge_master(ifname: str) -> str: """ return cmd(f'basename "$(readlink -f /sys/class/net/{ifname}/brport/bridge 2>/dev/null)"').strip() +def is_mpls_enabled(interface: str) -> bool: + """ + Check if MPLS is enabled on a given interface. + Returns True if /proc/sys/net/mpls/conf//input == 1, else False. + """ + try: + value = cmd(f'cat /proc/sys/net/mpls/conf/{interface}/input').strip() + return value == '1' + except: + return False + + def get_all_vrfs(): """ Return a dictionary of all system wide known VRF instances """ from json import loads diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py index a463760678..8f8cf6754e 100644 --- a/src/conf_mode/interfaces_zerotier.py +++ b/src/conf_mode/interfaces_zerotier.py @@ -33,7 +33,9 @@ from vyos.utils.dict import dict_set_nested from vyos.configdict import node_changed from vyos.utils.network import get_bridge_master +from vyos.utils.network import is_mpls_enabled from vyos.utils.network import interface_exists +from vyos.utils.system import sysctl_write zerotier_config = Path('/config/vyos-generated-zerotier') systemd_unit_path = Path('/run/systemd/system') @@ -322,24 +324,30 @@ def apply(config): # Check if the interface is a bridge member for network in interface_config['network_id']: - interfaces_changed[interface][f'{interface}.{network[:5]}'] = get_bridge_master(f'{interface}.{network[:5]}') + sub_int = f'{interface}.{network[:5]}' + interfaces_changed[interface][sub_int] = {} + interfaces_changed[interface][sub_int]['bridges'] = get_bridge_master(sub_int) + interfaces_changed[interface][sub_int]['mpls'] = is_mpls_enabled(sub_int) # Restart the interface if a restart is required. Enable and start # the interface if it's a new interface or was disabled. if restart_required and interface in restart_required: - call(f'systemctl --no-block --quiet restart vyos-zerotier-{interface}.service') + call(f'systemctl --quiet restart vyos-zerotier-{interface}.service') # If an interface wasn't changed, don't restart it. elif no_restart_required and interface in no_restart_required: continue else: call(f'systemctl --quiet enable vyos-zerotier-{interface}.service') - call(f'systemctl --no-block --quiet start vyos-zerotier-{interface}.service') + call(f'systemctl --quiet start vyos-zerotier-{interface}.service') # Give the interfaces time to start timeout = 10 interval = 1 for _, int_config in interfaces_changed.items(): - for interface, is_member in int_config.items(): + for interface, int_config in int_config.items(): + is_member = dict_search('bridges', int_config) + is_mpls = dict_search('mpls', int_config) + end = time.monotonic() + timeout while time.monotonic() < end: rc, output = rc_cmd(f'ip link show dev {interface}') @@ -353,6 +361,12 @@ def apply(config): if is_member: cmd(f'ip link set {interface} master {is_member}') + # After a restart, the interface would be removed as a MPLS interface. + # Re-add the interface as a MPLS interface + if is_mpls: + sys_interface = interface.replace(".", "/") + sysctl_write(f'net.mpls.conf.{sys_interface}.input', 1) + try: c = get_config() verify(c) diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py index 95608c83fc..b3f7e9bd96 100644 --- a/src/op_mode/zerotier.py +++ b/src/op_mode/zerotier.py @@ -32,6 +32,9 @@ from vyos.utils.dict import dict_search from vyos.utils.dict import dict_set_nested from vyos.utils.network import get_bridge_master +from vyos.utils.network import is_mpls_enabled +from vyos.utils.system import sysctl_write +from vyos.utils.system import sysctl_read zt_config_path = Path('/config/vyos-generated-zerotier') @@ -227,18 +230,24 @@ def restart(interface: str): # Check if the interface is a bridge member for network in networks: - sub_int_list[f'{interface}.{network[:5]}'] = get_bridge_master(f'{interface}.{network[:5]}') + sub_int = f'{interface}.{network[:5]}' + sub_int_list[sub_int] = {} + sub_int_list[sub_int]['bridges'] = get_bridge_master(sub_int) + sub_int_list[sub_int]['mpls'] = is_mpls_enabled(sub_int) rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') if rc != 0: raise vyos.opmode.Error(f"Failed to restart {interface}. Does {interface} exist?") - cmd(f'systemctl --no-block restart vyos-zerotier-{interface}') + cmd(f'systemctl restart vyos-zerotier-{interface}') # Give the interfaces time to start timeout = 10 interval = 1 - for restart_int, is_member in sub_int_list.items(): + for restart_int, restart_config in sub_int_list.items(): + is_member = dict_search('bridges', restart_config) + is_mpls = dict_search('mpls', restart_config) + end = time.monotonic() + timeout while time.monotonic() < end: rc, output = rc_cmd(f'ip link show dev {restart_int}') @@ -246,11 +255,18 @@ def restart(interface: str): time.sleep(interval) continue break + # After a restart, the interface would be removed as a bridge member. # Re-add the interface as a bridge member if is_member: cmd(f'ip link set {restart_int} master {is_member}') + # After a restart, the interface would be removed as a MPLS interface. + # Re-add the interface as a MPLS interface + if is_mpls: + sys_interface = restart_int.replace(".", "/") + sysctl_write(f'net.mpls.conf.{sys_interface}.input', 1) + def delete_config(interface: str): rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') From 486ade70c11dd66597e11ae555b06e79974df317 Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Sun, 28 Sep 2025 13:17:13 -0400 Subject: [PATCH 6/7] zerotier: T6455: Fix redundant runs on same interface I realized due to the tagNode calling the script, the script was called per interface. I was configuring all interfaces for each run. --- python/vyos/ifconfig/zerotier.py | 43 +++ python/vyos/utils/network.py | 28 +- src/conf_mode/interfaces_zerotier.py | 478 +++++++++++++-------------- src/op_mode/zerotier.py | 40 +-- 4 files changed, 308 insertions(+), 281 deletions(-) diff --git a/python/vyos/ifconfig/zerotier.py b/python/vyos/ifconfig/zerotier.py index 65808f73c9..0573dd1be8 100644 --- a/python/vyos/ifconfig/zerotier.py +++ b/python/vyos/ifconfig/zerotier.py @@ -13,8 +13,51 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import time from vyos.ifconfig.interface import Interface +from vyos.utils.network import get_bridge_master +from vyos.utils.network import is_mpls_enabled +from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd +from vyos.utils.system import sysctl_write +from vyos.utils.dict import dict_search + +def build_sub_int_list(interface: str, networks: list[str]): + sub_int_list = {} + for network in networks: + sub_int = f'{interface}.{network[:5]}' + sub_int_list[sub_int] = {} + sub_int_list[sub_int]['bridges'] = get_bridge_master(sub_int) + sub_int_list[sub_int]['mpls'] = is_mpls_enabled(sub_int) + return sub_int_list + +def wait_for_interface(sub_int_list: dict): + # Give the interfaces time to start + timeout = 10 + interval = 1 + for restart_int, restart_config in sub_int_list.items(): + is_member = dict_search('bridges', restart_config) + is_mpls = dict_search('mpls', restart_config) + + end = time.monotonic() + timeout + while time.monotonic() < end: + rc, output = rc_cmd(f'ip link show dev {restart_int}') + if rc != 0: + time.sleep(interval) + continue + break + + # After a restart, the interface would be removed as a bridge member. + # Re-add the interface as a bridge member + if is_member: + cmd(f'ip link set {restart_int} master {is_member}') + + # After a restart, the interface would be removed as a MPLS interface. + # Re-add the interface as a MPLS interface + if is_mpls: + sys_interface = restart_int.replace(".", "/") + sysctl_write(f'net.mpls.conf.{sys_interface}.input', 1) @Interface.register class ZeroTierIf(Interface): diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 74a94aa519..4c94b9f536 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -14,9 +14,12 @@ # License along with this library. If not, see . import hashlib +import time + from socket import AF_INET from socket import AF_INET6 from vyos.utils.process import cmd +from vyos.utils.process import rc_cmd def _are_same_ip(one, two): from socket import inet_pton @@ -270,11 +273,26 @@ def is_mpls_enabled(interface: str) -> bool: Check if MPLS is enabled on a given interface. Returns True if /proc/sys/net/mpls/conf//input == 1, else False. """ - try: - value = cmd(f'cat /proc/sys/net/mpls/conf/{interface}/input').strip() - return value == '1' - except: - return False + from vyos.utils.system import sysctl_read + + interface = interface.replace('.', '/') + + timeout = 10 + interval = 1 + end = time.monotonic() + timeout + + # The interface may not be up yet, so we need to wait for it to be up. + while time.monotonic() < end: + try: + rc, out = rc_cmd(f'sysctl -n net.mpls.conf.{interface}.input 2>/dev/null') + if rc != 0: + continue + value = sysctl_read(f'net.mpls.conf.{interface}.input') + return value == "1" + except: + pass + time.sleep(interval) + return False def get_all_vrfs(): diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py index 8f8cf6754e..93c412e373 100644 --- a/src/conf_mode/interfaces_zerotier.py +++ b/src/conf_mode/interfaces_zerotier.py @@ -16,6 +16,7 @@ import sys import time +import os from pathlib import Path @@ -24,6 +25,8 @@ from vyos.config import Config from vyos.configdiff import Diff from vyos.configdiff import get_config_diff +from vyos.ifconfig.zerotier import build_sub_int_list +from vyos.ifconfig.zerotier import wait_for_interface from vyos.template import render from vyos.utils.process import call from vyos.utils.process import cmd @@ -58,68 +61,82 @@ def get_config(config=None): ) } + zerotier['ifname'] = os.environ["VYOS_TAGNODE_VALUE"] + ifname = zerotier['ifname'] + # If the base node changed, an interface was deleted tmp = node_changed(conf, base) if tmp: - zerotier['interface_remove'] = tmp + if ifname in tmp: + zerotier['interface_remove'] = [ifname] + # Interface is to be removed, no need for further processing + return zerotier # Check if an interface config changed at all expand_nodes = Diff.ADD | Diff.DELETE tmp = node_changed(conf, base, recursive=True, expand_nodes=expand_nodes) if tmp: + if ifname in tmp: + zerotier['interface_changed'] = [ifname] zerotier['interface_changed'] = tmp # Get all children that may have changed for an interface diff = get_config_diff(conf, key_mangling=('-', '_')) - tmp = diff.get_child_nodes_diff(base, expand_nodes=expand_nodes, recursive=True) + tmp = diff.get_child_nodes_diff([*base, ifname], expand_nodes=expand_nodes, recursive=True) diff_dict = {} # Restart is only required when a listening port//ip or network id is changed for section in ("delete", "add"): - for iface, changes in tmp.get(section, {}).items(): - network_config = dict_search('network_config', changes) - peer_config = dict_search('peer_config', changes) - if network_config: - for _, search_result in dict_search_recursive(network_config, 'blacklist'): - if search_result: - diff_dict.setdefault(iface, set()).update(changes.keys()) - elif peer_config: - for _, search_result in dict_search_recursive(peer_config, 'blacklist'): - if search_result: - diff_dict.setdefault(iface, set()).update(changes.keys()) - else: - diff_dict.setdefault(iface, set()).update(changes.keys()) - - # Track which network-ids were removed. - if section == 'delete' and 'network_id' in changes: - netids = changes['network_id'] - if isinstance(netids, list): - dict_set_nested(f'networks_removed.{iface}', netids, zerotier) - else: - dict_set_nested(f'networks_removed.{iface}', [netids], zerotier) - - for interface, interface_config in zerotier['interfaces'].items(): - # If an interface is disabled, treat it as a removal. - if 'disable' in interface_config: - zerotier.setdefault('interface_remove', []).append(interface) - - # Restart is only required when a listening port//ip or network id is changed - if interface in diff_dict: - restart_keys = { - 'network_id', 'primary', 'secondary', 'tertiary', 'interface_blacklist', - 'disable_secondary_port', 'listen_address', 'network_config', 'peer_config' - } - if diff_dict[interface] & restart_keys: - zerotier.setdefault('restart_required', set()).update([interface]) - elif 'disable' in diff_dict[interface]: - pass + changes = tmp.get(section, {}) + network_config = dict_search('network_config', changes) + peer_config = dict_search('peer_config', changes) + + if network_config: + for _, search_result in dict_search_recursive(network_config, 'blacklist'): + if search_result: + diff_dict.setdefault(ifname, set()).update(changes.keys()) + elif peer_config: + for _, search_result in dict_search_recursive(peer_config, 'blacklist'): + if search_result: + diff_dict.setdefault(ifname, set()).update(changes.keys()) + else: + diff_dict.setdefault(ifname, set()).update(changes.keys()) + + # Track which network-ids were removed. + if section == 'delete' and 'network_id' in changes: + netids = changes['network_id'] + if isinstance(netids, list): + dict_set_nested(f'networks_removed.{ifname}', netids, zerotier) else: - zerotier.setdefault('no_restart_required', set()).update([interface]) + dict_set_nested(f'networks_removed.{ifname}', [netids], zerotier) + + interface_config = zerotier['interfaces'][ifname] + # If an interface is disabled, treat it as a removal. + if 'disable' in interface_config: + zerotier.setdefault('interface_remove', []).append(ifname) + # Restart is only required when a listening port//ip or network id is changed + if ifname in diff_dict: + restart_keys = { + 'network_id', 'primary', 'secondary', 'tertiary', 'interface_blacklist', + 'disable_secondary_port', 'listen_address', 'network_config', 'peer_config' + } + if diff_dict[ifname] & restart_keys: + zerotier.setdefault('restart_required', set()).update([ifname]) + elif 'disable' in diff_dict[ifname]: + pass + else: + zerotier.setdefault('no_restart_required', set()).update([ifname]) return zerotier def verify(config): + ifname = config['ifname'] + # Interface is to be removed, no need for further processing + if 'interface_remove' in config: + if ifname in config['interface_remove']: + return + ports = {} for interface, interface_config in config['interfaces'].items(): @@ -128,161 +145,172 @@ def verify(config): secondary_port = dict_search('secondary.port', interface_config) tertiary_port = dict_search('tertiary.port', interface_config) - # Primary port is required - if not primary_port: - raise ConfigError("Primary Port must be configured") - - # Network ID must be configured - if not dict_search('network_id', interface_config): - raise ConfigError("Network ID must be configured") - - # Check for secondary port when allow-secondary-port is false - if secondary_port and dict_search('disable_secondary_port', interface_config) is not None: - raise ConfigError("Secondary port cannot be set when disable-secondary-port is configured") - - # Multicore must be enabled when cpu-pinning or core-count is configured - multicore_enabled = dict_search('multicore_options.enabled', interface_config) - if any([dict_search('multicore_options.core_count', interface_config), - dict_search('multicore_options.cpu_pinning', interface_config) is not None]): - if multicore_enabled is None: - raise ConfigError("Multicore must be enabled when cpu-pinning or core-count is configured") - - # controller-api-key must be configured if controller-api-url is set - api_key = dict_search('controller_api_key', interface_config) - api_url = dict_search('controller_api_url', interface_config) - if api_url and not api_key: - raise ConfigError("controller-api-key must be configured if controller-api-url is set") - # Check for duplicate ports for port in filter(None, (primary_port, secondary_port, tertiary_port)): if port in ports: raise ConfigError(f"Port {port} already assigned to interface {dict_search(port, ports)}") ports[port] = interface - # Check if user defined bonding policy is configured - pre_defined_policies = ('active-backup', 'broadcast', 'balance-rr', 'balance-xor', 'balance-aware') - bonding_policy = dict_search('bonding_policy', interface_config, '') - custom_policies = dict_search('custom_policy', interface_config) - if bonding_policy and bonding_policy not in pre_defined_policies: - if custom_policies: - if bonding_policy not in custom_policies: - raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") - else: - raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") - - # Check if user defined bonding policy is configured - peer_specific_data = dict_search('peer_specific_bonds', interface_config, {}) - for node, node_config in peer_specific_data.items(): - peer_specific_bonding_policy = dict_search('bonding_policy', node_config) - if peer_specific_bonding_policy not in pre_defined_policies: - if custom_policies and peer_specific_bonding_policy not in custom_policies: - raise ConfigError(f"Custom bonding policy {peer_specific_bonding_policy} is not configured") - + interface_config = config['interfaces'][ifname] + + # Define ports (if configured; otherwise None) + primary_port = dict_search('primary.port', interface_config) + secondary_port = dict_search('secondary.port', interface_config) + tertiary_port = dict_search('tertiary.port', interface_config) + + # Primary port is required + if not primary_port: + raise ConfigError("Primary Port must be configured") + + # Network ID must be configured + if not dict_search('network_id', interface_config): + raise ConfigError("Network ID must be configured") + + # Check for secondary port when allow-secondary-port is false + if secondary_port and dict_search('disable_secondary_port', interface_config) is not None: + raise ConfigError("Secondary port cannot be set when disable-secondary-port is configured") + + # Multicore must be enabled when cpu-pinning or core-count is configured + multicore_enabled = dict_search('multicore_options.enabled', interface_config) + if any([dict_search('multicore_options.core_count', interface_config), + dict_search('multicore_options.cpu_pinning', interface_config) is not None]): + if multicore_enabled is None: + raise ConfigError("Multicore must be enabled when cpu-pinning or core-count is configured") + + # controller-api-key must be configured if controller-api-url is set + api_key = dict_search('controller_api_key', interface_config) + api_url = dict_search('controller_api_url', interface_config) + if api_url and not api_key: + raise ConfigError("controller-api-key must be configured if controller-api-url is set") + + # Check if user defined bonding policy is configured + pre_defined_policies = ('active-backup', 'broadcast', 'balance-rr', 'balance-xor', 'balance-aware') + bonding_policy = dict_search('bonding_policy', interface_config, '') + custom_policies = dict_search('custom_policy', interface_config) + if bonding_policy and bonding_policy not in pre_defined_policies: if custom_policies: - for policy_name, policy_config in custom_policies.items(): - # policy name cannot have the name of a base policy - if policy_name in pre_defined_policies: - raise ConfigError(f"Policy name cannot be the same as a predefined policy") - - base_policy = dict_search('base_policy', policy_config) - - # Base policy must be set for custom bonding policy - if not base_policy: - raise ConfigError(f"Base policy must be set for custom bonding policy {policy_name}") - - # link-select-method is only valid for active-backup - if dict_search('link_select_method', policy_config) and "active-backup" not in base_policy: - raise ConfigError("link-select-method is only valid for active-backup bonding policy") - - links = dict_search('links', policy_config) - if links: - primary_count = 0 - for link, link_config in links.items(): - # Check if link exists - if not interface_exists(link): - Warning(f"Interface {link} does not exist") - - # capacity is only valid for balance-aware - if dict_search('capacity', link_config) and "balance-aware" not in base_policy: - raise ConfigError("capacity is only valid for balance-aware bonding policy") - - # mode has no effect for broadcast bonding policy - if dict_search('mode', link_config) and "broadcast" in base_policy: - raise ConfigError("mode is not valid for broadcast bonding policy") - - failover_to = dict_search('failover_to', link_config) - if failover_to: - # Make sure not failing over to self - if failover_to == link: - raise ConfigError("Cannot fail over to the same link") - - # Check if the interface to failover-to exists - if not interface_exists(failover_to): - Warning(f"Interface {failover_to} does not exist") - - # active-backup bonding policy may only have one primary link - if "active-backup" in base_policy: - if dict_search('mode', link_config) == 'primary': - primary_count += 1 - if primary_count > 1: - raise ConfigError("active-backup bonding policy must have only one primary link") - - link_quality = dict_search('link_quality', policy_config) - if link_quality: - # link-quality is only valid for balance-aware - if "balance-aware" not in base_policy: - raise ConfigError("link-quality is only valid for balance-aware bonding policy") - - lat_weight = dict_search('link_quality.latency_weight', policy_config) - pdv_weight = dict_search('link_quality.variance_weight', policy_config) - - # Check if latency-weight or variance-weight is set, both must be set - if any([lat_weight, pdv_weight]) and not all([lat_weight, pdv_weight]): - raise ConfigError("If latency-weight or variance-weight is set, both must be set") - - # Check if latency-weight and variance-weight add up to 1 - if float(lat_weight) + float(pdv_weight) != 1: - raise ConfigError("Latency-weight and variance-weight must equal 1") + if bonding_policy not in custom_policies: + raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") + else: + raise ConfigError(f"Custom bonding policy {bonding_policy} is not configured") + + # Check if user defined bonding policy is configured + peer_specific_data = dict_search('peer_specific_bonds', interface_config, {}) + for node, node_config in peer_specific_data.items(): + peer_specific_bonding_policy = dict_search('bonding_policy', node_config) + if peer_specific_bonding_policy not in pre_defined_policies: + if custom_policies and peer_specific_bonding_policy not in custom_policies: + raise ConfigError(f"Custom bonding policy {peer_specific_bonding_policy} is not configured") + + if custom_policies: + for policy_name, policy_config in custom_policies.items(): + # policy name cannot have the name of a base policy + if policy_name in pre_defined_policies: + raise ConfigError(f"Policy name cannot be the same as a predefined policy") + + base_policy = dict_search('base_policy', policy_config) + + # Base policy must be set for custom bonding policy + if not base_policy: + raise ConfigError(f"Base policy must be set for custom bonding policy {policy_name}") + + # link-select-method is only valid for active-backup + if dict_search('link_select_method', policy_config) and "active-backup" not in base_policy: + raise ConfigError("link-select-method is only valid for active-backup bonding policy") + + links = dict_search('links', policy_config) + if links: + primary_count = 0 + for link, link_config in links.items(): + # Check if link exists + if not interface_exists(link): + Warning(f"Interface {link} does not exist") + + # capacity is only valid for balance-aware + if dict_search('capacity', link_config) and "balance-aware" not in base_policy: + raise ConfigError("capacity is only valid for balance-aware bonding policy") + + # mode has no effect for broadcast bonding policy + if dict_search('mode', link_config) and "broadcast" in base_policy: + raise ConfigError("mode is not valid for broadcast bonding policy") + + failover_to = dict_search('failover_to', link_config) + if failover_to: + # Make sure not failing over to self + if failover_to == link: + raise ConfigError("Cannot fail over to the same link") + + # Check if the interface to failover-to exists + if not interface_exists(failover_to): + Warning(f"Interface {failover_to} does not exist") + + # active-backup bonding policy may only have one primary link + if "active-backup" in base_policy: + if dict_search('mode', link_config) == 'primary': + primary_count += 1 + if primary_count > 1: + raise ConfigError("active-backup bonding policy must have only one primary link") + + link_quality = dict_search('link_quality', policy_config) + if link_quality: + # link-quality is only valid for balance-aware + if "balance-aware" not in base_policy: + raise ConfigError("link-quality is only valid for balance-aware bonding policy") + + lat_weight = dict_search('link_quality.latency_weight', policy_config) + pdv_weight = dict_search('link_quality.variance_weight', policy_config) + + # Check if latency-weight or variance-weight is set, both must be set + if any([lat_weight, pdv_weight]) and not all([lat_weight, pdv_weight]): + raise ConfigError("If latency-weight or variance-weight is set, both must be set") + + # Check if latency-weight and variance-weight add up to 1 + if float(lat_weight) + float(pdv_weight) != 1: + raise ConfigError("Latency-weight and variance-weight must equal 1") def generate(config): - for interface, interface_config in config['interfaces'].items(): - # If an interface wasn't changed, don't generate anything new. - if interface not in config['interface_changed']: - continue + ifname = config['ifname'] + if 'interface_remove' in config: + if ifname in config['interface_remove']: + return config - network_id = dict_search('network_id', interface_config) + interface_config = config['interfaces'][ifname] - # Generate systemd unit file - unit_path = systemd_unit_path / f'vyos-zerotier-{interface}.service' - if not unit_path.exists(): # <- don't create if it already exists - render(str(unit_path), 'zerotier/systemd-unit.j2', {"name": interface}) + # If an interface wasn't changed, don't generate anything new. + if ifname not in config['interface_changed']: + return config - # Create interface directory - iface_dir = zerotier_config / interface - if not iface_dir.exists(): # <- don't create if it already exists - iface_dir.mkdir(parents=True, exist_ok=True) + network_id = dict_search('network_id', interface_config) - # Generate local.conf file - local_conf_path = iface_dir / 'local.conf' - render(str(local_conf_path), 'zerotier/local.conf.j2', config['interfaces'][interface], rm_trail_comma=True) # <- always create + # Generate systemd unit file + unit_path = systemd_unit_path / f'vyos-zerotier-{ifname}.service' + if not unit_path.exists(): # <- don't create if it already exists + render(str(unit_path), 'zerotier/systemd-unit.j2', {"name": ifname}) - # Create networks.d directory if it doesn't exist - network_conf_dir = iface_dir /'networks.d' - if not network_conf_dir.exists(): # <- don't create if it already exists - network_conf_dir.mkdir(parents=True, exist_ok=True) + # Create interface directory + iface_dir = zerotier_config / ifname + if not iface_dir.exists(): # <- don't create if it already exists + iface_dir.mkdir(parents=True, exist_ok=True) - # Generate network.conf file - for network in network_id: - network_conf_path = network_conf_dir / f'{network}.conf' - if not network_conf_path.exists(): - network_conf_path.touch(exist_ok=True) + # Generate local.conf file + local_conf_path = iface_dir / 'local.conf' + render(str(local_conf_path), 'zerotier/local.conf.j2', config['interfaces'][ifname], rm_trail_comma=True) # <- always create - # Generate devicemap (maps network-ids to interfaces) - device_map_path = iface_dir / 'devicemap' - render(str(device_map_path), 'zerotier/devicemap.j2', {"interface": interface, "network_id": network_id}) # <- always create + # Create networks.d directory if it doesn't exist + network_conf_dir = iface_dir /'networks.d' + if not network_conf_dir.exists(): # <- don't create if it already exists + network_conf_dir.mkdir(parents=True, exist_ok=True) - return config + # Generate network.conf file + for network in network_id: + network_conf_path = network_conf_dir / f'{network}.conf' + if not network_conf_path.exists(): + network_conf_path.touch(exist_ok=True) + + # Generate devicemap (maps network-ids to interfaces) + device_map_path = iface_dir / 'devicemap' + render(str(device_map_path), 'zerotier/devicemap.j2', {"interface": ifname, "network_id": network_id}) # <- always create def apply(config): @@ -291,81 +319,51 @@ def apply(config): restart_required = dict_search('restart_required', config, []) no_restart_required = dict_search('no_restart_required', config, []) + ifname = config['ifname'] + # Stop and disable interfaces that were removed. if removed_interfaces: - for interface in removed_interfaces: - unit_path = systemd_unit_path / f'vyos-zerotier-{interface}.service' + if ifname in removed_interfaces: + unit_path = systemd_unit_path / f'vyos-zerotier-{ifname}.service' if unit_path.exists(): - call(f'systemctl --no-block --quiet stop vyos-zerotier-{interface}.service') - call(f'systemctl --no-block --quiet disable vyos-zerotier-{interface}.service') + call(f'systemctl --no-block --quiet stop vyos-zerotier-{ifname}.service') + call(f'systemctl --no-block --quiet disable vyos-zerotier-{ifname}.service') unit_path.unlink(missing_ok=True) + return + interface_config = config['interfaces'][ifname] + networks = interface_config['network_id'] # Remove network.conf files that were removed. if networks_removed: - for interface, networks in networks_removed.items(): + if ifname in networks_removed: for network in networks: - network_conf_path = zerotier_config / interface / 'networks.d' / f'{network}.conf' - network_local_conf_path = zerotier_config / interface / f'{network}.local.conf' + network_conf_path = zerotier_config / ifname / 'networks.d' / f'{network}.conf' + network_local_conf_path = zerotier_config / ifname / f'{network}.local.conf' network_conf_path.unlink(missing_ok=True) network_local_conf_path.unlink(missing_ok=True) call('systemctl daemon-reload') - interfaces_changed = {} - for interface, interface_config in config['interfaces'].items(): - # If an interface was removed, this was handled above. - if removed_interfaces and interface in removed_interfaces: - continue - - # If an interface wasn't changed, don't restart it. - if interface not in config['interface_changed']: - continue - - interfaces_changed[interface] = {} - - # Check if the interface is a bridge member - for network in interface_config['network_id']: - sub_int = f'{interface}.{network[:5]}' - interfaces_changed[interface][sub_int] = {} - interfaces_changed[interface][sub_int]['bridges'] = get_bridge_master(sub_int) - interfaces_changed[interface][sub_int]['mpls'] = is_mpls_enabled(sub_int) - - # Restart the interface if a restart is required. Enable and start - # the interface if it's a new interface or was disabled. - if restart_required and interface in restart_required: - call(f'systemctl --quiet restart vyos-zerotier-{interface}.service') - # If an interface wasn't changed, don't restart it. - elif no_restart_required and interface in no_restart_required: - continue - else: - call(f'systemctl --quiet enable vyos-zerotier-{interface}.service') - call(f'systemctl --quiet start vyos-zerotier-{interface}.service') - - # Give the interfaces time to start - timeout = 10 - interval = 1 - for _, int_config in interfaces_changed.items(): - for interface, int_config in int_config.items(): - is_member = dict_search('bridges', int_config) - is_mpls = dict_search('mpls', int_config) - - end = time.monotonic() + timeout - while time.monotonic() < end: - rc, output = rc_cmd(f'ip link show dev {interface}') - if rc != 0: - time.sleep(interval) - continue - break - - # After a restart, the interface would be removed as a bridge member. - # Re-add the interface as a bridge member - if is_member: - cmd(f'ip link set {interface} master {is_member}') - - # After a restart, the interface would be removed as a MPLS interface. - # Re-add the interface as a MPLS interface - if is_mpls: - sys_interface = interface.replace(".", "/") - sysctl_write(f'net.mpls.conf.{sys_interface}.input', 1) + # If an interface was removed, this was handled above. + if removed_interfaces and ifname in removed_interfaces: + return + + # If an interface wasn't changed, don't restart it. + if ifname not in config['interface_changed']: + return + + sub_int_list = build_sub_int_list(ifname, networks) + # Restart the interface if a restart is required. Enable and start + # the interface if it's a new interface or was disabled. + if restart_required and ifname in restart_required: + call(f'systemctl --quiet restart vyos-zerotier-{ifname}.service') + # If an interface wasn't changed, don't restart it. + elif no_restart_required and ifname in no_restart_required: + return + else: + call(f'systemctl --quiet enable vyos-zerotier-{ifname}.service') + call(f'systemctl --quiet start vyos-zerotier-{ifname}.service') + + wait_for_interface(sub_int_list) try: c = get_config() diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py index b3f7e9bd96..7e919fb15a 100644 --- a/src/op_mode/zerotier.py +++ b/src/op_mode/zerotier.py @@ -31,10 +31,8 @@ from vyos.configquery import op_mode_config_dict from vyos.utils.dict import dict_search from vyos.utils.dict import dict_set_nested -from vyos.utils.network import get_bridge_master -from vyos.utils.network import is_mpls_enabled -from vyos.utils.system import sysctl_write -from vyos.utils.system import sysctl_read +from vyos.ifconfig.zerotier import wait_for_interface +from vyos.ifconfig.zerotier import build_sub_int_list zt_config_path = Path('/config/vyos-generated-zerotier') @@ -226,14 +224,8 @@ def set(raw: bool, def restart(interface: str): networks = op_mode_config_dict(['interfaces', 'zerotier', interface], key_mangling=('-', '_'), get_first_key=True).get('network_id', []) - sub_int_list = {} - # Check if the interface is a bridge member - for network in networks: - sub_int = f'{interface}.{network[:5]}' - sub_int_list[sub_int] = {} - sub_int_list[sub_int]['bridges'] = get_bridge_master(sub_int) - sub_int_list[sub_int]['mpls'] = is_mpls_enabled(sub_int) + sub_int_list = build_sub_int_list(interface, networks) rc, output = rc_cmd(f'systemctl --no-block status vyos-zerotier-{interface}') if rc != 0: @@ -241,31 +233,7 @@ def restart(interface: str): cmd(f'systemctl restart vyos-zerotier-{interface}') - # Give the interfaces time to start - timeout = 10 - interval = 1 - for restart_int, restart_config in sub_int_list.items(): - is_member = dict_search('bridges', restart_config) - is_mpls = dict_search('mpls', restart_config) - - end = time.monotonic() + timeout - while time.monotonic() < end: - rc, output = rc_cmd(f'ip link show dev {restart_int}') - if rc != 0: - time.sleep(interval) - continue - break - - # After a restart, the interface would be removed as a bridge member. - # Re-add the interface as a bridge member - if is_member: - cmd(f'ip link set {restart_int} master {is_member}') - - # After a restart, the interface would be removed as a MPLS interface. - # Re-add the interface as a MPLS interface - if is_mpls: - sys_interface = restart_int.replace(".", "/") - sysctl_write(f'net.mpls.conf.{sys_interface}.input', 1) + wait_for_interface(sub_int_list) def delete_config(interface: str): From 250814bd03539be69250838b105f397c57686214 Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Mon, 29 Sep 2025 14:32:23 -0400 Subject: [PATCH 7/7] zerotier: T6455: Remove unused imports --- src/conf_mode/interfaces_zerotier.py | 6 ------ src/op_mode/zerotier.py | 1 - 2 files changed, 7 deletions(-) diff --git a/src/conf_mode/interfaces_zerotier.py b/src/conf_mode/interfaces_zerotier.py index 93c412e373..e70138459b 100644 --- a/src/conf_mode/interfaces_zerotier.py +++ b/src/conf_mode/interfaces_zerotier.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import sys -import time import os from pathlib import Path @@ -29,16 +28,11 @@ from vyos.ifconfig.zerotier import wait_for_interface from vyos.template import render from vyos.utils.process import call -from vyos.utils.process import cmd -from vyos.utils.process import rc_cmd from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_recursive from vyos.utils.dict import dict_set_nested from vyos.configdict import node_changed -from vyos.utils.network import get_bridge_master -from vyos.utils.network import is_mpls_enabled from vyos.utils.network import interface_exists -from vyos.utils.system import sysctl_write zerotier_config = Path('/config/vyos-generated-zerotier') systemd_unit_path = Path('/run/systemd/system') diff --git a/src/op_mode/zerotier.py b/src/op_mode/zerotier.py index 7e919fb15a..b63bcecea3 100644 --- a/src/op_mode/zerotier.py +++ b/src/op_mode/zerotier.py @@ -19,7 +19,6 @@ import sys import typing import shutil -import time from datetime import datetime from tabulate import tabulate