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/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/interface-definitions/interfaces_zerotier.xml.in b/interface-definitions/interfaces_zerotier.xml.in
new file mode 100644
index 0000000000..d430fea198
--- /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 link 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 0, 4, 6, 46, or 64
+
+ 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
+ Multipath 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..0573dd1be8
--- /dev/null
+++ b/python/vyos/ifconfig/zerotier.py
@@ -0,0 +1,71 @@
+#!/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 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):
+ 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/python/vyos/utils/network.py b/python/vyos/utils/network.py
index e6b838cdce..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
@@ -252,6 +255,46 @@ 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 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.
+ """
+ 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():
""" Return a dictionary of all system wide known VRF instances """
from json import loads
diff --git a/smoketest/scripts/cli/test_zerotier.py b/smoketest/scripts/cli/test_zerotier.py
new file mode 100644
index 0000000000..3fd6940eab
--- /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', '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', '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'], 'custom_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_zerotier.py b/src/conf_mode/interfaces_zerotier.py
new file mode 100644
index 0000000000..e70138459b
--- /dev/null
+++ b/src/conf_mode/interfaces_zerotier.py
@@ -0,0 +1,369 @@
+#!/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 os
+
+from pathlib import Path
+
+from vyos import ConfigError
+from vyos.base import Warning
+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.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
+
+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,
+ )
+ }
+
+ 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:
+ 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, 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"):
+ 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:
+ 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():
+ # 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)
+
+ # 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
+
+ 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:
+ 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):
+ ifname = config['ifname']
+ if 'interface_remove' in config:
+ if ifname in config['interface_remove']:
+ return config
+
+ interface_config = config['interfaces'][ifname]
+
+ # If an interface wasn't changed, don't generate anything new.
+ if ifname not in config['interface_changed']:
+ return config
+
+ network_id = dict_search('network_id', interface_config)
+
+ # 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 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 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
+
+ # 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": ifname, "network_id": network_id}) # <- always create
+
+
+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, [])
+
+ ifname = config['ifname']
+
+ # Stop and disable interfaces that were removed.
+ if removed_interfaces:
+ 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-{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:
+ if ifname in networks_removed:
+ for network in networks:
+ 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')
+ # 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()
+ 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..cac25f392a 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 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')
@@ -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..b63bcecea3
--- /dev/null
+++ b/src/op_mode/zerotier.py
@@ -0,0 +1,284 @@
+#!/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
+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')
+
+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 json.dumps(cli_data, indent=4)
+ 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):
+ networks = op_mode_config_dict(['interfaces', 'zerotier', interface], key_mangling=('-', '_'), get_first_key=True).get('network_id', [])
+
+ sub_int_list = build_sub_int_list(interface, networks)
+
+ 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 restart vyos-zerotier-{interface}')
+
+ wait_for_interface(sub_int_list)
+
+
+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")