diff options
115 files changed, 4524 insertions, 1496 deletions
diff --git a/.github/workflows/cleanup-mirror-pr-branch.yml b/.github/workflows/cleanup-mirror-pr-branch.yml index bbe6aa2f2..a62e44b24 100644 --- a/.github/workflows/cleanup-mirror-pr-branch.yml +++ b/.github/workflows/cleanup-mirror-pr-branch.yml @@ -11,5 +11,6 @@ permissions: jobs: call-delete-branch: + if: github.repository_owner != 'vyos' uses: vyos/.github/.github/workflows/cleanup-mirror-pr-branch.yml@current secrets: inherit diff --git a/.github/workflows/mirror-pr-and-sync.yml b/.github/workflows/mirror-pr-and-sync.yml new file mode 100644 index 000000000..48a67a43f --- /dev/null +++ b/.github/workflows/mirror-pr-and-sync.yml @@ -0,0 +1,26 @@ +name: Create Mirror PR and Repo Sync +on: + workflow_dispatch: + inputs: + pr_number: + description: 'Source repo PR Number' + required: true + type: string + sync_branch: + description: 'branch to sync' + required: true + type: string + +permissions: + pull-requests: write + contents: write + +jobs: + call-mirror-pr-and-sync: + if: github.repository_owner != 'vyos' + uses: VyOS-Networks/vyos-reusable-workflows/.github/workflows/mirror-pr-and-sync.yml@main + with: + pr_number: ${{ inputs.pr_number }} + sync_branch: ${{ inputs.sync_branch }} + secrets: + PAT: ${{ secrets.PAT }} diff --git a/.github/workflows/trigger-pr-mirror-repo-sync.yml b/.github/workflows/trigger-pr-mirror-repo-sync.yml index d5e8ce3b4..f74895987 100644 --- a/.github/workflows/trigger-pr-mirror-repo-sync.yml +++ b/.github/workflows/trigger-pr-mirror-repo-sync.yml @@ -8,5 +8,6 @@ on: jobs: call-trigger-mirror-pr-repo-sync: + if: github.repository_owner == 'vyos' && github.event.pull_request.merged == true uses: vyos/.github/.github/workflows/trigger-pr-mirror-repo-sync.yml@current secrets: inherit diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json index 35587b63c..170f0d259 100644 --- a/data/op-mode-standardized.json +++ b/data/op-mode-standardized.json @@ -19,7 +19,6 @@ "multicast.py", "nat.py", "neighbor.py", -"nhrp.py", "openconnect.py", "openvpn.py", "otp.py", diff --git a/data/templates/dhcp-server/10-override.conf.j2 b/data/templates/dhcp-server/10-override.conf.j2 deleted file mode 100644 index 6cf9e0a11..000000000 --- a/data/templates/dhcp-server/10-override.conf.j2 +++ /dev/null @@ -1,2 +0,0 @@ -[Unit] -ConditionFileNotEmpty= diff --git a/data/templates/dns-dynamic/ddclient.conf.j2 b/data/templates/dns-dynamic/ddclient.conf.j2 index 23aad4cb8..b209c8c81 100644 --- a/data/templates/dns-dynamic/ddclient.conf.j2 +++ b/data/templates/dns-dynamic/ddclient.conf.j2 @@ -21,10 +21,7 @@ if{{ ipv }}={{ address }}, \ {{ host }} {% endmacro %} ### Autogenerated by service_dns_dynamic.py ### -daemon={{ interval }} -syslog=yes ssl=yes -cache={{ config_file | replace('.conf', '.cache') }} {# ddclient default (web=dyndns) doesn't support ssl and results in process lockup #} web=googledomains {# ddclient default (use=ip) results in confusing warning message in log #} diff --git a/data/templates/dns-dynamic/override.conf.j2 b/data/templates/dns-dynamic/override.conf.j2 index c0edd8f05..aaed4ff35 100644 --- a/data/templates/dns-dynamic/override.conf.j2 +++ b/data/templates/dns-dynamic/override.conf.j2 @@ -1,4 +1,5 @@ {% set vrf_command = 'ip vrf exec ' ~ vrf ~ ' ' if vrf is vyos_defined else '' %} +{% set cache_file = config_file.replace('.conf', '.cache') %} [Unit] ConditionPathExists={{ config_file }} Wants= @@ -7,5 +8,5 @@ After=vyos-router.service [Service] EnvironmentFile= ExecStart= -ExecStart={{ vrf_command }}/usr/bin/ddclient --file {{ config_file }} --foreground +ExecStart={{ vrf_command }}/usr/bin/ddclient --file {{ config_file }} --cache {{ cache_file }} --foreground --daemon {{ interval }} Restart=always diff --git a/data/templates/frr/daemons.frr.tmpl b/data/templates/frr/daemons.frr.tmpl index 3506528d2..835dc382b 100644 --- a/data/templates/frr/daemons.frr.tmpl +++ b/data/templates/frr/daemons.frr.tmpl @@ -30,7 +30,7 @@ isisd=yes pimd=no pim6d=yes ldpd=yes -nhrpd=no +nhrpd=yes eigrpd=no babeld=yes sharpd=no diff --git a/data/templates/frr/nhrpd.frr.j2 b/data/templates/frr/nhrpd.frr.j2 new file mode 100644 index 000000000..2b2aba256 --- /dev/null +++ b/data/templates/frr/nhrpd.frr.j2 @@ -0,0 +1,62 @@ +! +{% if redirect is vyos_defined %} +nhrp nflog-group {{ redirect }} +{% endif %} +{% if multicast is vyos_defined %} +nhrp multicast-nflog-group {{ multicast }} +{% endif %} +{% if tunnel is vyos_defined %} +{% for iface, iface_config in tunnel.items() %} +interface {{ iface }} +{% if iface_config.authentication is vyos_defined %} + ip nhrp authentication {{ iface_config.authentication }} +{% endif %} +{% if iface_config.holdtime is vyos_defined %} + ip nhrp holdtime {{ iface_config.holdtime }} +{% endif %} +{% if iface_config.map.tunnel_ip is vyos_defined %} +{% for tunip, tunip_config in iface_config.map.tunnel_ip.items() %} +{% if tunip_config.nbma is vyos_defined %} + ip nhrp map {{ tunip }} {{ tunip_config.nbma }} +{% endif %} +{% endfor %} +{% endif %} +{% if iface_config.mtu is vyos_defined %} + ip nhrp mtu {{ iface_config.mtu }} +{% endif %} +{% if iface_config.multicast is vyos_defined %} +{% for multicast_ip in iface_config.multicast %} + ip nhrp map multicast {{ multicast_ip }} +{% endfor %} +{% endif %} +{% if iface_config.nhs.tunnel_ip is vyos_defined %} +{% for tunip, tunip_config in iface_config.nhs.tunnel_ip.items() %} +{% if tunip_config.nbma is vyos_defined %} +{% for nbmaip in tunip_config.nbma %} + ip nhrp nhs {{ tunip }} nbma {{ nbmaip }} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} +{% if iface_config.network_id is vyos_defined %} + ip nhrp network-id {{ iface_config.network_id }} +{% endif %} +{% if iface_config.redirect is vyos_defined %} + ip nhrp redirect +{% endif %} +{% if iface_config.registration_no_unique is vyos_defined %} + ip nhrp registration no-unique +{% endif %} +{% if iface_config.shortcut is vyos_defined %} + ip nhrp shortcut +{% endif %} +{% if iface_config.security_profile is vyos_defined %} + tunnel protection vici profile dmvpn-{{ iface_config.security_profile }}-{{ iface }}-child +{% endif %} +exit +! +{% endfor %} +{% endif %} +! +exit +! diff --git a/data/templates/frr/nhrpd_nftables.conf.j2 b/data/templates/frr/nhrpd_nftables.conf.j2 new file mode 100644 index 000000000..6ae35ef52 --- /dev/null +++ b/data/templates/frr/nhrpd_nftables.conf.j2 @@ -0,0 +1,46 @@ +#!/usr/sbin/nft -f + +table ip vyos_nhrp_multicast +table ip vyos_nhrp_redirect +delete table ip vyos_nhrp_multicast +delete table ip vyos_nhrp_redirect +{% if multicast is vyos_defined %} +table ip vyos_nhrp_multicast { + chain VYOS_NHRP_MULTICAST_OUTPUT { + type filter hook output priority filter+10; policy accept; +{% if tunnel is vyos_defined %} +{% for tun, tunnel_conf in tunnel.items() %} +{% if tunnel_conf.multicast is vyos_defined %} + oifname "{{ tun }}" ip daddr 224.0.0.0/24 counter log group {{ multicast }} + oifname "{{ tun }}" ip daddr 224.0.0.0/24 counter drop +{% endif %} +{% endfor %} +{% endif %} + } + chain VYOS_NHRP_MULTICAST_FORWARD { + type filter hook forward priority filter+10; policy accept; +{% if tunnel is vyos_defined %} +{% for tun, tunnel_conf in tunnel.items() %} +{% if tunnel_conf.multicast is vyos_defined %} + oifname "{{ tun }}" ip daddr 224.0.0.0/4 counter log group {{ multicast }} + oifname "{{ tun }}" ip daddr 224.0.0.0/4 counter drop +{% endif %} +{% endfor %} +{% endif %} + } +} +{% endif %} +{% if redirect is vyos_defined %} +table ip vyos_nhrp_redirect { + chain VYOS_NHRP_REDIRECT_FORWARD { + type filter hook forward priority filter+10; policy accept; +{% if tunnel is vyos_defined %} +{% for tun, tunnel_conf in tunnel.items() %} +{% if tunnel_conf.redirect is vyos_defined %} + iifname "{{ tun }}" oifname "{{ tun }}" meter loglimit-0 size 65535 { ip daddr & 255.255.255.0 . ip saddr & 255.255.255.0 timeout 1m limit rate 4/minute burst 1 packets } counter log group {{ redirect }} +{% endif %} +{% endfor %} +{% endif %} + } +} +{% endif %} diff --git a/data/templates/ipsec/swanctl/profile.j2 b/data/templates/ipsec/swanctl/profile.j2 index 8519a84f8..6a04b038a 100644 --- a/data/templates/ipsec/swanctl/profile.j2 +++ b/data/templates/ipsec/swanctl/profile.j2 @@ -22,16 +22,16 @@ } {% endif %} children { - dmvpn { + dmvpn-{{ name }}-{{ interface }}-child { esp_proposals = {{ esp | get_esp_ike_cipher(ike) | join(',') }} rekey_time = {{ esp.lifetime }}s rand_time = 540s local_ts = dynamic[gre] remote_ts = dynamic[gre] mode = {{ esp.mode }} -{% if ike.dead_peer_detection.action is vyos_defined %} - dpd_action = {{ ike.dead_peer_detection.action }} -{% endif %} + dpd_action = clear + close_action = none + start_action = none {% if esp.compression is vyos_defined('enable') %} ipcomp = yes {% endif %} diff --git a/data/templates/login/pam_radius_auth.conf.j2 b/data/templates/login/pam_radius_auth.conf.j2 index 75437ca71..f9b8d5e87 100644 --- a/data/templates/login/pam_radius_auth.conf.j2 +++ b/data/templates/login/pam_radius_auth.conf.j2 @@ -9,7 +9,7 @@ {% if address | is_ipv4 %} {% set source_address.ipv4 = address %} {% elif address | is_ipv6 %} -{% set source_address.ipv6 = "[" + address + "]" %} +{% set source_address.ipv6 = address %} {% endif %} {% endfor %} {% endif %} @@ -21,7 +21,7 @@ {% if server | is_ipv4 %} {{ server }}:{{ options.port }} {{ "%-25s" | format(options.key) }} {{ "%-10s" | format(options.timeout) }} {{ source_address.ipv4 if source_address.ipv4 is vyos_defined }} {% else %} -[{{ server }}]:{{ options.port }} {{ "%-25s" | format(options.key) }} {{ "%-10s" | format(options.timeout) }} {{ source_address.ipv6 if source_address.ipv6 is vyos_defined }} +{{ server | bracketize_ipv6 }}:{{ options.port }} {{ "%-25s" | format(options.key) }} {{ "%-10s" | format(options.timeout) }} {{ source_address.ipv6 if source_address.ipv6 is vyos_defined }} {% endif %} {% endfor %} {% endif %} diff --git a/data/templates/nhrp/nftables.conf.j2 b/data/templates/nhrp/nftables.conf.j2 deleted file mode 100644 index a0d1f6d4c..000000000 --- a/data/templates/nhrp/nftables.conf.j2 +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/sbin/nft -f - -{% if first_install is not vyos_defined %} -delete table ip vyos_nhrp_filter -{% endif %} -table ip vyos_nhrp_filter { - chain VYOS_NHRP_OUTPUT { - type filter hook output priority 10; policy accept; -{% if tunnel is vyos_defined %} -{% for tun, tunnel_conf in tunnel.items() %} -{% if if_tunnel[tun].source_address is vyos_defined %} - ip protocol gre ip saddr {{ if_tunnel[tun].source_address }} ip daddr 224.0.0.0/4 counter drop comment "VYOS_NHRP_{{ tun }}" -{% endif %} -{% endfor %} -{% endif %} - } -} diff --git a/data/templates/nhrp/opennhrp.conf.j2 b/data/templates/nhrp/opennhrp.conf.j2 deleted file mode 100644 index c040a8f14..000000000 --- a/data/templates/nhrp/opennhrp.conf.j2 +++ /dev/null @@ -1,42 +0,0 @@ -{# j2lint: disable=jinja-variable-format #} -# Created by VyOS - manual changes will be overwritten - -{% if tunnel is vyos_defined %} -{% for name, tunnel_conf in tunnel.items() %} -{% set type = 'spoke' if tunnel_conf.map is vyos_defined or tunnel_conf.dynamic_map is vyos_defined else 'hub' %} -{% set profile_name = profile_map[name] if profile_map is vyos_defined and name in profile_map else '' %} -interface {{ name }} #{{ type }} {{ profile_name }} -{% if tunnel_conf.map is vyos_defined %} -{% for map, map_conf in tunnel_conf.map.items() %} -{% set cisco = ' cisco' if map_conf.cisco is vyos_defined else '' %} -{% set register = ' register' if map_conf.register is vyos_defined else '' %} - map {{ map }} {{ map_conf.nbma_address }}{{ register }}{{ cisco }} -{% endfor %} -{% endif %} -{% if tunnel_conf.dynamic_map is vyos_defined %} -{% for map, map_conf in tunnel_conf.dynamic_map.items() %} - dynamic-map {{ map }} {{ map_conf.nbma_domain_name }} -{% endfor %} -{% endif %} -{% if tunnel_conf.cisco_authentication is vyos_defined %} - cisco-authentication {{ tunnel_conf.cisco_authentication }} -{% endif %} -{% if tunnel_conf.holding_time is vyos_defined %} - holding-time {{ tunnel_conf.holding_time }} -{% endif %} -{% if tunnel_conf.multicast is vyos_defined %} - multicast {{ tunnel_conf.multicast }} -{% endif %} -{% for key in ['non_caching', 'redirect', 'shortcut', 'shortcut_destination'] %} -{% if key in tunnel_conf %} - {{ key | replace("_", "-") }} -{% endif %} -{% endfor %} -{% if tunnel_conf.shortcut_target is vyos_defined %} -{% for target, shortcut_conf in tunnel_conf.shortcut_target.items() %} - shortcut-target {{ target }}{{ ' holding-time ' + shortcut_conf.holding_time if shortcut_conf.holding_time is vyos_defined }} -{% endfor %} -{% endif %} - -{% endfor %} -{% endif %} diff --git a/data/templates/pmacct/uacctd.conf.j2 b/data/templates/pmacct/uacctd.conf.j2 index aae0a0619..d2de80df4 100644 --- a/data/templates/pmacct/uacctd.conf.j2 +++ b/data/templates/pmacct/uacctd.conf.j2 @@ -25,12 +25,6 @@ imt_mem_pools_number: 169 {% set _ = plugin.append('nfprobe['~ nf_server_key ~ ']') %} {% endfor %} {% endif %} -{% if sflow.server is vyos_defined %} -{% for server in sflow.server %} -{% set sf_server_key = 'sf_' ~ server | dot_colon_to_dash %} -{% set _ = plugin.append('sfprobe[' ~ sf_server_key ~ ']') %} -{% endfor %} -{% endif %} {% if disable_imt is not defined %} {% set _ = plugin.append('memory') %} {% endif %} @@ -61,20 +55,3 @@ nfprobe_timeouts[{{ nf_server_key }}]: expint={{ netflow.timeout.expiry_interval {% endfor %} {% endif %} - -{% if sflow.server is vyos_defined %} -# sFlow servers -{% for server, server_config in sflow.server.items() %} -{# # prevent pmacct syntax error when using IPv6 flow collectors #} -{% set sf_server_key = 'sf_' ~ server | dot_colon_to_dash %} -sfprobe_receiver[{{ sf_server_key }}]: {{ server | bracketize_ipv6 }}:{{ server_config.port }} -sfprobe_agentip[{{ sf_server_key }}]: {{ sflow.agent_address }} -{% if sflow.sampling_rate is vyos_defined %} -sampling_rate[{{ sf_server_key }}]: {{ sflow.sampling_rate }} -{% endif %} -{% if sflow.source_address is vyos_defined %} -sfprobe_source_ip[{{ sf_server_key }}]: {{ sflow.source_address | bracketize_ipv6 }} -{% endif %} - -{% endfor %} -{% endif %} diff --git a/data/templates/sflow/hsflowd.conf.j2 b/data/templates/sflow/hsflowd.conf.j2 index 5000956bd..6a1ba2956 100644 --- a/data/templates/sflow/hsflowd.conf.j2 +++ b/data/templates/sflow/hsflowd.conf.j2 @@ -25,6 +25,9 @@ sflow { pcap { dev={{ iface }} } {% endfor %} {% endif %} +{% if enable_egress is vyos_defined %} + psample { group=1 egress=on } +{% endif %} {% if drop_monitor_limit is vyos_defined %} dropmon { limit={{ drop_monitor_limit }} start=on sw=on hw=off } {% endif %} diff --git a/data/templates/telegraf/syslog_telegraf.j2 b/data/templates/telegraf/syslog_telegraf.j2 index cdcbd92a4..4fe6382ab 100644 --- a/data/templates/telegraf/syslog_telegraf.j2 +++ b/data/templates/telegraf/syslog_telegraf.j2 @@ -2,4 +2,8 @@ $ModLoad omuxsock $OMUxSockSocket /run/telegraf/telegraf_syslog.sock +{% if telegraf.loki is vyos_defined or telegraf.splunk is vyos_defined %} +*.info;*.notice :omuxsock: +{% else %} *.notice :omuxsock: +{% endif %} diff --git a/debian/control b/debian/control index a0d475d56..76fe5c331 100644 --- a/debian/control +++ b/debian/control @@ -172,9 +172,6 @@ Depends: frr-rpki-rtrlib, frr-snmp, # End "protocols *" -# For "protocols nhrp" (part of DMVPN) - opennhrp, -# End "protocols nhrp" # For "protocols igmp-proxy" igmpproxy, # End "protocols igmp-proxy" diff --git a/debian/rules b/debian/rules index d7c427b0d..f579ffec9 100755 --- a/debian/rules +++ b/debian/rules @@ -9,7 +9,7 @@ VYOS_CFG_TMPL_DIR := opt/vyatta/share/vyatta-cfg/templates VYOS_OP_TMPL_DIR := opt/vyatta/share/vyatta-op/templates VYOS_MIBS_DIR := usr/share/snmp/mibs VYOS_LOCALUI_DIR := srv/localui -VYCONF_CONFIG_DIR := $(VYOS_LIBEXEC_DIR)/vyconf/config +VYCONF_REFTREE_DIR := $(VYOS_LIBEXEC_DIR)/vyconf/reftree MIGRATION_SCRIPTS_DIR := opt/vyatta/etc/config-migrate/migrate ACTIVATION_SCRIPTS_DIR := usr/libexec/vyos/activate @@ -90,8 +90,8 @@ override_dh_auto_install: cp -r templates-op/* $(DIR)/$(VYOS_OP_TMPL_DIR) # Install data files - mkdir -p $(DIR)/$(VYCONF_CONFIG_DIR) - cp -r data/reftree.cache $(DIR)/$(VYCONF_CONFIG_DIR) + mkdir -p $(DIR)/$(VYCONF_REFTREE_DIR) + cp -r data/reftree.cache $(DIR)/$(VYCONF_REFTREE_DIR) mkdir -p $(DIR)/$(VYOS_DATA_DIR) cp -r data/* $(DIR)/$(VYOS_DATA_DIR) # Remove j2lint comments / linter configuration which would insert additional new-lines diff --git a/debian/vyos-1x-smoketest.postinst b/debian/vyos-1x-smoketest.postinst index 57149af82..bff73796c 100755 --- a/debian/vyos-1x-smoketest.postinst +++ b/debian/vyos-1x-smoketest.postinst @@ -2,14 +2,18 @@ BUSYBOX_TAG="docker.io/library/busybox:stable" BUSYBOX_PATH="/usr/share/vyos/busybox-stable.tar" -if [[ -f $BUSYBOX_PATH ]]; then - rm -f $BUSYBOX_PATH +if [[ ! -f $BUSYBOX_PATH ]]; then + skopeo copy --additional-tag "$BUSYBOX_TAG" "docker://$BUSYBOX_TAG" "docker-archive:/$BUSYBOX_PATH" fi -skopeo copy --additional-tag "$BUSYBOX_TAG" "docker://$BUSYBOX_TAG" "docker-archive:/$BUSYBOX_PATH" TACPLUS_TAG="docker.io/lfkeitel/tacacs_plus:alpine" TACPLUS_PATH="/usr/share/vyos/tacplus-alpine.tar" -if [[ -f $TACPLUS_PATH ]]; then - rm -f $TACPLUS_PATH +if [[ ! -f $TACPLUS_PATH ]]; then + skopeo copy --additional-tag "$TACPLUS_TAG" "docker://$TACPLUS_TAG" "docker-archive:/$TACPLUS_PATH" +fi + +RADIUS_TAG="docker.io/dchidell/radius-web:latest" +RADIUS_PATH="/usr/share/vyos/radius-latest.tar" +if [[ ! -f $RADIUS_PATH ]]; then + skopeo copy --additional-tag "$RADIUS_TAG" "docker://$RADIUS_TAG" "docker-archive:/$RADIUS_PATH" fi -skopeo copy --additional-tag "$TACPLUS_TAG" "docker://$TACPLUS_TAG" "docker-archive:/$TACPLUS_PATH" diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index ff5a91e09..fde58651a 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -272,3 +272,7 @@ update-alternatives --set regulatory.db /lib/firmware/regulatory.db-upstream if systemctl is-active --quiet vyos-configd; then systemctl restart vyos-configd fi +# Restart vyos-domain-resolver if running +if systemctl is-active --quiet vyos-domain-resolver; then + systemctl restart vyos-domain-resolver +fi diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index ad1815604..04318a7c9 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -131,7 +131,7 @@ <properties> <help>Add custom environment variables</help> <constraint> - <regex>[-_a-zA-Z0-9]+</regex> + #include <include/constraint/alpha-numeric-hyphen-underscore.xml.i> </constraint> <constraintErrorMessage>Environment variable name must be alphanumeric and can contain hyphen and underscores</constraintErrorMessage> </properties> diff --git a/interface-definitions/include/haproxy/rule-backend.xml.i b/interface-definitions/include/haproxy/rule-backend.xml.i index b2be4fde4..1df9d5dcf 100644 --- a/interface-definitions/include/haproxy/rule-backend.xml.i +++ b/interface-definitions/include/haproxy/rule-backend.xml.i @@ -47,7 +47,7 @@ <properties> <help>Server name</help> <constraint> - <regex>[-_a-zA-Z0-9]+</regex> + #include <include/constraint/alpha-numeric-hyphen-underscore.xml.i> </constraint> <constraintErrorMessage>Server name must be alphanumeric and can contain hyphen and underscores</constraintErrorMessage> </properties> diff --git a/interface-definitions/include/haproxy/rule-frontend.xml.i b/interface-definitions/include/haproxy/rule-frontend.xml.i index 001ae2d80..eabdd8632 100644 --- a/interface-definitions/include/haproxy/rule-frontend.xml.i +++ b/interface-definitions/include/haproxy/rule-frontend.xml.i @@ -47,9 +47,12 @@ <properties> <help>Backend name</help> <constraint> - <regex>[-_a-zA-Z0-9]+</regex> + #include <include/constraint/alpha-numeric-hyphen-underscore.xml.i> </constraint> <constraintErrorMessage>Server name must be alphanumeric and can contain hyphen and underscores</constraintErrorMessage> + <completionHelp> + <path>load-balancing haproxy backend</path> + </completionHelp> </properties> </leafNode> </children> diff --git a/interface-definitions/include/netlink/log-level.xml.i b/interface-definitions/include/netlink/log-level.xml.i new file mode 100644 index 000000000..bbaf9412c --- /dev/null +++ b/interface-definitions/include/netlink/log-level.xml.i @@ -0,0 +1,21 @@ +<!-- include start from netlink/log-level.xml.i --> +<leafNode name="log-level"> + <properties> + <help>Set log-level</help> + <completionHelp> + <list>info debug</list> + </completionHelp> + <valueHelp> + <format>info</format> + <description>Info log level</description> + </valueHelp> + <valueHelp> + <format>debug</format> + <description>Debug log level</description> + </valueHelp> + <constraint> + <regex>(info|debug)</regex> + </constraint> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/netlink/queue-size.xml.i b/interface-definitions/include/netlink/queue-size.xml.i new file mode 100644 index 000000000..d284838cf --- /dev/null +++ b/interface-definitions/include/netlink/queue-size.xml.i @@ -0,0 +1,15 @@ +<!-- include start from netlink/queue-size.xml.i --> +<leafNode name="queue-size"> + <properties> + <help>Internal message queue size</help> + <valueHelp> + <format>u32:100-2147483647</format> + <description>Queue size</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-2147483647"/> + </constraint> + <constraintErrorMessage>Queue size must be between 100 and 2147483647</constraintErrorMessage> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/version/flow-accounting-version.xml.i b/interface-definitions/include/version/flow-accounting-version.xml.i index 5b01fe4b5..95d1e20db 100644 --- a/interface-definitions/include/version/flow-accounting-version.xml.i +++ b/interface-definitions/include/version/flow-accounting-version.xml.i @@ -1,3 +1,3 @@ <!-- include start from include/version/flow-accounting-version.xml.i --> -<syntaxVersion component='flow-accounting' version='1'></syntaxVersion> +<syntaxVersion component='flow-accounting' version='2'></syntaxVersion> <!-- include end --> diff --git a/interface-definitions/include/version/nhrp-version.xml.i b/interface-definitions/include/version/nhrp-version.xml.i new file mode 100644 index 000000000..7f6f3c4f7 --- /dev/null +++ b/interface-definitions/include/version/nhrp-version.xml.i @@ -0,0 +1,3 @@ +<!-- include start from include/version/nhrp-version.xml.i --> +<syntaxVersion component='nhrp' version='1'></syntaxVersion> +<!-- include end --> diff --git a/interface-definitions/interfaces_wireguard.xml.in b/interface-definitions/interfaces_wireguard.xml.in index ce49de038..4f8b6c751 100644 --- a/interface-definitions/interfaces_wireguard.xml.in +++ b/interface-definitions/interfaces_wireguard.xml.in @@ -40,6 +40,19 @@ </properties> <defaultValue>0</defaultValue> </leafNode> + <leafNode name="max-dns-retry"> + <properties> + <help>DNS retries when resolve fails</help> + <valueHelp> + <format>u32:1-15</format> + <description>Maximum number of retries</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-15"/> + </constraint> + </properties> + <defaultValue>3</defaultValue> + </leafNode> <leafNode name="private-key"> <properties> <help>Base64 encoded private key</help> @@ -104,6 +117,18 @@ </constraint> </properties> </leafNode> + <leafNode name="host-name"> + <properties> + <help>Hostname of tunnel endpoint</help> + <valueHelp> + <format>hostname</format> + <description>FQDN of WireGuard endpoint</description> + </valueHelp> + <constraint> + <validator name="fqdn"/> + </constraint> + </properties> + </leafNode> #include <include/port-number.xml.i> <leafNode name="persistent-keepalive"> <properties> diff --git a/interface-definitions/protocols_nhrp.xml.in b/interface-definitions/protocols_nhrp.xml.in index d7663c095..5304fbd78 100644 --- a/interface-definitions/protocols_nhrp.xml.in +++ b/interface-definitions/protocols_nhrp.xml.in @@ -20,115 +20,163 @@ </valueHelp> </properties> <children> - <leafNode name="cisco-authentication"> + <node name="map"> <properties> - <help>Pass phrase for cisco authentication</help> - <valueHelp> - <format>txt</format> - <description>Pass phrase for cisco authentication</description> - </valueHelp> - <constraint> - <regex>[^[:space:]]{1,8}</regex> - </constraint> - <constraintErrorMessage>Password should contain up to eight non-whitespace characters</constraintErrorMessage> - </properties> - </leafNode> - <tagNode name="dynamic-map"> - <properties> - <help>Set an HUB tunnel address</help> - <valueHelp> - <format>ipv4net</format> - <description>Set the IP address and prefix length</description> - </valueHelp> + <help>Map tunnel IP to NBMA </help> </properties> <children> - <leafNode name="nbma-domain-name"> + <tagNode name ="tunnel-ip"> <properties> - <help>Set HUB fqdn (nbma-address - fqdn)</help> + <help>Set a NHRP tunnel address</help> <valueHelp> - <format><fqdn></format> - <description>Set the external HUB fqdn</description> + <format>ipv4</format> + <description>Set the IP address to map</description> </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> </properties> - </leafNode> + <children> + <leafNode name="nbma"> + <properties> + <help>Set NHRP NBMA address to map</help> + <completionHelp> + <list>local</list> + </completionHelp> + <valueHelp> + <format>ipv4</format> + <description>Set the IP address to map</description> + </valueHelp> + <valueHelp> + <format>local</format> + <description>Set the local address</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + <regex>(local)</regex> + </constraint> + </properties> + </leafNode> + </children> + </tagNode> </children> - </tagNode> - <leafNode name="holding-time"> + </node> + <node name="nhs"> <properties> - <help>Holding time in seconds</help> - </properties> - </leafNode> - <tagNode name="map"> - <properties> - <help>Set an HUB tunnel address</help> + <help>Map tunnel IP to NBMA of Next Hop Server</help> </properties> <children> - <leafNode name="cisco"> - <properties> - <help>If the statically mapped peer is running Cisco IOS, specify this</help> - <valueless/> - </properties> - </leafNode> - <leafNode name="nbma-address"> + <tagNode name ="tunnel-ip"> <properties> - <help>Set HUB address (nbma-address - external hub address or fqdn)</help> - </properties> - </leafNode> - <leafNode name="register"> - <properties> - <help>Specifies that Registration Request should be sent to this peer on startup</help> - <valueless/> + <help>Set a NHRP NHS tunnel address</help> + <completionHelp> + <list>dynamic</list> + </completionHelp> + <valueHelp> + <format>ipv4</format> + <description>Set the IP address to map</description> + </valueHelp> + <valueHelp> + <format>dynamic</format> + <description> Set Next Hop Server to have a dynamic address </description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + <regex>(dynamic)</regex> + </constraint> </properties> - </leafNode> + <children> + <leafNode name="nbma"> + <properties> + <help>Set NHRP NBMA address of NHS</help> + <valueHelp> + <format>ipv4</format> + <description>Set the IP address to map</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> + <multi/> + </properties> + </leafNode> + </children> + </tagNode> </children> - </tagNode> + </node> <leafNode name="multicast"> <properties> - <help>Set multicast for NHRP</help> + <help>Map multicast to NBMA</help> <completionHelp> - <list>dynamic nhs</list> + <list>dynamic</list> </completionHelp> + <valueHelp> + <format>ipv4</format> + <description>Set the IP address to map(IP|FQDN)</description> + </valueHelp> + <valueHelp> + <format>dynamic</format> + <description>NBMA address is learnt dynamically</description> + </valueHelp> <constraint> - <regex>(dynamic|nhs)</regex> + <validator name="ip-address"/> + <regex>(dynamic)</regex> </constraint> + <multi/> </properties> </leafNode> - <leafNode name="non-caching"> + <leafNode name="registration-no-unique"> <properties> - <help>This can be used to reduce memory consumption on big NBMA subnets</help> + <help>Don't set unique flag</help> <valueless/> </properties> </leafNode> - <leafNode name="redirect"> + <leafNode name="authentication"> <properties> - <help>Enable sending of Cisco style NHRP Traffic Indication packets</help> - <valueless/> + <help>NHRP authentication</help> + <valueHelp> + <format>txt</format> + <description>Pass phrase for NHRP authentication</description> + </valueHelp> + <constraint> + <regex>[^[:space:]]{1,8}</regex> + </constraint> + <constraintErrorMessage>Password should contain up to eight non-whitespace characters</constraintErrorMessage> </properties> </leafNode> - <leafNode name="shortcut-destination"> + <leafNode name="holdtime"> <properties> - <help>This instructs opennhrp to reply with authorative answers on NHRP Resolution Requests destined to addresses in this interface</help> - <valueless/> + <help>Holding time in seconds</help> + <valueHelp> + <format>u32:1-65000</format> + <description>ring buffer size</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-65000"/> + </constraint> </properties> </leafNode> - <tagNode name="shortcut-target"> + <leafNode name="redirect"> <properties> - <help>Defines an off-NBMA network prefix for which the GRE interface will act as a gateway</help> + <help>Enable sending of Cisco style NHRP Traffic Indication packets</help> + <valueless/> </properties> - <children> - <leafNode name="holding-time"> - <properties> - <help>Holding time in seconds</help> - </properties> - </leafNode> - </children> - </tagNode> + </leafNode> <leafNode name="shortcut"> <properties> <help>Enable creation of shortcut routes. A received NHRP Traffic Indication will trigger the resolution and establishment of a shortcut route</help> <valueless/> </properties> </leafNode> + #include <include/interface/mtu-68-16000.xml.i> + <leafNode name="network-id"> + <properties> + <help>NHRP network id</help> + <valueHelp> + <format><1-4294967295></format> + <description>NHRP network id</description> + </valueHelp> + </properties> + </leafNode> </children> </tagNode> </children> diff --git a/interface-definitions/service_monitoring_network_event.xml.in b/interface-definitions/service_monitoring_network_event.xml.in new file mode 100644 index 000000000..edf23a06a --- /dev/null +++ b/interface-definitions/service_monitoring_network_event.xml.in @@ -0,0 +1,61 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="service"> + <children> + <node name="monitoring"> + <properties> + <help>Monitoring services</help> + </properties> + <children> + <node name="network-event" owner="${vyos_conf_scripts_dir}/service_monitoring_network_event.py"> + <properties> + <help>Network event logger</help> + <priority>1280</priority> + </properties> + <children> + <node name="event"> + <properties> + <help>Network event type</help> + </properties> + <children> + <leafNode name="route"> + <properties> + <help>Log routing table update events</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="link"> + <properties> + <help>Log link status change events</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="addr"> + <properties> + <help>Log address assignment and removal events</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="neigh"> + <properties> + <help>Log neighbor (ARP/ND) table update events</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="rule"> + <properties> + <help>Log policy routing rule change events</help> + <valueless/> + </properties> + </leafNode> + </children> + </node> + #include <include/netlink/queue-size.xml.i> + #include <include/netlink/log-level.xml.i> + </children> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/interface-definitions/system_conntrack.xml.in b/interface-definitions/system_conntrack.xml.in index cd59d1308..54610b625 100644 --- a/interface-definitions/system_conntrack.xml.in +++ b/interface-definitions/system_conntrack.xml.in @@ -263,38 +263,8 @@ <valueless/> </properties> </leafNode> - <leafNode name="queue-size"> - <properties> - <help>Internal message queue size</help> - <valueHelp> - <format>u32:100-999999</format> - <description>Queue size</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 1-999999"/> - </constraint> - <constraintErrorMessage>Queue size must be between 100 and 999999</constraintErrorMessage> - </properties> - </leafNode> - <leafNode name="log-level"> - <properties> - <help>Set log-level. Log must be enable.</help> - <completionHelp> - <list>info debug</list> - </completionHelp> - <valueHelp> - <format>info</format> - <description>Info log level</description> - </valueHelp> - <valueHelp> - <format>debug</format> - <description>Debug log level</description> - </valueHelp> - <constraint> - <regex>(info|debug)</regex> - </constraint> - </properties> - </leafNode> + #include <include/netlink/queue-size.xml.i> + #include <include/netlink/log-level.xml.i> </children> </node> <node name="modules"> diff --git a/interface-definitions/system_flow-accounting.xml.in b/interface-definitions/system_flow-accounting.xml.in index 83a2480a3..4799205ad 100644 --- a/interface-definitions/system_flow-accounting.xml.in +++ b/interface-definitions/system_flow-accounting.xml.in @@ -362,73 +362,6 @@ </node> </children> </node> - <node name="sflow"> - <properties> - <help>sFlow settings</help> - </properties> - <children> - <leafNode name="agent-address"> - <properties> - <help>sFlow agent IPv4 address</help> - <completionHelp> - <list>auto</list> - <script>${vyos_completion_dir}/list_local_ips.sh --ipv4</script> - </completionHelp> - <valueHelp> - <format>ipv4</format> - <description>sFlow IPv4 agent address</description> - </valueHelp> - <constraint> - <validator name="ipv4-address"/> - </constraint> - </properties> - </leafNode> - <leafNode name="sampling-rate"> - <properties> - <help>sFlow sampling-rate</help> - <valueHelp> - <format>u32</format> - <description>Sampling rate (1 in N packets)</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 0-4294967295"/> - </constraint> - </properties> - </leafNode> - <tagNode name="server"> - <properties> - <help>sFlow destination server</help> - <valueHelp> - <format>ipv4</format> - <description>IPv4 server to export sFlow</description> - </valueHelp> - <valueHelp> - <format>ipv6</format> - <description>IPv6 server to export sFlow</description> - </valueHelp> - <constraint> - <validator name="ip-address"/> - </constraint> - </properties> - <children> - <leafNode name="port"> - <properties> - <help>sFlow port number</help> - <valueHelp> - <format>u32:1025-65535</format> - <description>sFlow port number</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 1025-65535"/> - </constraint> - </properties> - <defaultValue>6343</defaultValue> - </leafNode> - </children> - </tagNode> - #include <include/source-address-ipv4-ipv6.xml.i> - </children> - </node> #include <include/interface/vrf.xml.i> </children> </node> diff --git a/interface-definitions/system_sflow.xml.in b/interface-definitions/system_sflow.xml.in index aaf4033d8..2cd7a5d12 100644 --- a/interface-definitions/system_sflow.xml.in +++ b/interface-definitions/system_sflow.xml.in @@ -106,6 +106,12 @@ </leafNode> </children> </tagNode> + <leafNode name="enable-egress"> + <properties> + <help>Enable egress sampling</help> + <valueless/> + </properties> + </leafNode> #include <include/interface/vrf.xml.i> </children> </node> diff --git a/interface-definitions/xml-component-version.xml.in b/interface-definitions/xml-component-version.xml.in index 67d86a1d0..70957c5fa 100644 --- a/interface-definitions/xml-component-version.xml.in +++ b/interface-definitions/xml-component-version.xml.in @@ -1,52 +1,4 @@ <?xml version="1.0"?> <interfaceDefinition> - #include <include/version/bgp-version.xml.i> - #include <include/version/broadcast-relay-version.xml.i> - #include <include/version/cluster-version.xml.i> - #include <include/version/config-management-version.xml.i> - #include <include/version/conntrack-sync-version.xml.i> - #include <include/version/conntrack-version.xml.i> - #include <include/version/container-version.xml.i> - #include <include/version/dhcp-relay-version.xml.i> - #include <include/version/dhcp-server-version.xml.i> - #include <include/version/dhcpv6-server-version.xml.i> - #include <include/version/dns-dynamic-version.xml.i> - #include <include/version/dns-forwarding-version.xml.i> - #include <include/version/firewall-version.xml.i> - #include <include/version/flow-accounting-version.xml.i> - #include <include/version/https-version.xml.i> - #include <include/version/interfaces-version.xml.i> - #include <include/version/ids-version.xml.i> - #include <include/version/ipoe-server-version.xml.i> - #include <include/version/ipsec-version.xml.i> - #include <include/version/openvpn-version.xml.i> - #include <include/version/isis-version.xml.i> - #include <include/version/l2tp-version.xml.i> - #include <include/version/lldp-version.xml.i> - #include <include/version/mdns-version.xml.i> - #include <include/version/monitoring-version.xml.i> - #include <include/version/nat66-version.xml.i> - #include <include/version/nat-version.xml.i> - #include <include/version/ntp-version.xml.i> - #include <include/version/openconnect-version.xml.i> - #include <include/version/ospf-version.xml.i> - #include <include/version/pim-version.xml.i> - #include <include/version/policy-version.xml.i> - #include <include/version/pppoe-server-version.xml.i> - #include <include/version/pptp-version.xml.i> - #include <include/version/qos-version.xml.i> - #include <include/version/quagga-version.xml.i> - #include <include/version/rip-version.xml.i> - #include <include/version/rpki-version.xml.i> - #include <include/version/salt-version.xml.i> - #include <include/version/snmp-version.xml.i> - #include <include/version/ssh-version.xml.i> - #include <include/version/sstp-version.xml.i> - #include <include/version/system-version.xml.i> - #include <include/version/vrf-version.xml.i> - #include <include/version/vrrp-version.xml.i> - #include <include/version/vyos-accel-ppp-version.xml.i> - #include <include/version/wanloadbalance-version.xml.i> - #include <include/version/webproxy-version.xml.i> - #include <include/version/reverseproxy-version.xml.i> + #include <include/version/*> </interfaceDefinition> diff --git a/op-mode-definitions/dhcp.xml.in b/op-mode-definitions/dhcp.xml.in index 63b1f62bb..4ee66a90c 100644 --- a/op-mode-definitions/dhcp.xml.in +++ b/op-mode-definitions/dhcp.xml.in @@ -140,7 +140,7 @@ <properties> <help>Show DHCP server statistics</help> </properties> - <command>${vyos_op_scripts_dir}/dhcp.py show_pool_statistics --family inet</command> + <command>${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet</command> <children> <tagNode name="pool"> <properties> @@ -149,7 +149,7 @@ <path>service dhcp-server shared-network-name</path> </completionHelp> </properties> - <command>${vyos_op_scripts_dir}/dhcp.py show_pool_statistics --family inet --pool $6</command> + <command>${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --pool $6</command> </tagNode> </children> </node> @@ -232,7 +232,7 @@ <properties> <help>Show DHCPv6 server statistics</help> </properties> - <command>${vyos_op_scripts_dir}/dhcp.py show_pool_statistics --family inet6</command> + <command>${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6</command> <children> <tagNode name="pool"> <properties> @@ -241,7 +241,7 @@ <path>service dhcpv6-server shared-network-name</path> </completionHelp> </properties> - <command>${vyos_op_scripts_dir}/dhcp.py show_pool_statistics --family inet6 --pool $6</command> + <command>${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --pool $6</command> </tagNode> </children> </node> diff --git a/op-mode-definitions/include/log/network-event-type-interface.xml.i b/op-mode-definitions/include/log/network-event-type-interface.xml.i new file mode 100644 index 000000000..2d781223c --- /dev/null +++ b/op-mode-definitions/include/log/network-event-type-interface.xml.i @@ -0,0 +1,11 @@ +<!-- included start from network-event-type-interface.xml.i --> +<tagNode name="interface"> + <properties> + <help>Show log for specific interface</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces</script> + </completionHelp> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service | grep "$(echo "\[$4\]" | tr '[:lower:]' '[:upper:]')" | grep "\b$6\b"</command> +</tagNode> +<!-- included end --> diff --git a/op-mode-definitions/include/show-interface-type-event-log.xml.i b/op-mode-definitions/include/show-interface-type-event-log.xml.i new file mode 100644 index 000000000..c69073fda --- /dev/null +++ b/op-mode-definitions/include/show-interface-type-event-log.xml.i @@ -0,0 +1,40 @@ +<!-- included start from show-interface-type-event-log.xml.i --> +<node name="event-log"> + <properties> + <help>Show network interface change event log</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b"</command> + <children> + <leafNode name="route"> + <properties> + <help>Show log for route events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]"</command> + </leafNode> + <leafNode name="link"> + <properties> + <help>Show log for network link events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]"</command> + </leafNode> + <leafNode name="addr"> + <properties> + <help>Show log for network address events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]"</command> + </leafNode> + <leafNode name="neigh"> + <properties> + <help>Show log for neighbor table events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]"</command> + </leafNode> + <leafNode name="rule"> + <properties> + <help>Show log for PBR rule change events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]"</command> + </leafNode> + </children> +</node> +<!-- included end --> diff --git a/op-mode-definitions/nhrp.xml.in b/op-mode-definitions/nhrp.xml.in index 11a4b8814..4ae1972c6 100644 --- a/op-mode-definitions/nhrp.xml.in +++ b/op-mode-definitions/nhrp.xml.in @@ -2,38 +2,26 @@ <interfaceDefinition> <node name="reset"> <children> - <node name="nhrp"> - <properties> - <help>Clear/Purge NHRP entries</help> - </properties> + <node name="ip"> <children> - <node name="flush"> + <node name="nhrp"> <properties> - <help>Clear all non-permanent entries</help> + <help>Clear/Purge NHRP entries</help> </properties> <children> - <tagNode name="tunnel"> + <leafNode name="cache"> <properties> - <help>Clear all non-permanent entries</help> + <help>Clear Dynamic cache entries</help> </properties> - <command>sudo opennhrpctl flush dev $5 || echo OpenNHRP is not running.</command> - </tagNode> - </children> - <command>sudo opennhrpctl flush || echo OpenNHRP is not running.</command> - </node> - <node name="purge"> - <properties> - <help>Purge entries from NHRP cache</help> - </properties> - <children> - <tagNode name="tunnel"> + <command>${vyos_op_scripts_dir}/vtysh_wrapper.sh $@</command> + </leafNode> + <leafNode name="shortcut"> <properties> - <help>Purge all entries from NHRP cache</help> + <help>Clear Shortcut entries</help> </properties> - <command>sudo opennhrpctl purge dev $5 || echo OpenNHRP is not running.</command> - </tagNode> + <command>${vyos_op_scripts_dir}/vtysh_wrapper.sh $@</command> + </leafNode> </children> - <command>sudo opennhrpctl purge || echo OpenNHRP is not running.</command> </node> </children> </node> @@ -41,25 +29,38 @@ </node> <node name="show"> <children> - <node name="nhrp"> + <node name="ip"> <properties> - <help>Show NHRP (Next Hop Resolution Protocol) information</help> + <help>Show IPv4 routing information</help> </properties> <children> - <leafNode name="interface"> + <node name="nhrp"> <properties> - <help>Show NHRP interface connection information</help> + <help>Show NHRP (Next Hop Resolution Protocol) information</help> </properties> - <command>${vyos_op_scripts_dir}/nhrp.py show_interface</command> - </leafNode> - <leafNode name="tunnel"> - <properties> - <help>Show NHRP tunnel connection information</help> - </properties> - <command>${vyos_op_scripts_dir}/nhrp.py show_tunnel</command> - </leafNode> + <children> + <leafNode name="cache"> + <properties> + <help>Forwarding cache information</help> + </properties> + <command>${vyos_op_scripts_dir}/vtysh_wrapper.sh $@</command> + </leafNode> + <leafNode name="nhs"> + <properties> + <help>Next hop server information</help> + </properties> + <command>${vyos_op_scripts_dir}/vtysh_wrapper.sh $@</command> + </leafNode> + <leafNode name="shortcut"> + <properties> + <help>Shortcut information</help> + </properties> + <command>${vyos_op_scripts_dir}/vtysh_wrapper.sh $@</command> + </leafNode> + </children> + </node> </children> </node> - </children> + </children> </node> </interfaceDefinition> diff --git a/op-mode-definitions/reset-wireguard.xml.in b/op-mode-definitions/reset-wireguard.xml.in new file mode 100644 index 000000000..c2243f519 --- /dev/null +++ b/op-mode-definitions/reset-wireguard.xml.in @@ -0,0 +1,34 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="reset"> + <children> + <node name="wireguard"> + <properties> + <help>Reset WireGuard Peers</help> + </properties> + <children> + <tagNode name="interface"> + <properties> + <help>WireGuard interface name</help> + <completionHelp> + <path>interfaces wireguard</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4"</command> + <children> + <tagNode name="peer"> + <properties> + <help>WireGuard peer name</help> + <completionHelp> + <path>interfaces wireguard ${COMP_WORDS[3]} peer</path> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4" --peer="$6"</command> + </tagNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/op-mode-definitions/show-interfaces-bonding.xml.in b/op-mode-definitions/show-interfaces-bonding.xml.in index e2950331b..0abb7cd5a 100644 --- a/op-mode-definitions/show-interfaces-bonding.xml.in +++ b/op-mode-definitions/show-interfaces-bonding.xml.in @@ -67,6 +67,7 @@ </leafNode> </children> </tagNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="bonding"> diff --git a/op-mode-definitions/show-interfaces-bridge.xml.in b/op-mode-definitions/show-interfaces-bridge.xml.in index dc813682d..998dacd38 100644 --- a/op-mode-definitions/show-interfaces-bridge.xml.in +++ b/op-mode-definitions/show-interfaces-bridge.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=bridge</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="bridge"> diff --git a/op-mode-definitions/show-interfaces-dummy.xml.in b/op-mode-definitions/show-interfaces-dummy.xml.in index b8ec7da91..18f21e97e 100644 --- a/op-mode-definitions/show-interfaces-dummy.xml.in +++ b/op-mode-definitions/show-interfaces-dummy.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=dummy</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="dummy"> diff --git a/op-mode-definitions/show-interfaces-ethernet.xml.in b/op-mode-definitions/show-interfaces-ethernet.xml.in index 09f0b3933..8a23455bf 100644 --- a/op-mode-definitions/show-interfaces-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-ethernet.xml.in @@ -68,6 +68,7 @@ </leafNode> </children> </tagNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="ethernet"> diff --git a/op-mode-definitions/show-interfaces-geneve.xml.in b/op-mode-definitions/show-interfaces-geneve.xml.in index d3d188031..b5fe84ca7 100644 --- a/op-mode-definitions/show-interfaces-geneve.xml.in +++ b/op-mode-definitions/show-interfaces-geneve.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=geneve</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="geneve"> diff --git a/op-mode-definitions/show-interfaces-input.xml.in b/op-mode-definitions/show-interfaces-input.xml.in index e5d420056..c9856f77f 100644 --- a/op-mode-definitions/show-interfaces-input.xml.in +++ b/op-mode-definitions/show-interfaces-input.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=input</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="input"> diff --git a/op-mode-definitions/show-interfaces-l2tpv3.xml.in b/op-mode-definitions/show-interfaces-l2tpv3.xml.in index 2d165171c..88b73d7d7 100644 --- a/op-mode-definitions/show-interfaces-l2tpv3.xml.in +++ b/op-mode-definitions/show-interfaces-l2tpv3.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=l2tpv3</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="l2tpv3"> diff --git a/op-mode-definitions/show-interfaces-loopback.xml.in b/op-mode-definitions/show-interfaces-loopback.xml.in index d341a6359..467e1a13d 100644 --- a/op-mode-definitions/show-interfaces-loopback.xml.in +++ b/op-mode-definitions/show-interfaces-loopback.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=loopback</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="loopback"> diff --git a/op-mode-definitions/show-interfaces-macsec.xml.in b/op-mode-definitions/show-interfaces-macsec.xml.in index 28264d252..640031b77 100644 --- a/op-mode-definitions/show-interfaces-macsec.xml.in +++ b/op-mode-definitions/show-interfaces-macsec.xml.in @@ -29,6 +29,9 @@ </completionHelp> </properties> <command>ip macsec show $4</command> + <children> + #include <include/show-interface-type-event-log.xml.i> + </children> </tagNode> </children> </node> diff --git a/op-mode-definitions/show-interfaces-pppoe.xml.in b/op-mode-definitions/show-interfaces-pppoe.xml.in index 1c6e0b83e..c1f502cb3 100644 --- a/op-mode-definitions/show-interfaces-pppoe.xml.in +++ b/op-mode-definitions/show-interfaces-pppoe.xml.in @@ -28,6 +28,7 @@ </properties> <command>if [ -d "/sys/class/net/$4" ]; then /usr/sbin/pppstats "$4"; fi</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="pppoe"> diff --git a/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in b/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in index 4ab2a5fbb..a9e4257ce 100644 --- a/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=pseudo-ethernet</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="pseudo-ethernet"> diff --git a/op-mode-definitions/show-interfaces-sstpc.xml.in b/op-mode-definitions/show-interfaces-sstpc.xml.in index 307276f72..3bd7a8247 100644 --- a/op-mode-definitions/show-interfaces-sstpc.xml.in +++ b/op-mode-definitions/show-interfaces-sstpc.xml.in @@ -28,6 +28,7 @@ </properties> <command>if [ -d "/sys/class/net/$4" ]; then /usr/sbin/pppstats "$4"; fi</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="sstpc"> diff --git a/op-mode-definitions/show-interfaces-tunnel.xml.in b/op-mode-definitions/show-interfaces-tunnel.xml.in index b99b0cbb2..579b173cb 100644 --- a/op-mode-definitions/show-interfaces-tunnel.xml.in +++ b/op-mode-definitions/show-interfaces-tunnel.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=tunnel</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="tunnel"> diff --git a/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in b/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in index 18ae806b7..4112a17af 100644 --- a/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=virtual-ethernet</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="virtual-ethernet"> diff --git a/op-mode-definitions/show-interfaces-vti.xml.in b/op-mode-definitions/show-interfaces-vti.xml.in index ae5cfeb9c..d13b3e7cc 100644 --- a/op-mode-definitions/show-interfaces-vti.xml.in +++ b/op-mode-definitions/show-interfaces-vti.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=vti</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="vti"> diff --git a/op-mode-definitions/show-interfaces-vxlan.xml.in b/op-mode-definitions/show-interfaces-vxlan.xml.in index fd729b986..89c8d075b 100644 --- a/op-mode-definitions/show-interfaces-vxlan.xml.in +++ b/op-mode-definitions/show-interfaces-vxlan.xml.in @@ -19,6 +19,7 @@ </properties> <command>${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=vxlan</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="vxlan"> diff --git a/op-mode-definitions/show-interfaces-wireguard.xml.in b/op-mode-definitions/show-interfaces-wireguard.xml.in index 0e61ccd74..d86152a21 100644 --- a/op-mode-definitions/show-interfaces-wireguard.xml.in +++ b/op-mode-definitions/show-interfaces-wireguard.xml.in @@ -43,6 +43,7 @@ </properties> <command>sudo ${vyos_op_scripts_dir}/interfaces_wireguard.py show_summary --intf-name="$4"</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="wireguard"> diff --git a/op-mode-definitions/show-interfaces-wireless.xml.in b/op-mode-definitions/show-interfaces-wireless.xml.in index 09c9a7895..b0a1502de 100644 --- a/op-mode-definitions/show-interfaces-wireless.xml.in +++ b/op-mode-definitions/show-interfaces-wireless.xml.in @@ -73,6 +73,7 @@ </leafNode> </children> </tagNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> </children> diff --git a/op-mode-definitions/show-interfaces-wwan.xml.in b/op-mode-definitions/show-interfaces-wwan.xml.in index 3682282a3..2301b32d0 100644 --- a/op-mode-definitions/show-interfaces-wwan.xml.in +++ b/op-mode-definitions/show-interfaces-wwan.xml.in @@ -80,6 +80,7 @@ </properties> <command>echo not implemented</command> </leafNode> + #include <include/show-interface-type-event-log.xml.i> </children> </tagNode> <node name="wwan"> diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index 7ace50cc9..5ee7c973f 100755 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -958,6 +958,68 @@ </properties> <command>journalctl --no-hostname --boot --unit squid.service</command> </leafNode> + <node name="network-event"> + <properties> + <help>Show log for network events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service</command> + <children> + <tagNode name="interface"> + <properties> + <help>Show log for specific interface</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces</script> + </completionHelp> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep $5</command> + </tagNode> + <node name="route"> + <properties> + <help>Show log for route events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]"</command> + <children> + #include <include/log/network-event-type-interface.xml.i> + </children> + </node> + <node name="link"> + <properties> + <help>Show log for network link events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]"</command> + <children> + #include <include/log/network-event-type-interface.xml.i> + </children> + </node> + <node name="addr"> + <properties> + <help>Show log for network address events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]"</command> + <children> + #include <include/log/network-event-type-interface.xml.i> + </children> + </node> + <node name="neigh"> + <properties> + <help>Show log for neighbor table events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]"</command> + <children> + #include <include/log/network-event-type-interface.xml.i> + </children> + </node> + <node name="rule"> + <properties> + <help>Show log for PBR rule change events</help> + </properties> + <command>journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]"</command> + <children> + #include <include/log/network-event-type-interface.xml.i> + </children> + </node> + </children> + </node> </children> </node> </children> diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 5d6ca9be9..4c4ead0a3 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -120,11 +120,14 @@ class ConfigTreeQuery(GenericConfigQuery): def get_config_dict(self, path=[], effective=False, key_mangling=None, get_first_key=False, no_multi_convert=False, - no_tag_node_value_mangle=False): + no_tag_node_value_mangle=False, with_defaults=False, + with_recursive_defaults=False): return self.config.get_config_dict(path, effective=effective, key_mangling=key_mangling, get_first_key=get_first_key, no_multi_convert=no_multi_convert, - no_tag_node_value_mangle=no_tag_node_value_mangle) + no_tag_node_value_mangle=no_tag_node_value_mangle, + with_defaults=with_defaults, + with_recursive_defaults=with_recursive_defaults) class VbashOpRun(GenericOpRun): def __init__(self): diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index fb79e8459..8d27a7e46 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -1,5 +1,5 @@ # configtree -- a standalone VyOS config file manipulation library (Python bindings) -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; @@ -21,33 +21,40 @@ from ctypes import cdll, c_char_p, c_void_p, c_int, c_bool LIBPATH = '/usr/lib/libvyosconfig.so.0' + def replace_backslash(s, search, replace): """Modify quoted strings containing backslashes not of escape sequences""" + def replace_method(match): result = match.group().replace(search, replace) return result + p = re.compile(r'("[^"]*[\\][^"]*"\n|\'[^\']*[\\][^\']*\'\n)') return p.sub(replace_method, s) + def escape_backslash(string: str) -> str: """Escape single backslashes in quoted strings""" result = replace_backslash(string, '\\', '\\\\') return result + def unescape_backslash(string: str) -> str: """Unescape backslashes in quoted strings""" result = replace_backslash(string, '\\\\', '\\') return result + def extract_version(s): - """ Extract the version string from the config string """ + """Extract the version string from the config string""" t = re.split('(^//)', s, maxsplit=1, flags=re.MULTILINE) return (s, ''.join(t[1:])) + def check_path(path): # Necessary type checking if not isinstance(path, list): - raise TypeError("Expected a list, got a {}".format(type(path))) + raise TypeError('Expected a list, got a {}'.format(type(path))) else: pass @@ -165,7 +172,7 @@ class ConfigTree(object): config = self.__from_string(config_section.encode()) if config is None: msg = self.__get_error().decode() - raise ValueError("Failed to parse config: {0}".format(msg)) + raise ValueError('Failed to parse config: {0}'.format(msg)) else: self.__config = config self.__version = version_section @@ -195,10 +202,10 @@ class ConfigTree(object): config_string = unescape_backslash(config_string) if no_version: return config_string - config_string = "{0}\n{1}".format(config_string, self.__version) + config_string = '{0}\n{1}'.format(config_string, self.__version) return config_string - def to_commands(self, op="set"): + def to_commands(self, op='set'): commands = self.__to_commands(self.__config, op.encode()).decode() commands = unescape_backslash(commands) return commands @@ -211,11 +218,11 @@ class ConfigTree(object): def create_node(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__create_node(self.__config, path_str) - if (res != 0): - raise ConfigTreeError(f"Path already exists: {path}") + if res != 0: + raise ConfigTreeError(f'Path already exists: {path}') def set(self, path, value=None, replace=True): """Set new entry in VyOS configuration. @@ -227,7 +234,7 @@ class ConfigTree(object): """ check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() if value is None: self.__set_valueless(self.__config, path_str) @@ -238,25 +245,27 @@ class ConfigTree(object): self.__set_add_value(self.__config, path_str, str(value).encode()) if self.__migration: - self.migration_log.info(f"- op: set path: {path} value: {value} replace: {replace}") + self.migration_log.info( + f'- op: set path: {path} value: {value} replace: {replace}' + ) def delete(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__delete(self.__config, path_str) - if (res != 0): + if res != 0: raise ConfigTreeError(f"Path doesn't exist: {path}") if self.__migration: - self.migration_log.info(f"- op: delete path: {path}") + self.migration_log.info(f'- op: delete path: {path}') def delete_value(self, path, value): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__delete_value(self.__config, path_str, value.encode()) - if (res != 0): + if res != 0: if res == 1: raise ConfigTreeError(f"Path doesn't exist: {path}") elif res == 2: @@ -265,11 +274,11 @@ class ConfigTree(object): raise ConfigTreeError() if self.__migration: - self.migration_log.info(f"- op: delete_value path: {path} value: {value}") + self.migration_log.info(f'- op: delete_value path: {path} value: {value}') def rename(self, path, new_name): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() newname_str = new_name.encode() # Check if a node with intended new name already exists @@ -277,42 +286,46 @@ class ConfigTree(object): if self.exists(new_path): raise ConfigTreeError() res = self.__rename(self.__config, path_str, newname_str) - if (res != 0): + if res != 0: raise ConfigTreeError("Path [{}] doesn't exist".format(path)) if self.__migration: - self.migration_log.info(f"- op: rename old_path: {path} new_path: {new_path}") + self.migration_log.info( + f'- op: rename old_path: {path} new_path: {new_path}' + ) def copy(self, old_path, new_path): check_path(old_path) check_path(new_path) - oldpath_str = " ".join(map(str, old_path)).encode() - newpath_str = " ".join(map(str, new_path)).encode() + oldpath_str = ' '.join(map(str, old_path)).encode() + newpath_str = ' '.join(map(str, new_path)).encode() # Check if a node with intended new name already exists if self.exists(new_path): raise ConfigTreeError() res = self.__copy(self.__config, oldpath_str, newpath_str) - if (res != 0): + if res != 0: msg = self.__get_error().decode() raise ConfigTreeError(msg) if self.__migration: - self.migration_log.info(f"- op: copy old_path: {old_path} new_path: {new_path}") + self.migration_log.info( + f'- op: copy old_path: {old_path} new_path: {new_path}' + ) def exists(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__exists(self.__config, path_str) - if (res == 0): + if res == 0: return False else: return True def list_nodes(self, path, path_must_exist=True): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__list_nodes(self.__config, path_str).decode() res = json.loads(res_json) @@ -327,7 +340,7 @@ class ConfigTree(object): def return_value(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__return_value(self.__config, path_str).decode() res = json.loads(res_json) @@ -339,7 +352,7 @@ class ConfigTree(object): def return_values(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res_json = self.__return_values(self.__config, path_str).decode() res = json.loads(res_json) @@ -351,61 +364,62 @@ class ConfigTree(object): def is_tag(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__is_tag(self.__config, path_str) - if (res >= 1): + if res >= 1: return True else: return False def set_tag(self, path, value=True): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__set_tag(self.__config, path_str, value) - if (res == 0): + if res == 0: return True else: raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) def is_leaf(self, path): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() return self.__is_leaf(self.__config, path_str) def set_leaf(self, path, value): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__set_leaf(self.__config, path_str, value) - if (res == 0): + if res == 0: return True else: raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) def get_subtree(self, path, with_node=False): check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__get_subtree(self.__config, path_str, with_node) subt = ConfigTree(address=res) return subt + def show_diff(left, right, path=[], commands=False, libpath=LIBPATH): if left is None: left = ConfigTree(config_string='\n') if right is None: right = ConfigTree(config_string='\n') if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): - raise TypeError("Arguments must be instances of ConfigTree") + raise TypeError('Arguments must be instances of ConfigTree') if path: if (not left.exists(path)) and (not right.exists(path)): raise ConfigTreeError(f"Path {path} doesn't exist") check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() __lib = cdll.LoadLibrary(libpath) __show_diff = __lib.show_diff @@ -417,20 +431,21 @@ def show_diff(left, right, path=[], commands=False, libpath=LIBPATH): res = __show_diff(commands, path_str, left._get_config(), right._get_config()) res = res.decode() - if res == "#1@": + if res == '#1@': msg = __get_error().decode() raise ConfigTreeError(msg) res = unescape_backslash(res) return res + def union(left, right, libpath=LIBPATH): if left is None: left = ConfigTree(config_string='\n') if right is None: right = ConfigTree(config_string='\n') if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): - raise TypeError("Arguments must be instances of ConfigTree") + raise TypeError('Arguments must be instances of ConfigTree') __lib = cdll.LoadLibrary(libpath) __tree_union = __lib.tree_union @@ -440,14 +455,15 @@ def union(left, right, libpath=LIBPATH): __get_error.argtypes = [] __get_error.restype = c_char_p - res = __tree_union( left._get_config(), right._get_config()) + res = __tree_union(left._get_config(), right._get_config()) tree = ConfigTree(address=res) return tree + def mask_inclusive(left, right, libpath=LIBPATH): if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): - raise TypeError("Arguments must be instances of ConfigTree") + raise TypeError('Arguments must be instances of ConfigTree') try: __lib = cdll.LoadLibrary(libpath) @@ -469,7 +485,8 @@ def mask_inclusive(left, right, libpath=LIBPATH): return tree -def reference_tree_to_json(from_dir, to_file, internal_cache="", libpath=LIBPATH): + +def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH): try: __lib = cdll.LoadLibrary(libpath) __reference_tree_to_json = __lib.reference_tree_to_json @@ -477,13 +494,66 @@ def reference_tree_to_json(from_dir, to_file, internal_cache="", libpath=LIBPATH __get_error = __lib.get_error __get_error.argtypes = [] __get_error.restype = c_char_p - res = __reference_tree_to_json(internal_cache.encode(), from_dir.encode(), to_file.encode()) + res = __reference_tree_to_json( + internal_cache.encode(), from_dir.encode(), to_file.encode() + ) except Exception as e: raise ConfigTreeError(e) if res == 1: msg = __get_error().decode() raise ConfigTreeError(msg) + +def merge_reference_tree_cache(cache_dir, primary_name, result_name, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __merge_reference_tree_cache = __lib.merge_reference_tree_cache + __merge_reference_tree_cache.argtypes = [c_char_p, c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __merge_reference_tree_cache( + cache_dir.encode(), primary_name.encode(), result_name.encode() + ) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + +def interface_definitions_to_cache(from_dir, cache_path, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __interface_definitions_to_cache = __lib.interface_definitions_to_cache + __interface_definitions_to_cache.argtypes = [c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __interface_definitions_to_cache(from_dir.encode(), cache_path.encode()) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + +def reference_tree_cache_to_json(cache_path, render_file, libpath=LIBPATH): + try: + __lib = cdll.LoadLibrary(libpath) + __reference_tree_cache_to_json = __lib.reference_tree_cache_to_json + __reference_tree_cache_to_json.argtypes = [c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __reference_tree_cache_to_json(cache_path.encode(), render_file.encode()) + except Exception as e: + raise ConfigTreeError(e) + if res == 1: + msg = __get_error().decode() + raise ConfigTreeError(msg) + + class DiffTree: def __init__(self, left, right, path=[], libpath=LIBPATH): if left is None: @@ -491,7 +561,7 @@ class DiffTree: if right is None: right = ConfigTree(config_string='\n') if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)): - raise TypeError("Arguments must be instances of ConfigTree") + raise TypeError('Arguments must be instances of ConfigTree') if path: if not left.exists(path): raise ConfigTreeError(f"Path {path} doesn't exist in lhs tree") @@ -508,7 +578,7 @@ class DiffTree: self.__diff_tree.restype = c_void_p check_path(path) - path_str = " ".join(map(str, path)).encode() + path_str = ' '.join(map(str, path)).encode() res = self.__diff_tree(path_str, left._get_config(), right._get_config()) @@ -524,11 +594,11 @@ class DiffTree: def to_commands(self): add = self.add.to_commands() - delete = self.delete.to_commands(op="delete") - return delete + "\n" + add + delete = self.delete.to_commands(op='delete') + return delete + '\n' + add + def deep_copy(config_tree: ConfigTree) -> ConfigTree: - """An inelegant, but reasonably fast, copy; replace with backend copy - """ + """An inelegant, but reasonably fast, copy; replace with backend copy""" D = DiffTree(None, config_tree) return D.add diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index 544983b2c..ba44978d1 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2024-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -52,6 +52,7 @@ pim6_daemon = 'pim6d' rip_daemon = 'ripd' ripng_daemon = 'ripngd' zebra_daemon = 'zebra' +nhrp_daemon = 'nhrpd' def get_frrender_dict(conf, argv=None) -> dict: from copy import deepcopy @@ -147,6 +148,50 @@ def get_frrender_dict(conf, argv=None) -> dict: pim = config_dict_merge(default_values, pim) return pim + def dict_helper_nhrp_defaults(nhrp): + # NFLOG group numbers which are used in netfilter firewall rules and + # in the global config in FRR. + # https://docs.frrouting.org/en/latest/nhrpd.html#hub-functionality + # https://docs.frrouting.org/en/latest/nhrpd.html#multicast-functionality + # Use nflog group number for NHRP redirects = 1 + # Use nflog group number from MULTICAST traffic = 2 + nflog_redirect = 1 + nflog_multicast = 2 + + nhrp = conf.merge_defaults(nhrp, recursive=True) + + nhrp_tunnel = conf.get_config_dict(['interfaces', 'tunnel'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + if nhrp_tunnel: nhrp.update({'if_tunnel': nhrp_tunnel}) + + for intf, intf_config in nhrp['tunnel'].items(): + if 'multicast' in intf_config: + nhrp['multicast'] = nflog_multicast + if 'redirect' in intf_config: + nhrp['redirect'] = nflog_redirect + + ##Add ipsec profile config to nhrp configuration to apply encryption + profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + for name, profile_conf in profile.items(): + if 'disable' in profile_conf: + continue + if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: + interfaces = profile_conf['bind']['tunnel'] + if isinstance(interfaces, str): + interfaces = [interfaces] + for interface in interfaces: + if dict_search(f'tunnel.{interface}', nhrp): + nhrp['tunnel'][interface][ + 'security_profile'] = name + return nhrp + # Ethernet and bonding interfaces can participate in EVPN which is configured via FRR tmp = {} for if_type in ['ethernet', 'bonding']: @@ -364,6 +409,18 @@ def get_frrender_dict(conf, argv=None) -> dict: elif conf.exists_effective(static_cli_path): dict.update({'static' : {'deleted' : ''}}) + # We need to check the CLI if the NHRP node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + nhrp_cli_path = ['protocols', 'nhrp'] + if conf.exists(nhrp_cli_path): + nhrp = conf.get_config_dict(nhrp_cli_path, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + nhrp = dict_helper_nhrp_defaults(nhrp) + dict.update({'nhrp' : nhrp}) + elif conf.exists_effective(nhrp_cli_path): + dict.update({'nhrp' : {'deleted' : ''}}) + # T3680 - get a list of all interfaces currently configured to use DHCP tmp = get_dhcp_interfaces(conf) if tmp: @@ -626,6 +683,9 @@ class FRRender: if 'ipv6' in config_dict and 'deleted' not in config_dict['ipv6']: output += render_to_string('frr/zebra.route-map.frr.j2', config_dict['ipv6']) output += '\n' + if 'nhrp' in config_dict and 'deleted' not in config_dict['nhrp']: + output += render_to_string('frr/nhrpd.frr.j2', config_dict['nhrp']) + output += '\n' return output debug('FRR: START CONFIGURATION RENDERING') diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py index 7402da55a..a886c1b9e 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -48,7 +48,7 @@ class Control(Section): def _popen(self, command): return popen(command, self.debug) - def _cmd(self, command): + def _cmd(self, command, env=None): import re if 'netns' in self.config: # This command must be executed from default netns 'ip link set dev X netns X' @@ -61,7 +61,7 @@ class Control(Section): command = command else: command = f'ip netns exec {self.config["netns"]} {command}' - return cmd(command, self.debug) + return cmd(command, self.debug, env=env) def _get_command(self, config, name): """ diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 519012625..341fd32ff 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2019-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -22,9 +22,11 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 +from vyos.template import is_ipv4 class WireGuardOperational(Operational): def _dump(self): @@ -80,80 +82,76 @@ class WireGuardOperational(Operational): } return output - def show_interface(self): - from vyos.config import Config + def get_latest_handshakes(self): + """Get latest handshake time for each peer""" + output = {} - c = Config() + # Dump wireguard last handshake + tmp = self._cmd(f'wg show {self.ifname} latest-handshakes') + # Output: + # PUBLIC-KEY= 1732812147 + for line in tmp.split('\n'): + if not line: + # Skip empty lines and last line + continue + items = line.split('\t') - wgdump = self._dump().get(self.config['ifname'], None) + if len(items) != 2: + continue - c.set_level(['interfaces', 'wireguard', self.config['ifname']]) - description = c.return_effective_value(['description']) - ips = c.return_effective_values(['address']) + output[items[0]] = int(items[1]) - answer = 'interface: {}\n'.format(self.config['ifname']) - if description: - answer += ' description: {}\n'.format(description) - if ips: - answer += ' address: {}\n'.format(', '.join(ips)) + return output + + def reset_peer(self, peer_name=None, public_key=None): + c = ConfigTreeQuery() + tmp = c.get_config_dict(['interfaces', 'wireguard', self.ifname], + effective=True, get_first_key=True, + key_mangling=('-', '_'), with_defaults=True) + + current_peers = self._dump().get(self.ifname, {}).get('peers', {}) + + for peer, peer_config in tmp['peer'].items(): + peer_public_key = peer_config['public_key'] + if peer_name is None or peer == peer_name or public_key == peer_public_key: + if ('address' not in peer_config and 'host_name' not in peer_config) or 'port' not in peer_config: + if peer_name is not None: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" address/host-name unset!') + continue - answer += ' public key: {}\n'.format(wgdump['public_key']) - answer += ' private key: (hidden)\n' - answer += ' listening port: {}\n'.format(wgdump['listen_port']) - answer += '\n' + # As we work with an effective config, a port CLI node is always + # available when an address/host-name is defined on the CLI + port = peer_config['port'] - for peer in c.list_effective_nodes(['peer']): - if wgdump['peers']: - pubkey = c.return_effective_value(['peer', peer, 'public-key']) - if pubkey in wgdump['peers']: - wgpeer = wgdump['peers'][pubkey] + # address has higher priority than host-name + if 'address' in peer_config: + address = peer_config['address'] + new_endpoint = f'{address}:{port}' + else: + host_name = peer_config['host_name'] + new_endpoint = f'{host_name}:{port}' - answer += ' peer: {}\n'.format(peer) - answer += ' public key: {}\n'.format(pubkey) + if 'disable' in peer_config: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" disabled!') + continue - """ figure out if the tunnel is recently active or not """ - status = 'inactive' - if wgpeer['latest_handshake'] is None: - """ no handshake ever """ - status = 'inactive' + cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' + try: + if (peer_public_key in current_peers + and 'endpoint' in current_peers[peer_public_key] + and current_peers[peer_public_key]['endpoint'] is not None + ): + current_endpoint = current_peers[peer_public_key]['endpoint'] + message = f'Resetting {self.ifname} peer {peer_public_key} from {current_endpoint} endpoint to {new_endpoint} ... ' else: - if int(wgpeer['latest_handshake']) > 0: - delta = timedelta( - seconds=int(time.time() - wgpeer['latest_handshake']) - ) - answer += ' latest handshake: {}\n'.format(delta) - if time.time() - int(wgpeer['latest_handshake']) < (60 * 5): - """ Five minutes and the tunnel is still active """ - status = 'active' - else: - """ it's been longer than 5 minutes """ - status = 'inactive' - elif int(wgpeer['latest_handshake']) == 0: - """ no handshake ever """ - status = 'inactive' - answer += ' status: {}\n'.format(status) - - if wgpeer['endpoint'] is not None: - answer += ' endpoint: {}\n'.format(wgpeer['endpoint']) - - if wgpeer['allowed_ips'] is not None: - answer += ' allowed ips: {}\n'.format( - ','.join(wgpeer['allowed_ips']).replace(',', ', ') - ) - - if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0: - rx_size = size(wgpeer['transfer_rx'], system=alternative) - tx_size = size(wgpeer['transfer_tx'], system=alternative) - answer += ' transfer: {} received, {} sent\n'.format( - rx_size, tx_size - ) - - if wgpeer['persistent_keepalive'] is not None: - answer += ' persistent keepalive: every {} seconds\n'.format( - wgpeer['persistent_keepalive'] - ) - answer += '\n' - return answer + message = f'Resetting {self.ifname} peer {peer_public_key} endpoint to {new_endpoint} ... ' + print(message, end='') + + self._cmd(cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': + tmp['max_dns_retry']}) + print('done') + except: + print(f'Error\nPlease try to run command manually:\n{cmd}\n') @Interface.register @@ -180,22 +178,26 @@ class WireGuardIf(Interface): get_config_dict(). It's main intention is to consolidate the scattered interface setup code and provide a single point of entry when workin on any interface.""" - tmp_file = NamedTemporaryFile('w') tmp_file.write(config['private_key']) tmp_file.flush() # Wireguard base command is identical for every peer - base_cmd = 'wg set {ifname}' + base_cmd = f'wg set {self.ifname}' + interface_cmd = base_cmd if 'port' in config: - base_cmd += ' listen-port {port}' + interface_cmd += ' listen-port {port}' if 'fwmark' in config: - base_cmd += ' fwmark {fwmark}' + interface_cmd += ' fwmark {fwmark}' - base_cmd += f' private-key {tmp_file.name}' - base_cmd = base_cmd.format(**config) + interface_cmd += f' private-key {tmp_file.name}' + interface_cmd = interface_cmd.format(**config) # T6490: execute command to ensure interface configured - self._cmd(base_cmd) + self._cmd(interface_cmd) + + # If no PSK is given remove it by using /dev/null - passing keys via + # the shell (usually bash) is considered insecure, thus we use a file + no_psk_file = '/dev/null' if 'peer' in config: for peer, peer_config in config['peer'].items(): @@ -203,43 +205,60 @@ class WireGuardIf(Interface): # marked as disabled - also active sessions are terminated as # the public key was already removed when entering this method! if 'disable' in peer_config: + # remove peer if disabled, no error report even if peer not exists + cmd = base_cmd + ' peer {public_key} remove' + self._cmd(cmd.format(**peer_config)) continue - # start of with a fresh 'wg' command - cmd = base_cmd + ' peer {public_key}' - - # If no PSK is given remove it by using /dev/null - passing keys via - # the shell (usually bash) is considered insecure, thus we use a file - no_psk_file = '/dev/null' psk_file = no_psk_file - if 'preshared_key' in peer_config: - psk_file = '/tmp/tmp.wireguard.psk' - with open(psk_file, 'w') as f: - f.write(peer_config['preshared_key']) - cmd += f' preshared-key {psk_file}' - - # Persistent keepalive is optional - if 'persistent_keepalive' in peer_config: - cmd += ' persistent-keepalive {persistent_keepalive}' - - # Multiple allowed-ip ranges can be defined - ensure we are always - # dealing with a list - if isinstance(peer_config['allowed_ips'], str): - peer_config['allowed_ips'] = [peer_config['allowed_ips']] - cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - - # Endpoint configuration is optional - if {'address', 'port'} <= set(peer_config): - if is_ipv6(peer_config['address']): - cmd += ' endpoint [{address}]:{port}' - else: - cmd += ' endpoint {address}:{port}' - self._cmd(cmd.format(**peer_config)) - - # PSK key file is not required to be stored persistently as its backed by CLI - if psk_file != no_psk_file and os.path.exists(psk_file): - os.remove(psk_file) + # start of with a fresh 'wg' command + peer_cmd = base_cmd + ' peer {public_key}' + + try: + cmd = peer_cmd + + if 'preshared_key' in peer_config: + psk_file = '/tmp/tmp.wireguard.psk' + with open(psk_file, 'w') as f: + f.write(peer_config['preshared_key']) + cmd += f' preshared-key {psk_file}' + + # Persistent keepalive is optional + if 'persistent_keepalive' in peer_config: + cmd += ' persistent-keepalive {persistent_keepalive}' + + # Multiple allowed-ip ranges can be defined - ensure we are always + # dealing with a list + if isinstance(peer_config['allowed_ips'], str): + peer_config['allowed_ips'] = [peer_config['allowed_ips']] + cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) + + self._cmd(cmd.format(**peer_config)) + + cmd = peer_cmd + + # Ensure peer is created even if dns not working + if {'address', 'port'} <= set(peer_config): + if is_ipv6(peer_config['address']): + cmd += ' endpoint [{address}]:{port}' + elif is_ipv4(peer_config['address']): + cmd += ' endpoint {address}:{port}' + else: + # don't set endpoint if address uses domain name + continue + elif {'host_name', 'port'} <= set(peer_config): + cmd += ' endpoint {host_name}:{port}' + + self._cmd(cmd.format(**peer_config), env={ + 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) + except: + # todo: logging + pass + finally: + # PSK key file is not required to be stored persistently as its backed by CLI + if psk_file != no_psk_file and os.path.exists(psk_file): + os.remove(psk_file) # call base class super().update(config) diff --git a/python/vyos/include/__init__.py b/python/vyos/include/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + diff --git a/python/vyos/include/uapi/__init__.py b/python/vyos/include/uapi/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + diff --git a/python/vyos/include/uapi/linux/__init__.py b/python/vyos/include/uapi/linux/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/linux/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + diff --git a/python/vyos/include/uapi/linux/fib_rules.py b/python/vyos/include/uapi/linux/fib_rules.py new file mode 100644 index 000000000..72f0b18cb --- /dev/null +++ b/python/vyos/include/uapi/linux/fib_rules.py @@ -0,0 +1,20 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +FIB_RULE_PERMANENT = 0x00000001 +FIB_RULE_INVERT = 0x00000002 +FIB_RULE_UNRESOLVED = 0x00000004 +FIB_RULE_IIF_DETACHED = 0x00000008 +FIB_RULE_DEV_DETACHED = FIB_RULE_IIF_DETACHED +FIB_RULE_OIF_DETACHED = 0x00000010 diff --git a/python/vyos/include/uapi/linux/icmpv6.py b/python/vyos/include/uapi/linux/icmpv6.py new file mode 100644 index 000000000..47e0c723c --- /dev/null +++ b/python/vyos/include/uapi/linux/icmpv6.py @@ -0,0 +1,18 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +ICMPV6_ROUTER_PREF_LOW = 3 +ICMPV6_ROUTER_PREF_MEDIUM = 0 +ICMPV6_ROUTER_PREF_HIGH = 1 +ICMPV6_ROUTER_PREF_INVALID = 2 diff --git a/python/vyos/include/uapi/linux/if_arp.py b/python/vyos/include/uapi/linux/if_arp.py new file mode 100644 index 000000000..90cb66ebd --- /dev/null +++ b/python/vyos/include/uapi/linux/if_arp.py @@ -0,0 +1,176 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +# ARP protocol HARDWARE identifiers +ARPHRD_NETROM = 0 # from KA9Q: NET/ROM pseudo +ARPHRD_ETHER = 1 # Ethernet 10Mbps +ARPHRD_EETHER = 2 # Experimental Ethernet +ARPHRD_AX25 = 3 # AX.25 Level 2 +ARPHRD_PRONET = 4 # PROnet token ring +ARPHRD_CHAOS = 5 # Chaosnet +ARPHRD_IEEE802 = 6 # IEEE 802.2 Ethernet/TR/TB +ARPHRD_ARCNET = 7 # ARCnet +ARPHRD_APPLETLK = 8 # APPLEtalk +ARPHRD_DLCI = 15 # Frame Relay DLCI +ARPHRD_ATM = 19 # ATM +ARPHRD_METRICOM = 23 # Metricom STRIP (new IANA id) +ARPHRD_IEEE1394 = 24 # IEEE 1394 IPv4 - RFC 2734 +ARPHRD_EUI64 = 27 # EUI-64 +ARPHRD_INFINIBAND = 32 # InfiniBand + +# Dummy types for non-ARP hardware +ARPHRD_SLIP = 256 +ARPHRD_CSLIP = 257 +ARPHRD_SLIP6 = 258 +ARPHRD_CSLIP6 = 259 +ARPHRD_RSRVD = 260 # Notional KISS type +ARPHRD_ADAPT = 264 +ARPHRD_ROSE = 270 +ARPHRD_X25 = 271 # CCITT X.25 +ARPHRD_HWX25 = 272 # Boards with X.25 in firmware +ARPHRD_CAN = 280 # Controller Area Network +ARPHRD_MCTP = 290 +ARPHRD_PPP = 512 +ARPHRD_CISCO = 513 # Cisco HDLC +ARPHRD_HDLC = ARPHRD_CISCO # Alias for CISCO +ARPHRD_LAPB = 516 # LAPB +ARPHRD_DDCMP = 517 # Digital's DDCMP protocol +ARPHRD_RAWHDLC = 518 # Raw HDLC +ARPHRD_RAWIP = 519 # Raw IP + +ARPHRD_TUNNEL = 768 # IPIP tunnel +ARPHRD_TUNNEL6 = 769 # IP6IP6 tunnel +ARPHRD_FRAD = 770 # Frame Relay Access Device +ARPHRD_SKIP = 771 # SKIP vif +ARPHRD_LOOPBACK = 772 # Loopback device +ARPHRD_LOCALTLK = 773 # Localtalk device +ARPHRD_FDDI = 774 # Fiber Distributed Data Interface +ARPHRD_BIF = 775 # AP1000 BIF +ARPHRD_SIT = 776 # sit0 device - IPv6-in-IPv4 +ARPHRD_IPDDP = 777 # IP over DDP tunneller +ARPHRD_IPGRE = 778 # GRE over IP +ARPHRD_PIMREG = 779 # PIMSM register interface +ARPHRD_HIPPI = 780 # High Performance Parallel Interface +ARPHRD_ASH = 781 # Nexus 64Mbps Ash +ARPHRD_ECONET = 782 # Acorn Econet +ARPHRD_IRDA = 783 # Linux-IrDA +ARPHRD_FCPP = 784 # Point to point fibrechannel +ARPHRD_FCAL = 785 # Fibrechannel arbitrated loop +ARPHRD_FCPL = 786 # Fibrechannel public loop +ARPHRD_FCFABRIC = 787 # Fibrechannel fabric + +ARPHRD_IEEE802_TR = 800 # Magic type ident for TR +ARPHRD_IEEE80211 = 801 # IEEE 802.11 +ARPHRD_IEEE80211_PRISM = 802 # IEEE 802.11 + Prism2 header +ARPHRD_IEEE80211_RADIOTAP = 803 # IEEE 802.11 + radiotap header +ARPHRD_IEEE802154 = 804 +ARPHRD_IEEE802154_MONITOR = 805 # IEEE 802.15.4 network monitor + +ARPHRD_PHONET = 820 # PhoNet media type +ARPHRD_PHONET_PIPE = 821 # PhoNet pipe header +ARPHRD_CAIF = 822 # CAIF media type +ARPHRD_IP6GRE = 823 # GRE over IPv6 +ARPHRD_NETLINK = 824 # Netlink header +ARPHRD_6LOWPAN = 825 # IPv6 over LoWPAN +ARPHRD_VSOCKMON = 826 # Vsock monitor header + +ARPHRD_VOID = 0xFFFF # Void type, nothing is known +ARPHRD_NONE = 0xFFFE # Zero header length + +# ARP protocol opcodes +ARPOP_REQUEST = 1 # ARP request +ARPOP_REPLY = 2 # ARP reply +ARPOP_RREQUEST = 3 # RARP request +ARPOP_RREPLY = 4 # RARP reply +ARPOP_InREQUEST = 8 # InARP request +ARPOP_InREPLY = 9 # InARP reply +ARPOP_NAK = 10 # (ATM)ARP NAK + +ARPHRD_TO_NAME = { + ARPHRD_NETROM: "netrom", + ARPHRD_ETHER: "ether", + ARPHRD_EETHER: "eether", + ARPHRD_AX25: "ax25", + ARPHRD_PRONET: "pronet", + ARPHRD_CHAOS: "chaos", + ARPHRD_IEEE802: "ieee802", + ARPHRD_ARCNET: "arcnet", + ARPHRD_APPLETLK: "atalk", + ARPHRD_DLCI: "dlci", + ARPHRD_ATM: "atm", + ARPHRD_METRICOM: "metricom", + ARPHRD_IEEE1394: "ieee1394", + ARPHRD_INFINIBAND: "infiniband", + ARPHRD_SLIP: "slip", + ARPHRD_CSLIP: "cslip", + ARPHRD_SLIP6: "slip6", + ARPHRD_CSLIP6: "cslip6", + ARPHRD_RSRVD: "rsrvd", + ARPHRD_ADAPT: "adapt", + ARPHRD_ROSE: "rose", + ARPHRD_X25: "x25", + ARPHRD_HWX25: "hwx25", + ARPHRD_CAN: "can", + ARPHRD_PPP: "ppp", + ARPHRD_HDLC: "hdlc", + ARPHRD_LAPB: "lapb", + ARPHRD_DDCMP: "ddcmp", + ARPHRD_RAWHDLC: "rawhdlc", + ARPHRD_TUNNEL: "ipip", + ARPHRD_TUNNEL6: "tunnel6", + ARPHRD_FRAD: "frad", + ARPHRD_SKIP: "skip", + ARPHRD_LOOPBACK: "loopback", + ARPHRD_LOCALTLK: "ltalk", + ARPHRD_FDDI: "fddi", + ARPHRD_BIF: "bif", + ARPHRD_SIT: "sit", + ARPHRD_IPDDP: "ip/ddp", + ARPHRD_IPGRE: "gre", + ARPHRD_PIMREG: "pimreg", + ARPHRD_HIPPI: "hippi", + ARPHRD_ASH: "ash", + ARPHRD_ECONET: "econet", + ARPHRD_IRDA: "irda", + ARPHRD_FCPP: "fcpp", + ARPHRD_FCAL: "fcal", + ARPHRD_FCPL: "fcpl", + ARPHRD_FCFABRIC: "fcfb0", + ARPHRD_FCFABRIC+1: "fcfb1", + ARPHRD_FCFABRIC+2: "fcfb2", + ARPHRD_FCFABRIC+3: "fcfb3", + ARPHRD_FCFABRIC+4: "fcfb4", + ARPHRD_FCFABRIC+5: "fcfb5", + ARPHRD_FCFABRIC+6: "fcfb6", + ARPHRD_FCFABRIC+7: "fcfb7", + ARPHRD_FCFABRIC+8: "fcfb8", + ARPHRD_FCFABRIC+9: "fcfb9", + ARPHRD_FCFABRIC+10: "fcfb10", + ARPHRD_FCFABRIC+11: "fcfb11", + ARPHRD_FCFABRIC+12: "fcfb12", + ARPHRD_IEEE802_TR: "tr", + ARPHRD_IEEE80211: "ieee802.11", + ARPHRD_IEEE80211_PRISM: "ieee802.11/prism", + ARPHRD_IEEE80211_RADIOTAP: "ieee802.11/radiotap", + ARPHRD_IEEE802154: "ieee802.15.4", + ARPHRD_IEEE802154_MONITOR: "ieee802.15.4/monitor", + ARPHRD_PHONET: "phonet", + ARPHRD_PHONET_PIPE: "phonet_pipe", + ARPHRD_CAIF: "caif", + ARPHRD_IP6GRE: "gre6", + ARPHRD_NETLINK: "netlink", + ARPHRD_6LOWPAN: "6lowpan", + ARPHRD_NONE: "none", + ARPHRD_VOID: "void", +}
\ No newline at end of file diff --git a/python/vyos/include/uapi/linux/lwtunnel.py b/python/vyos/include/uapi/linux/lwtunnel.py new file mode 100644 index 000000000..6797a762b --- /dev/null +++ b/python/vyos/include/uapi/linux/lwtunnel.py @@ -0,0 +1,38 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +LWTUNNEL_ENCAP_NONE = 0 +LWTUNNEL_ENCAP_MPLS = 1 +LWTUNNEL_ENCAP_IP = 2 +LWTUNNEL_ENCAP_ILA = 3 +LWTUNNEL_ENCAP_IP6 = 4 +LWTUNNEL_ENCAP_SEG6 = 5 +LWTUNNEL_ENCAP_BPF = 6 +LWTUNNEL_ENCAP_SEG6_LOCAL = 7 +LWTUNNEL_ENCAP_RPL = 8 +LWTUNNEL_ENCAP_IOAM6 = 9 +LWTUNNEL_ENCAP_XFRM = 10 + +ENCAP_TO_NAME = { + LWTUNNEL_ENCAP_MPLS: 'mpls', + LWTUNNEL_ENCAP_IP: 'ip', + LWTUNNEL_ENCAP_IP6: 'ip6', + LWTUNNEL_ENCAP_ILA: 'ila', + LWTUNNEL_ENCAP_BPF: 'bpf', + LWTUNNEL_ENCAP_SEG6: 'seg6', + LWTUNNEL_ENCAP_SEG6_LOCAL: 'seg6local', + LWTUNNEL_ENCAP_RPL: 'rpl', + LWTUNNEL_ENCAP_IOAM6: 'ioam6', + LWTUNNEL_ENCAP_XFRM: 'xfrm', +} diff --git a/python/vyos/include/uapi/linux/neighbour.py b/python/vyos/include/uapi/linux/neighbour.py new file mode 100644 index 000000000..d5caf44b9 --- /dev/null +++ b/python/vyos/include/uapi/linux/neighbour.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +NTF_USE = (1 << 0) +NTF_SELF = (1 << 1) +NTF_MASTER = (1 << 2) +NTF_PROXY = (1 << 3) +NTF_EXT_LEARNED = (1 << 4) +NTF_OFFLOADED = (1 << 5) +NTF_STICKY = (1 << 6) +NTF_ROUTER = (1 << 7) +NTF_EXT_MANAGED = (1 << 0) +NTF_EXT_LOCKED = (1 << 1) + +NTF_FlAGS = { + 'self': NTF_SELF, + 'router': NTF_ROUTER, + 'extern_learn': NTF_EXT_LEARNED, + 'offload': NTF_OFFLOADED, + 'master': NTF_MASTER, + 'sticky': NTF_STICKY, + 'locked': NTF_EXT_LOCKED, +} diff --git a/python/vyos/include/uapi/linux/rtnetlink.py b/python/vyos/include/uapi/linux/rtnetlink.py new file mode 100644 index 000000000..e31272460 --- /dev/null +++ b/python/vyos/include/uapi/linux/rtnetlink.py @@ -0,0 +1,63 @@ +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +RTM_F_NOTIFY = 0x100 +RTM_F_CLONED = 0x200 +RTM_F_EQUALIZE = 0x400 +RTM_F_PREFIX = 0x800 +RTM_F_LOOKUP_TABLE = 0x1000 +RTM_F_FIB_MATCH = 0x2000 +RTM_F_OFFLOAD = 0x4000 +RTM_F_TRAP = 0x8000 +RTM_F_OFFLOAD_FAILED = 0x20000000 + +RTNH_F_DEAD = 1 +RTNH_F_PERVASIVE = 2 +RTNH_F_ONLINK = 4 +RTNH_F_OFFLOAD = 8 +RTNH_F_LINKDOWN = 16 +RTNH_F_UNRESOLVED = 32 +RTNH_F_TRAP = 64 + +RT_TABLE_COMPAT = 252 +RT_TABLE_DEFAULT = 253 +RT_TABLE_MAIN = 254 +RT_TABLE_LOCAL = 255 + +RTAX_FEATURE_ECN = (1 << 0) +RTAX_FEATURE_SACK = (1 << 1) +RTAX_FEATURE_TIMESTAMP = (1 << 2) +RTAX_FEATURE_ALLFRAG = (1 << 3) +RTAX_FEATURE_TCP_USEC_TS = (1 << 4) + +RT_FlAGS = { + 'dead': RTNH_F_DEAD, + 'onlink': RTNH_F_ONLINK, + 'pervasive': RTNH_F_PERVASIVE, + 'offload': RTNH_F_OFFLOAD, + 'trap': RTNH_F_TRAP, + 'notify': RTM_F_NOTIFY, + 'linkdown': RTNH_F_LINKDOWN, + 'unresolved': RTNH_F_UNRESOLVED, + 'rt_offload': RTM_F_OFFLOAD, + 'rt_trap': RTM_F_TRAP, + 'rt_offload_failed': RTM_F_OFFLOAD_FAILED, +} + +RT_TABLE_TO_NAME = { + RT_TABLE_COMPAT: 'compat', + RT_TABLE_DEFAULT: 'default', + RT_TABLE_MAIN: 'main', + RT_TABLE_LOCAL: 'local', +} diff --git a/python/vyos/kea.py b/python/vyos/kea.py index addfdba49..951c83693 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -17,6 +17,9 @@ import json import os import socket +from datetime import datetime +from datetime import timezone + from vyos.template import is_ipv6 from vyos.template import isc_static_route from vyos.template import netmask_from_cidr @@ -40,7 +43,7 @@ kea4_options = { 'time_offset': 'time-offset', 'wpad_url': 'wpad-url', 'ipv6_only_preferred': 'v6-only-preferred', - 'captive_portal': 'v4-captive-portal' + 'captive_portal': 'v4-captive-portal', } kea6_options = { @@ -52,11 +55,35 @@ kea6_options = { 'nisplus_domain': 'nisp-domain-name', 'nisplus_server': 'nisp-servers', 'sntp_server': 'sntp-servers', - 'captive_portal': 'v6-captive-portal' + 'captive_portal': 'v6-captive-portal', } kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket' + +def _format_hex_string(in_str): + out_str = '' + # if input is divisible by 2, add : every 2 chars + if len(in_str) > 0 and len(in_str) % 2 == 0: + out_str = ':'.join(a + b for a, b in zip(in_str[::2], in_str[1::2])) + else: + out_str = in_str + + return out_str + + +def _find_list_of_dict_index(lst, key='ip', value=''): + """ + Find the index entry of list of dict matching the dict value + Exampe: + % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}] + % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2') + % 1 + """ + idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) + return idx + + def kea_parse_options(config): options = [] @@ -64,14 +91,21 @@ def kea_parse_options(config): if node not in config: continue - value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] + value = ( + ', '.join(config[node]) if isinstance(config[node], list) else config[node] + ) options.append({'name': option_name, 'data': value}) if 'client_prefix_length' in config: - options.append({'name': 'subnet-mask', 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length'])}) + options.append( + { + 'name': 'subnet-mask', + 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length']), + } + ) if 'ip_forwarding' in config: - options.append({'name': 'ip-forwarding', 'data': "true"}) + options.append({'name': 'ip-forwarding', 'data': 'true'}) if 'static_route' in config: default_route = '' @@ -79,31 +113,41 @@ def kea_parse_options(config): if 'default_router' in config: default_route = isc_static_route('0.0.0.0/0', config['default_router']) - routes = [isc_static_route(route, route_options['next_hop']) for route, route_options in config['static_route'].items()] - - options.append({'name': 'rfc3442-static-route', 'data': ", ".join(routes if not default_route else routes + [default_route])}) - options.append({'name': 'windows-static-route', 'data': ", ".join(routes)}) + routes = [ + isc_static_route(route, route_options['next_hop']) + for route, route_options in config['static_route'].items() + ] + + options.append( + { + 'name': 'rfc3442-static-route', + 'data': ', '.join( + routes if not default_route else routes + [default_route] + ), + } + ) + options.append({'name': 'windows-static-route', 'data': ', '.join(routes)}) if 'time_zone' in config: - with open("/usr/share/zoneinfo/" + config['time_zone'], "rb") as f: - tz_string = f.read().split(b"\n")[-2].decode("utf-8") + with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f: + tz_string = f.read().split(b'\n')[-2].decode('utf-8') options.append({'name': 'pcode', 'data': tz_string}) options.append({'name': 'tcode', 'data': config['time_zone']}) - unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') + unifi_controller = dict_search_args( + config, 'vendor_option', 'ubiquiti', 'unifi_controller' + ) if unifi_controller: - options.append({ - 'name': 'unifi-controller', - 'data': unifi_controller, - 'space': 'ubnt' - }) + options.append( + {'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt'} + ) return options + def kea_parse_subnet(subnet, config): out = {'subnet': subnet, 'id': int(config['subnet_id'])} - options = [] if 'option' in config: out['option-data'] = kea_parse_options(config['option']) @@ -125,9 +169,7 @@ def kea_parse_subnet(subnet, config): pools = [] for num, range_config in config['range'].items(): start, stop = range_config['start'], range_config['stop'] - pool = { - 'pool': f'{start} - {stop}' - } + pool = {'pool': f'{start} - {stop}'} if 'option' in range_config: pool['option-data'] = kea_parse_options(range_config['option']) @@ -164,16 +206,21 @@ def kea_parse_subnet(subnet, config): reservation['option-data'] = kea_parse_options(host_config['option']) if 'bootfile_name' in host_config['option']: - reservation['boot-file-name'] = host_config['option']['bootfile_name'] + reservation['boot-file-name'] = host_config['option'][ + 'bootfile_name' + ] if 'bootfile_server' in host_config['option']: - reservation['next-server'] = host_config['option']['bootfile_server'] + reservation['next-server'] = host_config['option'][ + 'bootfile_server' + ] reservations.append(reservation) out['reservations'] = reservations return out + def kea6_parse_options(config): options = [] @@ -181,7 +228,9 @@ def kea6_parse_options(config): if node not in config: continue - value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] + value = ( + ', '.join(config[node]) if isinstance(config[node], list) else config[node] + ) options.append({'name': option_name, 'data': value}) if 'sip_server' in config: @@ -197,17 +246,20 @@ def kea6_parse_options(config): hosts.append(server) if addrs: - options.append({'name': 'sip-server-addr', 'data': ", ".join(addrs)}) + options.append({'name': 'sip-server-addr', 'data': ', '.join(addrs)}) if hosts: - options.append({'name': 'sip-server-dns', 'data': ", ".join(hosts)}) + options.append({'name': 'sip-server-dns', 'data': ', '.join(hosts)}) cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server') if cisco_tftp: - options.append({'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp}) + options.append( + {'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp} + ) return options + def kea6_parse_subnet(subnet, config): out = {'subnet': subnet, 'id': int(config['subnet_id'])} @@ -245,12 +297,14 @@ def kea6_parse_subnet(subnet, config): pd_pool = { 'prefix': prefix, 'prefix-len': int(pd_conf['prefix_length']), - 'delegated-len': int(pd_conf['delegated_length']) + 'delegated-len': int(pd_conf['delegated_length']), } if 'excluded_prefix' in pd_conf: pd_pool['excluded-prefix'] = pd_conf['excluded_prefix'] - pd_pool['excluded-prefix-len'] = int(pd_conf['excluded_prefix_length']) + pd_pool['excluded-prefix-len'] = int( + pd_conf['excluded_prefix_length'] + ) pd_pools.append(pd_pool) @@ -270,9 +324,7 @@ def kea6_parse_subnet(subnet, config): if 'disable' in host_config: continue - reservation = { - 'hostname': host - } + reservation = {'hostname': host} if 'mac' in host_config: reservation['hw-address'] = host_config['mac'] @@ -281,10 +333,10 @@ def kea6_parse_subnet(subnet, config): reservation['duid'] = host_config['duid'] if 'ipv6_address' in host_config: - reservation['ip-addresses'] = [ host_config['ipv6_address'] ] + reservation['ip-addresses'] = [host_config['ipv6_address']] if 'ipv6_prefix' in host_config: - reservation['prefixes'] = [ host_config['ipv6_prefix'] ] + reservation['prefixes'] = [host_config['ipv6_prefix']] if 'option' in host_config: reservation['option-data'] = kea6_parse_options(host_config['option']) @@ -295,6 +347,7 @@ def kea6_parse_subnet(subnet, config): return out + def _ctrl_socket_command(inet, command, args=None): path = kea_ctrl_socket.format(inet=inet) @@ -321,6 +374,7 @@ def _ctrl_socket_command(inet, command, args=None): return json.loads(result.decode('utf-8')) + def kea_get_leases(inet): leases = _ctrl_socket_command(inet, f'lease{inet}-get-all') @@ -329,6 +383,7 @@ def kea_get_leases(inet): return leases['arguments']['leases'] + def kea_delete_lease(inet, ip_address): args = {'ip-address': ip_address} @@ -339,6 +394,7 @@ def kea_delete_lease(inet, ip_address): return False + def kea_get_active_config(inet): config = _ctrl_socket_command(inet, 'config-get') @@ -347,8 +403,18 @@ def kea_get_active_config(inet): return config + +def kea_get_dhcp_pools(config, inet): + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + return [network['name'] for network in shared_networks] if shared_networks else [] + + def kea_get_pool_from_subnet_id(config, inet, subnet_id): - shared_networks = dict_search_args(config, 'arguments', f'Dhcp{inet}', 'shared-networks') + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) if not shared_networks: return None @@ -362,3 +428,120 @@ def kea_get_pool_from_subnet_id(config, inet, subnet_id): return network['name'] return None + + +def kea_get_static_mappings(config, inet, pools=[]) -> list: + """ + Get DHCP static mapping from active Kea DHCPv4 or DHCPv6 configuration + :return list + """ + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + + mappings = [] + + if shared_networks: + for network in shared_networks: + if f'subnet{inet}' not in network: + continue + + for p in pools: + if network['name'] == p: + for subnet in network[f'subnet{inet}']: + if 'reservations' in subnet: + for reservation in subnet['reservations']: + mapping = {'pool': p, 'subnet': subnet['subnet']} + mapping.update(reservation) + # rename 'ip(v6)-address' to 'ip', inet6 has 'ipv6-address' and inet has 'ip-address' + mapping['ip'] = mapping.pop( + 'ipv6-address', mapping.pop('ip-address', None) + ) + # rename 'hw-address' to 'mac' + mapping['mac'] = mapping.pop('hw-address', None) + mappings.append(mapping) + + return mappings + + +def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list: + """ + Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration + :return list + """ + leases = kea_get_leases(inet) + + data = [] + for lease in leases: + lifetime = lease['valid-lft'] + expiry = lease['cltt'] + lifetime + + lease['start_timestamp'] = datetime.fromtimestamp( + expiry - lifetime, timezone.utc + ) + lease['expire_timestamp'] = ( + datetime.fromtimestamp(expiry, timezone.utc) if expiry else None + ) + + data_lease = {} + data_lease['ip'] = lease['ip-address'] + lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'} + data_lease['state'] = lease_state_long[lease['state']] + data_lease['pool'] = ( + kea_get_pool_from_subnet_id(config, inet, lease['subnet-id']) + if config + else '-' + ) + data_lease['end'] = ( + lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None + ) + data_lease['origin'] = 'local' # TODO: Determine remote in HA + # remove trailing dot in 'hostname' to ensure consistency for `vyos-hostsd-client` + data_lease['hostname'] = lease.get('hostname', '-').rstrip('.') + + if inet == '4': + data_lease['mac'] = lease['hw-address'] + data_lease['start'] = lease['start_timestamp'].timestamp() + + if inet == '6': + data_lease['last_communication'] = lease['start_timestamp'].timestamp() + data_lease['duid'] = _format_hex_string(lease['duid']) + data_lease['type'] = lease['type'] + + if lease['type'] == 'IA_PD': + prefix_len = lease['prefix-len'] + data_lease['ip'] += f'/{prefix_len}' + + data_lease['remaining'] = '-' + + if lease['valid-lft'] > 0: + data_lease['remaining'] = lease['expire_timestamp'] - datetime.now( + timezone.utc + ) + + if data_lease['remaining'].days >= 0: + # substraction gives us a timedelta object which can't be formatted with strftime + # so we use str(), split gets rid of the microseconds + data_lease['remaining'] = str(data_lease['remaining']).split('.')[0] + + # Do not add old leases + if ( + data_lease['remaining'] + and data_lease['pool'] in pools + and data_lease['state'] != 'free' + and (not state or state == 'all' or data_lease['state'] in state) + ): + data.append(data_lease) + + # deduplicate + checked = [] + for entry in data: + addr = entry.get('ip') + if addr not in checked: + checked.append(addr) + else: + idx = _find_list_of_dict_index(data, key='ip', value=addr) + if idx is not None: + data.pop(idx) + + return data diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py index 66df5d107..b477b5b5e 100644 --- a/python/vyos/qos/base.py +++ b/python/vyos/qos/base.py @@ -89,7 +89,8 @@ class QoSBase: if value in self._dsfields: return self._dsfields[value] else: - return value + # left shift operation aligns the DSCP/TOS value with its bit position in the IP header. + return int(value) << 2 def _calc_random_detect_queue_params(self, avg_pkt, max_thr, limit=None, min_thr=None, mark_probability=None, precedence=0): diff --git a/python/vyos/remote.py b/python/vyos/remote.py index d87fd24f6..c54fb6031 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -363,6 +363,7 @@ class GitC: # environment vars for our git commands env = { + **os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email, diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 847f80108..05eac8a6a 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -15,6 +15,10 @@ import os +# A list of used Kernel constants +# https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/drivers/net/wireguard/messages.h?h=linux-6.6.y#n45 +WIREGUARD_REKEY_AFTER_TIME = 120 + def check_kmod(k_mod): """ Common utility function to load required kernel modules on demand """ from vyos import ConfigError diff --git a/scripts/transclude-template b/scripts/transclude-template index 5c6668a84..767583acd 100755 --- a/scripts/transclude-template +++ b/scripts/transclude-template @@ -23,6 +23,7 @@ import os import re import sys +import glob regexp = re.compile(r'^ *#include <(.+)>$') @@ -34,7 +35,9 @@ def parse_file(filename): if line: result = regexp.match(line) if result: - lines += parse_file(os.path.join(directory, result.group(1))) + res = os.path.join(directory, result.group(1)) + for g in sorted(glob.glob(res)): + lines += parse_file(g) else: lines += line else: diff --git a/smoketest/config-tests/basic-vyos-no-ntp b/smoketest/config-tests/basic-vyos-no-ntp new file mode 100644 index 000000000..a18260108 --- /dev/null +++ b/smoketest/config-tests/basic-vyos-no-ntp @@ -0,0 +1,53 @@ +set interfaces dummy dum0 address '172.18.254.203/32' +set interfaces ethernet eth0 duplex 'auto' +set interfaces ethernet eth0 offload gro +set interfaces ethernet eth0 offload gso +set interfaces ethernet eth0 offload sg +set interfaces ethernet eth0 offload tso +set interfaces ethernet eth0 speed 'auto' +set interfaces ethernet eth0 vif 203 address '172.18.203.10/24' +set interfaces ethernet eth1 duplex 'auto' +set interfaces ethernet eth1 offload gro +set interfaces ethernet eth1 offload gso +set interfaces ethernet eth1 offload sg +set interfaces ethernet eth1 offload tso +set interfaces ethernet eth1 speed 'auto' +set interfaces ethernet eth2 offload gro +set interfaces ethernet eth2 offload gso +set interfaces ethernet eth2 offload sg +set interfaces ethernet eth2 offload tso +set interfaces ethernet eth3 offload gro +set interfaces ethernet eth3 offload gso +set interfaces ethernet eth3 offload sg +set interfaces ethernet eth3 offload tso +set protocols ospf area 0 network '172.18.203.0/24' +set protocols ospf area 0 network '172.18.254.203/32' +set protocols ospf interface eth0.203 authentication md5 key-id 10 md5-key 'vyos' +set protocols ospf interface eth0.203 dead-interval '40' +set protocols ospf interface eth0.203 hello-interval '10' +set protocols ospf interface eth0.203 passive disable +set protocols ospf interface eth0.203 priority '1' +set protocols ospf interface eth0.203 retransmit-interval '5' +set protocols ospf interface eth0.203 transmit-delay '1' +set protocols ospf log-adjacency-changes detail +set protocols ospf parameters abr-type 'cisco' +set protocols ospf parameters router-id '172.18.254.203' +set protocols ospf passive-interface 'default' +set protocols ospf redistribute connected metric-type '2' +set system config-management commit-revisions '50' +set system conntrack modules ftp +set system conntrack modules h323 +set system conntrack modules nfs +set system conntrack modules pptp +set system conntrack modules sip +set system conntrack modules sqlnet +set system conntrack modules tftp +set system console device ttyS0 speed '115200' +set system domain-name 'vyos.ci.net' +set system host-name 'no-ntp' +set system login user vyos authentication encrypted-password '$6$r/Yw/07NXNY$/ZB.Rjf9jxEV.BYoDyLdH.kH14rU52pOBtrX.4S34qlPt77chflCHvpTCq9a6huLzwaMR50rEICzA5GoIRZlM0' +set system login user vyos authentication plaintext-password '' +set system name-server '172.16.254.30' +set system syslog global facility all level 'debug' +set system syslog global facility local7 level 'debug' +set system time-zone 'Europe/Berlin' diff --git a/smoketest/config-tests/bgp-big-as-cloud b/smoketest/config-tests/bgp-big-as-cloud index 03efef868..d6c17b3d2 100644 --- a/smoketest/config-tests/bgp-big-as-cloud +++ b/smoketest/config-tests/bgp-big-as-cloud @@ -836,7 +836,6 @@ set system flow-accounting interface 'eth0.4089' set system flow-accounting netflow engine-id '1' set system flow-accounting netflow server 192.0.2.55 port '2055' set system flow-accounting netflow version '9' -set system flow-accounting sflow server 1.2.3.4 port '1234' set system flow-accounting syslog-facility 'daemon' set system host-name 'vyos' set system login user vyos authentication encrypted-password '$6$2Ta6TWHd/U$NmrX0x9kexCimeOcYK1MfhMpITF9ELxHcaBU/znBq.X2ukQOj61fVI2UYP/xBzP4QtiTcdkgs7WOQMHWsRymO/' @@ -845,6 +844,9 @@ set system name-server '2001:db8::1' set system name-server '2001:db8::2' set system name-server '192.0.2.1' set system name-server '192.0.2.2' +set system sflow interface 'eth0.4088' +set system sflow interface 'eth0.4089' +set system sflow server 1.2.3.4 port '1234' set system syslog global facility all level 'all' set system syslog global preserve-fqdn set system time-zone 'Europe/Zurich' diff --git a/smoketest/config-tests/bgp-dmvpn-hub b/smoketest/config-tests/bgp-dmvpn-hub index 30521520a..99f3799a4 100644 --- a/smoketest/config-tests/bgp-dmvpn-hub +++ b/smoketest/config-tests/bgp-dmvpn-hub @@ -4,7 +4,7 @@ set interfaces ethernet eth0 duplex 'auto' set interfaces ethernet eth1 speed 'auto' set interfaces ethernet eth1 duplex 'auto' set interfaces loopback lo -set interfaces tunnel tun0 address '192.168.254.62/26' +set interfaces tunnel tun0 address '192.168.254.62/32' set interfaces tunnel tun0 enable-multicast set interfaces tunnel tun0 encapsulation 'gre' set interfaces tunnel tun0 parameters ip key '1' @@ -21,10 +21,12 @@ set protocols bgp peer-group DMVPN address-family ipv4-unicast set protocols bgp system-as '65000' set protocols bgp timers holdtime '30' set protocols bgp timers keepalive '10' -set protocols nhrp tunnel tun0 cisco-authentication 'secret' -set protocols nhrp tunnel tun0 holding-time '300' +set protocols nhrp tunnel tun0 authentication 'secret' +set protocols nhrp tunnel tun0 holdtime '300' set protocols nhrp tunnel tun0 multicast 'dynamic' +set protocols nhrp tunnel tun0 network-id '1' set protocols nhrp tunnel tun0 redirect +set protocols nhrp tunnel tun0 registration-no-unique set protocols nhrp tunnel tun0 shortcut set protocols static route 0.0.0.0/0 next-hop 100.64.10.0 set protocols static route 172.20.0.0/16 blackhole distance '200' diff --git a/smoketest/config-tests/bgp-dmvpn-spoke b/smoketest/config-tests/bgp-dmvpn-spoke index d1c7bc7c0..e4fb82a0e 100644 --- a/smoketest/config-tests/bgp-dmvpn-spoke +++ b/smoketest/config-tests/bgp-dmvpn-spoke @@ -5,7 +5,7 @@ set interfaces pppoe pppoe1 authentication password 'cpe-1' set interfaces pppoe pppoe1 authentication username 'cpe-1' set interfaces pppoe pppoe1 no-peer-dns set interfaces pppoe pppoe1 source-interface 'eth0.7' -set interfaces tunnel tun0 address '192.168.254.1/26' +set interfaces tunnel tun0 address '192.168.254.1/32' set interfaces tunnel tun0 enable-multicast set interfaces tunnel tun0 encapsulation 'gre' set interfaces tunnel tun0 parameters ip key '1' @@ -21,14 +21,16 @@ set protocols bgp parameters log-neighbor-changes set protocols bgp system-as '65001' set protocols bgp timers holdtime '30' set protocols bgp timers keepalive '10' -set protocols nhrp tunnel tun0 cisco-authentication 'secret' -set protocols nhrp tunnel tun0 holding-time '300' -set protocols nhrp tunnel tun0 map 192.168.254.62/26 nbma-address '100.64.10.1' -set protocols nhrp tunnel tun0 map 192.168.254.62/26 register -set protocols nhrp tunnel tun0 multicast 'nhs' +set protocols nhrp tunnel tun0 authentication 'secret' +set protocols nhrp tunnel tun0 holdtime '300' +set protocols nhrp tunnel tun0 multicast '100.64.10.1' +set protocols nhrp tunnel tun0 network-id '1' +set protocols nhrp tunnel tun0 nhs tunnel-ip 192.168.254.62 nbma '100.64.10.1' set protocols nhrp tunnel tun0 redirect +set protocols nhrp tunnel tun0 registration-no-unique set protocols nhrp tunnel tun0 shortcut set protocols static route 172.17.0.0/16 blackhole distance '200' +set protocols static route 192.168.254.0/26 next-hop 192.168.254.62 distance '250' set service dhcp-server shared-network-name LAN-3 subnet 172.17.1.0/24 option default-router '172.17.1.1' set service dhcp-server shared-network-name LAN-3 subnet 172.17.1.0/24 option name-server '172.17.1.1' set service dhcp-server shared-network-name LAN-3 subnet 172.17.1.0/24 range 0 start '172.17.1.100' diff --git a/smoketest/configs/basic-vyos-no-ntp b/smoketest/configs/basic-vyos-no-ntp new file mode 100644 index 000000000..6fb8f384f --- /dev/null +++ b/smoketest/configs/basic-vyos-no-ntp @@ -0,0 +1,132 @@ +interfaces { + dummy dum0 { + address 172.18.254.203/32 + } + ethernet eth0 { + duplex auto + offload { + gro + gso + sg + tso + } + speed auto + vif 203 { + address 172.18.203.10/24 + ip { + ospf { + authentication { + md5 { + key-id 10 { + md5-key vyos + } + } + } + dead-interval 40 + hello-interval 10 + priority 1 + retransmit-interval 5 + transmit-delay 1 + } + } + } + } + ethernet eth1 { + duplex auto + offload { + gro + gso + sg + tso + } + speed auto + } + ethernet eth2 { + offload { + gro + gso + sg + tso + } + } + ethernet eth3 { + offload { + gro + gso + sg + tso + } + } +} +protocols { + ospf { + area 0 { + network 172.18.203.0/24 + network 172.18.254.203/32 + } + log-adjacency-changes { + detail + } + parameters { + abr-type cisco + router-id 172.18.254.203 + } + passive-interface default + passive-interface-exclude eth0.203 + redistribute { + connected { + metric-type 2 + } + } + } +} +system { + config-management { + commit-revisions 50 + } + conntrack { + modules { + ftp + h323 + nfs + pptp + sip + sqlnet + tftp + } + } + domain-name vyos.ci.net + console { + device ttyS0 { + speed 115200 + } + } + host-name no-ntp + login { + user vyos { + authentication { + encrypted-password $6$r/Yw/07NXNY$/ZB.Rjf9jxEV.BYoDyLdH.kH14rU52pOBtrX.4S34qlPt77chflCHvpTCq9a6huLzwaMR50rEICzA5GoIRZlM0 + plaintext-password "" + } + } + } + name-server 172.16.254.30 + ntp { + } + syslog { + global { + facility all { + level debug + } + facility protocols { + level debug + } + } + } + time-zone Europe/Berlin +} + + +// Warning: Do not remove the following line. +// vyos-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack@3:conntrack-sync@2:container@1:dhcp-relay@2:dhcp-server@6:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@23:ipoe-server@1:ipsec@5:isis@1:l2tp@3:lldp@1:mdns@1:nat@5:ntp@1:pppoe-server@5:pptp@2:qos@1:quagga@8:rpki@1:salt@1:snmp@2:ssh@2:sstp@3:system@21:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webproxy@2:zone-policy@1" +// Release version: 1.3.8 diff --git a/smoketest/scripts/cli/test_interfaces_wireguard.py b/smoketest/scripts/cli/test_interfaces_wireguard.py index 4b994a659..f8cd18cf2 100755 --- a/smoketest/scripts/cli/test_interfaces_wireguard.py +++ b/smoketest/scripts/cli/test_interfaces_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright (C) 2020-2025 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 @@ -17,27 +17,33 @@ import os import unittest -from base_vyostest_shim import VyOSUnitTestSHIM +from base_interfaces_test import BasicInterfaceTest from vyos.configsession import ConfigSessionError from vyos.utils.file import read_file from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_running base_path = ['interfaces', 'wireguard'] - -class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): +domain_resolver = 'vyos-domain-resolver.service' +class WireGuardInterfaceTest(BasicInterfaceTest.TestCase): @classmethod def setUpClass(cls): - super(WireGuardInterfaceTest, cls).setUpClass() - - cls._test_addr = ['192.0.2.1/26', '192.0.2.255/31', '192.0.2.64/32', - '2001:db8:1::ffff/64', '2001:db8:101::1/112'] - cls._interfaces = ['wg0', 'wg1'] + cls._base_path = ['interfaces', 'wireguard'] + cls._options = { + 'wg0': ['private-key wBbGJJXYllwDcw63AFjiIR6ZlsvqvAf3eDwog64Dp0Q=', + 'peer RED public-key 6hkkfxN4VUQLu36NLZr47I7ST/FkQl2clPWr+9a6ZH8=', + 'peer RED allowed-ips 169.254.0.0/16', + 'port 5678'], + 'wg1': ['private-key QFwnBHlHYspehvpklBKb7cikM+QMkEy2p6gfsg06S08=', + 'peer BLUE public-key hRJLmP8SVU9/MLmPmYmpOa+RTB4F/zhDqA+/QDuW1Hg=', + 'peer BLUE allowed-ips 169.254.0.0/16', + 'port 4567'], + } + cls._interfaces = list(cls._options) - def tearDown(self): - self.cli_delete(base_path) - self.cli_commit() + super(WireGuardInterfaceTest, cls).setUpClass() - def test_01_wireguard_peer(self): + def test_wireguard_peer(self): # Create WireGuard interfaces with associated peers for intf in self._interfaces: peer = 'foo-' + intf @@ -64,7 +70,7 @@ class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): self.assertTrue(os.path.isdir(f'/sys/class/net/{intf}')) - def test_02_wireguard_add_remove_peer(self): + def test_wireguard_add_remove_peer(self): # T2939: Create WireGuard interfaces with associated peers. # Remove one of the configured peers. # T4774: Test prevention of duplicate peer public keys @@ -102,7 +108,7 @@ class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path + [interface, 'peer', 'PEER01']) self.cli_commit() - def test_03_wireguard_same_public_key(self): + def test_wireguard_same_public_key(self): # T5413: Test prevention of equality interface public key and peer's # public key interface = 'wg0' @@ -115,45 +121,40 @@ class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + [interface, 'private-key', privkey]) self.cli_set(base_path + [interface, 'peer', 'PEER01', 'public-key', pubkey_fail]) - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'port', port]) self.cli_set(base_path + [interface, 'peer', 'PEER01', 'allowed-ips', '10.205.212.10/32']) self.cli_set(base_path + [interface, 'peer', 'PEER01', 'address', '192.0.2.1']) # The same pubkey as the interface wg0 with self.assertRaises(ConfigSessionError): self.cli_commit() - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'public-key', pubkey_ok]) + # If address is defined for a peer, so must be the peer port + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + [interface, 'peer', 'PEER01', 'port', port]) + # Commit peers self.cli_commit() self.assertTrue(os.path.isdir(f'/sys/class/net/{interface}')) - def test_04_wireguard_threaded(self): + def test_wireguard_threaded(self): # T5409: Test adding threaded option on interface. - # Test prevention for adding threaded - # if no enabled peer is configured. - interface = 'wg0' - port = '12345' - privkey = 'OOjcXGfgQlAuM6q8Z9aAYduCua7pxf7UKYvIqoUPoGQ=' - pubkey = 'ebFx/1G0ti8tvuZd94sEIosAZZIznX+dBAKG/8DFm0I=' + for intf in self._interfaces: + for option in self._options.get(intf, []): + self.cli_set(self._base_path + [intf] + option.split()) - self.cli_set(base_path + [interface, 'address', '172.16.0.1/24']) - self.cli_set(base_path + [interface, 'private-key', privkey]) - - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'port', port]) - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'public-key', pubkey]) - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'allowed-ips', '10.205.212.10/32']) - self.cli_set(base_path + [interface, 'peer', 'PEER01', 'address', '192.0.2.1']) - self.cli_set(base_path + [interface, 'per-client-thread']) + self.cli_set(base_path + [intf, 'per-client-thread']) # Commit peers self.cli_commit() - tmp = read_file(f'/sys/class/net/{interface}/threaded') - self.assertTrue(tmp, "1") - def test_05_wireguard_peer_pubkey_change(self): + for intf in self._interfaces: + tmp = read_file(f'/sys/class/net/{intf}/threaded') + self.assertTrue(tmp, "1") + + def test_wireguard_peer_pubkey_change(self): # T5707 changing WireGuard CLI public key of a peer - it's not removed def get_peers(interface) -> list: @@ -171,7 +172,6 @@ class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): peers.append(items[0]) return peers - interface = 'wg1337' port = '1337' privkey = 'iJi4lb2HhkLx2KSAGOjji2alKkYsJjSPkHkrcpxgEVU=' @@ -200,5 +200,41 @@ class WireGuardInterfaceTest(VyOSUnitTestSHIM.TestCase): self.assertNotIn(pubkey_1, peers) self.assertIn(pubkey_2, peers) + def test_wireguard_hostname(self): + # T4930: Test dynamic endpoint support + interface = 'wg1234' + port = '54321' + privkey = 'UOWIeZKNzijhgu0bPRy2PB3gnuOBLfQax5GiYfkmU3A=' + pubkey = '4nG5NfhHBQUq/DnwT0RjRoBCqAh3VrRHqdQgzC/xujk=' + + base_interface_path = base_path + [interface] + self.cli_set(base_interface_path + ['address', '172.16.0.1/24']) + self.cli_set(base_interface_path + ['private-key', privkey]) + + peer_base_path = base_interface_path + ['peer', 'dynamic01'] + self.cli_set(peer_base_path + ['port', port]) + self.cli_set(peer_base_path + ['public-key', pubkey]) + self.cli_set(peer_base_path + ['allowed-ips', '169.254.0.0/16']) + self.cli_set(peer_base_path + ['address', '192.0.2.1']) + self.cli_set(peer_base_path + ['host-name', 'wg.vyos.net']) + + # Peer address and host-name are mutually exclusive + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_delete(peer_base_path + ['address']) + + # Commit peers + self.cli_commit() + + # Ensure the service is running which checks for DNS changes + self.assertTrue(is_systemd_service_running(domain_resolver)) + + self.cli_delete(base_interface_path) + self.cli_commit() + + # Ensure the service is no longer running after WireGuard interface is deleted + self.assertFalse(is_systemd_service_running(domain_resolver)) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_nat.py b/smoketest/scripts/cli/test_nat.py index 0beafcc6c..b33ef2617 100755 --- a/smoketest/scripts/cli/test_nat.py +++ b/smoketest/scripts/cli/test_nat.py @@ -84,7 +84,7 @@ class TestNAT(VyOSUnitTestSHIM.TestCase): address_group = 'smoketest_addr' address_group_member = '192.0.2.1' interface_group = 'smoketest_ifaces' - interface_group_member = 'bond.99' + interface_group_member = 'eth0' self.cli_set(['firewall', 'group', 'address-group', address_group, 'address', address_group_member]) self.cli_set(['firewall', 'group', 'interface-group', interface_group, 'interface', interface_group_member]) diff --git a/smoketest/scripts/cli/test_protocols_nhrp.py b/smoketest/scripts/cli/test_protocols_nhrp.py index 43ae4abf2..f6d1f1da5 100755 --- a/smoketest/scripts/cli/test_protocols_nhrp.py +++ b/smoketest/scripts/cli/test_protocols_nhrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 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 @@ -25,6 +25,7 @@ from vyos.utils.file import read_file tunnel_path = ['interfaces', 'tunnel'] nhrp_path = ['protocols', 'nhrp'] vpn_path = ['vpn', 'ipsec'] +PROCESS_NAME = 'nhrpd' class TestProtocolsNHRP(VyOSUnitTestSHIM.TestCase): @classmethod @@ -41,29 +42,41 @@ class TestProtocolsNHRP(VyOSUnitTestSHIM.TestCase): self.cli_delete(tunnel_path) self.cli_commit() - def test_config(self): + def test_01_nhrp_config(self): tunnel_if = "tun100" - tunnel_source = "192.0.2.1" + tunnel_ip = '172.16.253.134/32' + tunnel_source = "192.0.2.134" tunnel_encapsulation = "gre" esp_group = "ESP-HUB" ike_group = "IKE-HUB" nhrp_secret = "vyos123" nhrp_profile = "NHRPVPN" + nhrp_holdtime = '300' + nhs_tunnelip = '172.16.253.1' + nhs_nbmaip = '192.0.2.1' + map_tunnelip = '172.16.253.135' + map_nbmaip = "192.0.2.135" + nhrp_networkid = '1' ipsec_secret = "secret" - + multicat_log_group = '2' + redirect_log_group = '1' # Tunnel - self.cli_set(tunnel_path + [tunnel_if, "address", "172.16.253.134/29"]) + self.cli_set(tunnel_path + [tunnel_if, "address", tunnel_ip]) self.cli_set(tunnel_path + [tunnel_if, "encapsulation", tunnel_encapsulation]) self.cli_set(tunnel_path + [tunnel_if, "source-address", tunnel_source]) self.cli_set(tunnel_path + [tunnel_if, "enable-multicast"]) self.cli_set(tunnel_path + [tunnel_if, "parameters", "ip", "key", "1"]) # NHRP - self.cli_set(nhrp_path + ["tunnel", tunnel_if, "cisco-authentication", nhrp_secret]) - self.cli_set(nhrp_path + ["tunnel", tunnel_if, "holding-time", "300"]) - self.cli_set(nhrp_path + ["tunnel", tunnel_if, "multicast", "dynamic"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "authentication", nhrp_secret]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "holdtime", nhrp_holdtime]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "multicast", nhs_tunnelip]) self.cli_set(nhrp_path + ["tunnel", tunnel_if, "redirect"]) self.cli_set(nhrp_path + ["tunnel", tunnel_if, "shortcut"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "registration-no-unique"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "network-id", nhrp_networkid]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "nhs", "tunnel-ip", nhs_tunnelip, "nbma", nhs_nbmaip]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "map", "tunnel-ip", map_tunnelip, "nbma", map_nbmaip]) # IKE/ESP Groups self.cli_set(vpn_path + ["esp-group", esp_group, "lifetime", "1800"]) @@ -93,29 +106,40 @@ class TestProtocolsNHRP(VyOSUnitTestSHIM.TestCase): self.cli_commit() - opennhrp_lines = [ - f'interface {tunnel_if} #hub {nhrp_profile}', - f'cisco-authentication {nhrp_secret}', - f'holding-time 300', - f'shortcut', - f'multicast dynamic', - f'redirect' + frrconfig = self.getFRRconfig(f'interface {tunnel_if}', endsection='^exit') + self.assertIn(f'interface {tunnel_if}', frrconfig) + self.assertIn(f' ip nhrp authentication {nhrp_secret}', frrconfig) + self.assertIn(f' ip nhrp holdtime {nhrp_holdtime}', frrconfig) + self.assertIn(f' ip nhrp map multicast {nhs_tunnelip}', frrconfig) + self.assertIn(f' ip nhrp redirect', frrconfig) + self.assertIn(f' ip nhrp registration no-unique', frrconfig) + self.assertIn(f' ip nhrp shortcut', frrconfig) + self.assertIn(f' ip nhrp network-id {nhrp_networkid}', frrconfig) + self.assertIn(f' ip nhrp nhs {nhs_tunnelip} nbma {nhs_nbmaip}', frrconfig) + self.assertIn(f' ip nhrp map {map_tunnelip} {map_nbmaip}', frrconfig) + self.assertIn(f' tunnel protection vici profile dmvpn-{nhrp_profile}-{tunnel_if}-child', + frrconfig) + + nftables_search_multicast = [ + ['chain VYOS_NHRP_MULTICAST_OUTPUT'], + ['type filter hook output priority filter + 10; policy accept;'], + [f'oifname "{tunnel_if}"', 'ip daddr 224.0.0.0/24', 'counter', f'log group {multicat_log_group}'], + [f'oifname "{tunnel_if}"', 'ip daddr 224.0.0.0/24', 'counter', 'drop'], + ['chain VYOS_NHRP_MULTICAST_FORWARD'], + ['type filter hook output priority filter + 10; policy accept;'], + [f'oifname "{tunnel_if}"', 'ip daddr 224.0.0.0/4', 'counter', f'log group {multicat_log_group}'], + [f'oifname "{tunnel_if}"', 'ip daddr 224.0.0.0/4', 'counter', 'drop'] ] - tmp_opennhrp_conf = read_file('/run/opennhrp/opennhrp.conf') - - for line in opennhrp_lines: - self.assertIn(line, tmp_opennhrp_conf) - - firewall_matches = [ - f'ip protocol {tunnel_encapsulation}', - f'ip saddr {tunnel_source}', - f'ip daddr 224.0.0.0/4', - f'comment "VYOS_NHRP_{tunnel_if}"' + nftables_search_redirect = [ + ['chain VYOS_NHRP_REDIRECT_FORWARD'], + ['type filter hook forward priority filter + 10; policy accept;'], + [f'iifname "{tunnel_if}" oifname "{tunnel_if}"', 'meter loglimit-0 size 65535 { ip daddr & 255.255.255.0 . ip saddr & 255.255.255.0 timeout 1m limit rate 4/minute burst 1 packets }', 'counter', f'log group {redirect_log_group}'] ] + self.verify_nftables(nftables_search_multicast, 'ip vyos_nhrp_multicast') + self.verify_nftables(nftables_search_redirect, 'ip vyos_nhrp_redirect') - self.assertTrue(find_nftables_rule('ip vyos_nhrp_filter', 'VYOS_NHRP_OUTPUT', firewall_matches) is not None) - self.assertTrue(process_named_running('opennhrp')) + self.assertTrue(process_named_running(PROCESS_NAME)) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py index 9fbc931de..522102e67 100755 --- a/smoketest/scripts/cli/test_service_dns_dynamic.py +++ b/smoketest/scripts/cli/test_service_dns_dynamic.py @@ -20,8 +20,10 @@ import tempfile from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError +from vyos.utils.file import read_file from vyos.utils.process import cmd from vyos.utils.process import process_named_running +from vyos.xml_ref import default_value DDCLIENT_SYSTEMD_UNIT = '/run/systemd/system/ddclient.service.d/override.conf' DDCLIENT_CONF = '/run/ddclient/ddclient.conf' @@ -29,6 +31,7 @@ DDCLIENT_PNAME = 'ddclient' base_path = ['service', 'dns', 'dynamic'] name_path = base_path + ['name'] +default_interval = default_value(base_path + ['interval']) server = 'ddns.vyos.io' hostname = 'test.ddns.vyos.io' zone = 'vyos.io' @@ -95,12 +98,14 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') - # default value 300 seconds - self.assertIn(f'daemon=300', ddclient_conf) self.assertIn(f'usev4=ifv4', ddclient_conf) self.assertIn(f'ifv4={interface}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) + # Check default interval of 300 seconds + systemd_override = read_file(DDCLIENT_SYSTEMD_UNIT) + self.assertIn(f'--daemon {default_interval}', systemd_override) + for opt in details.keys(): if opt == 'username': login = details[opt] @@ -140,7 +145,6 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') - self.assertIn(f'daemon={interval}', ddclient_conf) self.assertIn(f'usev6=ifv6', ddclient_conf) self.assertIn(f'ifv6={interface}', ddclient_conf) self.assertIn(f'protocol={proto}', ddclient_conf) @@ -150,6 +154,10 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): self.assertIn(f'min-interval={wait_time}', ddclient_conf) self.assertIn(f'max-interval={expiry_time_good}', ddclient_conf) + # default value 300 seconds + systemd_override = read_file(DDCLIENT_SYSTEMD_UNIT) + self.assertIn(f'--daemon {interval}', systemd_override) + # IPv4+IPv6 dual DDNS service configuration def test_03_dyndns_service_dual_stack(self): services = {'cloudflare': {'protocol': 'cloudflare', 'zone': zone}, @@ -339,9 +347,10 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): self.cli_commit() # Check for process in VRF - systemd_override = cmd(f'cat {DDCLIENT_SYSTEMD_UNIT}') + systemd_override = read_file(DDCLIENT_SYSTEMD_UNIT) self.assertIn(f'ExecStart=ip vrf exec {vrf_name} /usr/bin/ddclient ' \ - f'--file {DDCLIENT_CONF} --foreground', systemd_override) + f'--file {DDCLIENT_CONF} --cache {DDCLIENT_CONF.replace("conf", "cache")} ' \ + f'--foreground --daemon {default_interval}', systemd_override) # Check for process in VRF proc = cmd(f'ip vrf pids {vrf_name}') diff --git a/smoketest/scripts/cli/test_service_monitoring_network_event.py b/smoketest/scripts/cli/test_service_monitoring_network_event.py new file mode 100644 index 000000000..3c9b4bf7f --- /dev/null +++ b/smoketest/scripts/cli/test_service_monitoring_network_event.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>. + +import unittest +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.utils.file import read_json + + +base_path = ['service', 'monitoring', 'network-event'] + + +def get_logger_config(): + return read_json('/run/vyos-network-event-logger.conf') + + +class TestMonitoringNetworkEvent(VyOSUnitTestSHIM.TestCase): + @classmethod + def setUpClass(cls): + super(TestMonitoringNetworkEvent, cls).setUpClass() + + # ensure we can also run this test on a live system - so lets clean + # out the current configuration :) + cls.cli_delete(cls, base_path) + + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + + def test_network_event_log(self): + expected_config = { + 'event': { + 'route': {}, + 'link': {}, + 'addr': {}, + 'neigh': {}, + 'rule': {}, + }, + 'queue_size': '10000' + } + + self.cli_set(base_path + ['event', 'route']) + self.cli_set(base_path + ['event', 'link']) + self.cli_set(base_path + ['event', 'addr']) + self.cli_set(base_path + ['event', 'neigh']) + self.cli_set(base_path + ['event', 'rule']) + self.cli_set(base_path + ['queue-size', '10000']) + self.cli_commit() + self.assertEqual(expected_config, get_logger_config()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_system_flow-accounting.py b/smoketest/scripts/cli/test_system_flow-accounting.py index 515134220..9d7942789 100755 --- a/smoketest/scripts/cli/test_system_flow-accounting.py +++ b/smoketest/scripts/cli/test_system_flow-accounting.py @@ -97,111 +97,6 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase): self.assertIn(f'syslog: {syslog}', uacctd) self.assertIn(f'plugins: memory', uacctd) - def test_sflow(self): - sampling_rate = '4000' - source_address = '192.0.2.1' - dummy_if = 'dum3841' - agent_address = '192.0.2.2' - - sflow_server = { - '1.2.3.4' : { }, - '5.6.7.8' : { 'port' : '6000' }, - } - - self.cli_set(['interfaces', 'dummy', dummy_if, 'address', agent_address + '/32']) - self.cli_set(['interfaces', 'dummy', dummy_if, 'address', source_address + '/32']) - self.cli_set(base_path + ['disable-imt']) - - # You need to configure at least one interface for flow-accounting - with self.assertRaises(ConfigSessionError): - self.cli_commit() - for interface in Section.interfaces('ethernet'): - self.cli_set(base_path + ['interface', interface]) - - - # You need to configure at least one sFlow or NetFlow protocol, or not - # set "disable-imt" for flow-accounting - with self.assertRaises(ConfigSessionError): - self.cli_commit() - - self.cli_set(base_path + ['sflow', 'agent-address', agent_address]) - self.cli_set(base_path + ['sflow', 'sampling-rate', sampling_rate]) - self.cli_set(base_path + ['sflow', 'source-address', source_address]) - for server, server_config in sflow_server.items(): - self.cli_set(base_path + ['sflow', 'server', server]) - if 'port' in server_config: - self.cli_set(base_path + ['sflow', 'server', server, 'port', server_config['port']]) - - # commit changes - self.cli_commit() - - uacctd = read_file(uacctd_conf) - - # when 'disable-imt' is not configured on the CLI it must be present - self.assertNotIn(f'imt_path: /tmp/uacctd.pipe', uacctd) - self.assertNotIn(f'imt_mem_pools_number: 169', uacctd) - self.assertNotIn(f'plugins: memory', uacctd) - - for server, server_config in sflow_server.items(): - plugin_name = server.replace('.', '-') - if 'port' in server_config: - self.assertIn(f'sfprobe_receiver[sf_{plugin_name}]: {server}', uacctd) - else: - self.assertIn(f'sfprobe_receiver[sf_{plugin_name}]: {server}:6343', uacctd) - - self.assertIn(f'sfprobe_agentip[sf_{plugin_name}]: {agent_address}', uacctd) - self.assertIn(f'sampling_rate[sf_{plugin_name}]: {sampling_rate}', uacctd) - self.assertIn(f'sfprobe_source_ip[sf_{plugin_name}]: {source_address}', uacctd) - - self.cli_delete(['interfaces', 'dummy', dummy_if]) - - def test_sflow_ipv6(self): - sampling_rate = '100' - sflow_server = { - '2001:db8::1' : { }, - '2001:db8::2' : { 'port' : '6000' }, - } - - self.cli_set(base_path + ['disable-imt']) - - # You need to configure at least one interface for flow-accounting - with self.assertRaises(ConfigSessionError): - self.cli_commit() - for interface in Section.interfaces('ethernet'): - self.cli_set(base_path + ['interface', interface]) - - - # You need to configure at least one sFlow or NetFlow protocol, or not - # set "disable-imt" for flow-accounting - with self.assertRaises(ConfigSessionError): - self.cli_commit() - - self.cli_set(base_path + ['sflow', 'sampling-rate', sampling_rate]) - for server, server_config in sflow_server.items(): - self.cli_set(base_path + ['sflow', 'server', server]) - if 'port' in server_config: - self.cli_set(base_path + ['sflow', 'server', server, 'port', server_config['port']]) - - # commit changes - self.cli_commit() - - uacctd = read_file(uacctd_conf) - - # when 'disable-imt' is not configured on the CLI it must be present - self.assertNotIn(f'imt_path: /tmp/uacctd.pipe', uacctd) - self.assertNotIn(f'imt_mem_pools_number: 169', uacctd) - self.assertNotIn(f'plugins: memory', uacctd) - - for server, server_config in sflow_server.items(): - tmp_srv = server - tmp_srv = tmp_srv.replace(':', '-') - - if 'port' in server_config: - self.assertIn(f'sfprobe_receiver[sf_{tmp_srv}]: {bracketize_ipv6(server)}', uacctd) - else: - self.assertIn(f'sfprobe_receiver[sf_{tmp_srv}]: {bracketize_ipv6(server)}:6343', uacctd) - self.assertIn(f'sampling_rate[sf_{tmp_srv}]: {sampling_rate}', uacctd) - def test_netflow(self): engine_id = '33' max_flows = '667' @@ -288,8 +183,8 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase): self.assertIn(f'nfprobe_timeouts[nf_{tmp_srv}]: expint={tmo_expiry}:general={tmo_flow}:icmp={tmo_icmp}:maxlife={tmo_max}:tcp.fin={tmo_tcp_fin}:tcp={tmo_tcp_generic}:tcp.rst={tmo_tcp_rst}:udp={tmo_udp}', uacctd) - self.cli_delete(['interfaces', 'dummy', dummy_if]) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py index f6a2c3cb3..d79f5521c 100755 --- a/smoketest/scripts/cli/test_system_login.py +++ b/smoketest/scripts/cli/test_system_login.py @@ -31,17 +31,19 @@ from subprocess import PIPE from pwd import getpwall from vyos.configsession import ConfigSessionError +from vyos.configquery import ConfigTreeQuery from vyos.utils.auth import get_current_user from vyos.utils.process import cmd -from vyos.utils.process import process_named_running from vyos.utils.file import read_file from vyos.utils.file import write_file from vyos.template import inc_ip +from vyos.template import is_ipv6 +from vyos.xml_ref import default_value base_path = ['system', 'login'] users = ['vyos1', 'vyos-roxx123', 'VyOS-123_super.Nice'] -SSH_PROCESS_NAME = 'sshd' +ssh_test_command = '/opt/vyatta/bin/vyatta-op-cmd-wrapper show version' ssh_pubkey = """ AAAAB3NzaC1yc2EAAAADAQABAAABgQD0NuhUOEtMIKnUVFIHoFatqX/c4mjerXyF @@ -57,7 +59,6 @@ TTSb0X1zPGxPIRFy5GoGtO9Mm5h4OZk= tac_image = 'docker.io/lfkeitel/tacacs_plus:alpine' tac_image_path = '/usr/share/vyos/tacplus-alpine.tar' - TAC_PLUS_TMPL_SRC = """ id = spawnd { debug redirect = /dev/stdout @@ -100,6 +101,25 @@ id = tac_plus { member = admin } } + +""" + +radius_image = 'docker.io/dchidell/radius-web:latest' +radius_image_path = '/usr/share/vyos/radius-latest.tar' +RADIUS_CLIENTS_TMPL_SRC = """ +client SMOKETEST { + secret = {{ radius_key }} + nastype = other + ipaddr = {{ source_address }} +} + +""" +RADIUS_USERS_TMPL_SRC = """ +# User configuration +{{ username }} Cleartext-Password := "{{ password }}" + Service-Type = NAS-Prompt-User, + Cisco-AVPair = "shell:priv-lvl=15" + """ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): @@ -112,16 +132,36 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): cls.cli_delete(cls, base_path + ['radius']) cls.cli_delete(cls, base_path + ['tacacs']) - # Load image for smoketest provided in vyos-1x-smoketest + # Load images for smoketest provided in vyos-1x-smoketest if not os.path.exists(tac_image_path): cls.fail(cls, f'{tac_image} image not available') cmd(f'sudo podman load -i {tac_image_path}') + if not os.path.exists(radius_image_path): + cls.fail(cls, f'{radius_image} image not available') + cmd(f'sudo podman load -i {radius_image_path}') + + cls.ssh_test_command_result = cls.op_mode(cls, ['show', 'version']) + + # Dynamically start SSH service if it's not running + config = ConfigTreeQuery() + cls.is_sshd_pre_test = config.exists(['service', 'sshd']) + if not cls.is_sshd_pre_test: + # Start SSH service + cls.cli_set(cls, ['service', 'ssh']) + @classmethod def tearDownClass(cls): + # Stop SSH service - if it was not running before starting the test + if not cls.is_sshd_pre_test: + cls.cli_set(cls, ['service', 'ssh']) + cls.cli_commit(cls) + super(TestSystemLogin, cls).tearDownClass() - # Cleanup podman image + + # Cleanup container images cmd(f'sudo podman image rm -f {tac_image}') + cmd(f'sudo podman image rm -f {radius_image}') def tearDown(self): # Delete individual users from configuration @@ -152,9 +192,6 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path + ['user', system_user]) def test_system_login_user(self): - # Check if user can be created and we can SSH to localhost - self.cli_set(['service', 'ssh', 'port', '22']) - for user in users: name = f'VyOS Roxx {user}' home_dir = f'/tmp/smoketest/{user}' @@ -240,71 +277,71 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): self.assertIn(f'{option}=y', kernel_config) def test_system_login_radius_ipv4(self): - # Verify generated RADIUS configuration files - - radius_key = 'VyOSsecretVyOS' - radius_server = '172.16.100.10' - radius_source = '127.0.0.1' - radius_port = '2000' - radius_timeout = '1' - - self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key]) - self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port]) - self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout]) - self.cli_set(base_path + ['radius', 'source-address', radius_source]) - self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) + radius_servers = ['100.64.0.4', '100.64.0.5'] + radius_source = '100.64.0.1' + self._system_login_radius_test_helper(radius_servers, radius_source) - # check validate() - Only one IPv4 source-address supported - with self.assertRaises(ConfigSessionError): - self.cli_commit() - self.cli_delete(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) - - self.cli_commit() + def test_system_login_radius_ipv6(self): + radius_servers = ['2001:db8::4', '2001:db8::5'] + radius_source = '2001:db8::1' + self._system_login_radius_test_helper(radius_servers, radius_source) - # this file must be read with higher permissions - pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf') - tmp = re.findall(r'\n?{}:{}\s+{}\s+{}\s+{}'.format(radius_server, - radius_port, radius_key, radius_timeout, - radius_source), pam_radius_auth_conf) - self.assertTrue(tmp) + def _system_login_radius_test_helper(self, radius_servers: list, radius_source: str): + # Verify generated RADIUS configuration files + radius_key = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10)) - # required, static options - self.assertIn('priv-lvl 15', pam_radius_auth_conf) - self.assertIn('mapped_priv_user radius_priv_user', pam_radius_auth_conf) + default_port = default_value(base_path + ['radius', 'server', radius_servers[0], 'port']) + default_timeout = default_value(base_path + ['radius', 'server', radius_servers[0], 'timeout']) - # PAM - pam_common_account = read_file('/etc/pam.d/common-account') - self.assertIn('pam_radius_auth.so', pam_common_account) + dummy_if = 'dum12760' - pam_common_auth = read_file('/etc/pam.d/common-auth') - self.assertIn('pam_radius_auth.so', pam_common_auth) + # Load container image for FreeRADIUS server + radius_config = '/tmp/smoketest-radius-server' + radius_container_path = ['container', 'name', 'radius-1'] - pam_common_session = read_file('/etc/pam.d/common-session') - self.assertIn('pam_radius_auth.so', pam_common_session) - - pam_common_session_noninteractive = read_file('/etc/pam.d/common-session-noninteractive') - self.assertIn('pam_radius_auth.so', pam_common_session_noninteractive) + # Generate random string with 10 digits + username = 'radius-admin' + password = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10)) + radius_source_mask = '32' + if is_ipv6(radius_source): + radius_source_mask = '128' + radius_test_user = { + 'username' : username, + 'password' : password, + 'radius_key' : radius_key, + 'source_address' : f'{radius_source}/{radius_source_mask}' + } - # NSS - nsswitch_conf = read_file('/etc/nsswitch.conf') - tmp = re.findall(r'passwd:\s+mapuid\s+files\s+mapname', nsswitch_conf) - self.assertTrue(tmp) + tmpl = jinja2.Template(RADIUS_CLIENTS_TMPL_SRC) + write_file(f'{radius_config}/clients.cfg', tmpl.render(radius_test_user)) - tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf) - self.assertTrue(tmp) + tmpl = jinja2.Template(RADIUS_USERS_TMPL_SRC) + write_file(f'{radius_config}/users', tmpl.render(radius_test_user)) - def test_system_login_radius_ipv6(self): - # Verify generated RADIUS configuration files + # Start tac_plus container + self.cli_set(radius_container_path + ['allow-host-networks']) + self.cli_set(radius_container_path + ['image', radius_image]) + self.cli_set(radius_container_path + ['volume', 'clients', 'destination', '/etc/raddb/clients.conf']) + self.cli_set(radius_container_path + ['volume', 'clients', 'mode', 'ro']) + self.cli_set(radius_container_path + ['volume', 'clients', 'source', f'{radius_config}/clients.cfg']) + self.cli_set(radius_container_path + ['volume', 'users', 'destination', '/etc/raddb/users']) + self.cli_set(radius_container_path + ['volume', 'users', 'mode', 'ro']) + self.cli_set(radius_container_path + ['volume', 'users', 'source', f'{radius_config}/users']) - radius_key = 'VyOS-VyOS' - radius_server = '2001:db8::1' - radius_source = '::1' - radius_port = '4000' - radius_timeout = '4' + # Start container + self.cli_commit() - self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key]) - self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port]) - self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout]) + # Deinfine RADIUS servers + for radius_server in radius_servers: + # Use this system as "remote" RADIUS server + dummy_address_mask = '32' + if is_ipv6(radius_server): + dummy_address_mask = '128' + self.cli_set(['interfaces', 'dummy', dummy_if, 'address', f'{radius_server}/{dummy_address_mask}']) + self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key]) + + # Define RADIUS traffic source address + self.cli_set(['interfaces', 'dummy', dummy_if, 'address', f'{radius_source}/{radius_source_mask}']) self.cli_set(base_path + ['radius', 'source-address', radius_source]) self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) @@ -317,10 +354,13 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): # this file must be read with higher permissions pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf') - tmp = re.findall(r'\n?\[{}\]:{}\s+{}\s+{}\s+\[{}\]'.format(radius_server, - radius_port, radius_key, radius_timeout, - radius_source), pam_radius_auth_conf) - self.assertTrue(tmp) + + for radius_server in radius_servers: + if is_ipv6(radius_server): + # it is essential to escape the [] brackets when searching with a regex + radius_server = rf'\[{radius_server}\]' + tmp = re.findall(rf'\n?{radius_server}:{default_port}\s+{radius_key}\s+{default_timeout}\s+{radius_source}', pam_radius_auth_conf) + self.assertTrue(tmp) # required, static options self.assertIn('priv-lvl 15', pam_radius_auth_conf) @@ -347,6 +387,27 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf) self.assertTrue(tmp) + # Login with proper credentials + out, err = self.ssh_send_cmd(ssh_test_command, username, password) + # verify login + self.assertFalse(err) + self.assertEqual(out, self.ssh_test_command_result) + + # Login with invalid credentials + with self.assertRaises(paramiko.ssh_exception.AuthenticationException): + _, _ = self.ssh_send_cmd(ssh_test_command, username, f'{password}1') + + # Remove RADIUS configuration + self.cli_delete(base_path + ['radius']) + # Remove RADIUS container + self.cli_delete(radius_container_path) + # Remove dummy interface + self.cli_delete(['interfaces', 'dummy', dummy_if]) + self.cli_commit() + + # Remove rendered tac_plus daemon configuration + shutil.rmtree(radius_config) + def test_system_login_max_login_session(self): max_logins = '2' timeout = '600' @@ -390,12 +451,6 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): tmpl = jinja2.Template(TAC_PLUS_TMPL_SRC) write_file(f'{tac_plus_config}/tac_plus.cfg', tmpl.render(tac_test_user)) - # Check if SSH service is running - ssh_running = process_named_running(SSH_PROCESS_NAME) - if not ssh_running: - # Start SSH service - self.cli_set(['service', 'ssh']) - # Start tac_plus container self.cli_set(tac_container_path + ['allow-host-networks']) self.cli_set(tac_container_path + ['image', tac_image]) @@ -450,15 +505,14 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): self.assertIn(f'server={server}', nss_tacacs_conf) # Login with proper credentials - test_command = 'uname -a' - out, err = self.ssh_send_cmd(test_command, username, password) + out, err = self.ssh_send_cmd(ssh_test_command, username, password) # verify login self.assertFalse(err) - self.assertEqual(out, cmd(test_command)) + self.assertEqual(out, self.ssh_test_command_result) # Login with invalid credentials with self.assertRaises(paramiko.ssh_exception.AuthenticationException): - _, _ = self.ssh_send_cmd(test_command, username, f'{password}1') + _, _ = self.ssh_send_cmd(ssh_test_command, username, f'{password}1') # Remove TACACS configuration self.cli_delete(base_path + ['tacacs']) @@ -471,10 +525,6 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): # Remove rendered tac_plus daemon configuration shutil.rmtree(tac_plus_config) - # Stop SSH service if it was not running before - if not ssh_running: - self.cli_delete(['service', 'ssh']) - def test_delete_current_user(self): current_user = get_current_user() diff --git a/smoketest/scripts/cli/test_system_sflow.py b/smoketest/scripts/cli/test_system_sflow.py index 74c065473..700253e2b 100755 --- a/smoketest/scripts/cli/test_system_sflow.py +++ b/smoketest/scripts/cli/test_system_sflow.py @@ -96,6 +96,39 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase): for interface in Section.interfaces('ethernet'): self.assertIn(f'pcap {{ dev={interface} }}', hsflowd) + def test_sflow_ipv6(self): + sampling_rate = '100' + default_polling = '30' + default_port = '6343' + sflow_server = { + '2001:db8::1': {}, + '2001:db8::2': {'port': '8023'}, + } + + for interface in Section.interfaces('ethernet'): + self.cli_set(base_path + ['interface', interface]) + + self.cli_set(base_path + ['sampling-rate', sampling_rate]) + for server, server_config in sflow_server.items(): + self.cli_set(base_path + ['server', server]) + if 'port' in server_config: + self.cli_set(base_path + ['server', server, 'port', server_config['port']]) + + # commit changes + self.cli_commit() + + # verify configuration + hsflowd = read_file(hsflowd_conf) + + self.assertIn(f'sampling={sampling_rate}', hsflowd) + self.assertIn(f'polling={default_polling}', hsflowd) + + for server, server_config in sflow_server.items(): + if 'port' in server_config: + self.assertIn(f'collector {{ ip = {server} udpport = {server_config["port"]} }}', hsflowd) + else: + self.assertIn(f'collector {{ ip = {server} udpport = {default_port} }}', hsflowd) + def test_vrf(self): interface = 'eth0' server = '192.0.2.1' diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index f2bea58d1..91a76e6f6 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 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 @@ -353,24 +353,40 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): def test_dmvpn(self): - tunnel_if = 'tun100' - nhrp_secret = 'secret' ike_lifetime = '3600' esp_lifetime = '1800' + tunnel_if = "tun100" + tunnel_ip = '172.16.253.134/32' + tunnel_source = "192.0.2.134" + tunnel_encapsulation = "gre" + esp_group = "ESP-HUB" + ike_group = "IKE-HUB" + nhrp_secret = "vyos123" + nhrp_holdtime = '300' + nhs_tunnelip = '172.16.253.1' + nhs_nbmaip = '192.0.2.1' + map_tunnelip = '172.16.253.135' + map_nbmaip = "192.0.2.135" + nhrp_networkid = '1' + # Tunnel - self.cli_set(tunnel_path + [tunnel_if, 'address', '172.16.253.134/29']) - self.cli_set(tunnel_path + [tunnel_if, 'encapsulation', 'gre']) - self.cli_set(tunnel_path + [tunnel_if, 'source-address', '192.0.2.1']) - self.cli_set(tunnel_path + [tunnel_if, 'enable-multicast']) - self.cli_set(tunnel_path + [tunnel_if, 'parameters', 'ip', 'key', '1']) + self.cli_set(tunnel_path + [tunnel_if, "address", tunnel_ip]) + self.cli_set(tunnel_path + [tunnel_if, "encapsulation", tunnel_encapsulation]) + self.cli_set(tunnel_path + [tunnel_if, "source-address", tunnel_source]) + self.cli_set(tunnel_path + [tunnel_if, "enable-multicast"]) + self.cli_set(tunnel_path + [tunnel_if, "parameters", "ip", "key", "1"]) # NHRP - self.cli_set(nhrp_path + ['tunnel', tunnel_if, 'cisco-authentication', nhrp_secret]) - self.cli_set(nhrp_path + ['tunnel', tunnel_if, 'holding-time', '300']) - self.cli_set(nhrp_path + ['tunnel', tunnel_if, 'multicast', 'dynamic']) - self.cli_set(nhrp_path + ['tunnel', tunnel_if, 'redirect']) - self.cli_set(nhrp_path + ['tunnel', tunnel_if, 'shortcut']) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "authentication", nhrp_secret]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "holdtime", nhrp_holdtime]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "multicast", nhs_tunnelip]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "redirect"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "shortcut"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "registration-no-unique"]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "network-id", nhrp_networkid]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "nhs", "tunnel-ip", nhs_tunnelip, "nbma", nhs_nbmaip]) + self.cli_set(nhrp_path + ["tunnel", tunnel_if, "map", "tunnel-ip", map_tunnelip, "nbma", map_nbmaip]) # IKE/ESP Groups self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', esp_lifetime]) @@ -399,11 +415,11 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): swanctl_conf = read_file(swanctl_file) swanctl_lines = [ - f'proposals = aes128-sha1-modp1024,aes256-sha1-prfsha1-modp1024', + f'proposals = aes256-sha1-prfsha1-modp1024', f'version = 1', f'rekey_time = {ike_lifetime}s', f'rekey_time = {esp_lifetime}s', - f'esp_proposals = aes128-sha1-modp1024,aes256-sha1-modp1024,3des-md5-modp1024', + f'esp_proposals = aes256-sha1-modp1024,3des-md5-modp1024', f'local_ts = dynamic[gre]', f'remote_ts = dynamic[gre]', f'mode = transport', diff --git a/smoketest/scripts/system/test_kernel_options.py b/smoketest/scripts/system/test_kernel_options.py index 700e4cec7..b51b0be1d 100755 --- a/smoketest/scripts/system/test_kernel_options.py +++ b/smoketest/scripts/system/test_kernel_options.py @@ -128,5 +128,11 @@ class TestKernelModules(unittest.TestCase): tmp = re.findall(f'{option}=(y|m)', self._config_data) self.assertTrue(tmp) + def test_psample_enabled(self): + # Psample must be enabled in the OS Kernel to enable egress flow for hsflowd + for option in ['CONFIG_PSAMPLE']: + tmp = re.findall(f'{option}=y', self._config_data) + self.assertTrue(tmp) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py index 98ef98d12..ee1436e49 100755 --- a/src/conf_mode/interfaces_tunnel.py +++ b/src/conf_mode/interfaces_tunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 yOS maintainers and contributors +# Copyright (C) 2018-2025 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 @@ -13,9 +13,8 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. - from sys import exit - +import ipaddress from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed @@ -89,6 +88,13 @@ def verify(tunnel): raise ConfigError('Tunnel used for NHRP, it can not be deleted!') return None + if 'nhrp' in tunnel: + if 'address' in tunnel: + address_list = dict_search('address', tunnel) + for tunip in address_list: + if ipaddress.ip_network(tunip, strict=False).prefixlen != 32: + raise ConfigError( + 'Tunnel is used for NHRP, Netmask should be /32!') verify_tunnel(tunnel) diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index b6fd6b0b2..877d013cf 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -29,11 +29,12 @@ from vyos.ifconfig import WireGuardIf from vyos.utils.kernel import check_kmod from vyos.utils.network import check_port_availability from vyos.utils.network import is_wireguard_key_pair +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag +from pathlib import Path airbag.enable() - def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -54,6 +55,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'peer']): wireguard.update({'rebuild_required': {}}) + wireguard['peers_need_resolve'] = [] + if 'peer' in wireguard: + for peer, peer_config in wireguard['peer'].items(): + if 'disable' not in peer_config and 'host_name' in peer_config: + wireguard['peers_need_resolve'].append(peer) + return wireguard def verify(wireguard): @@ -82,22 +89,33 @@ def verify(wireguard): for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] + base_error = f'WireGuard peer "{tmp}":' + + if 'host_name' in peer and 'address' in peer: + raise ConfigError(f'{base_error} address/host-name are mutually exclusive!') + if 'allowed_ips' not in peer: - raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') + raise ConfigError(f'{base_error} missing mandatory allowed-ips!') if 'public_key' not in peer: - raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!') - - if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer): - raise ConfigError('Both Wireguard port and address must be defined ' - f'for peer "{tmp}" if either one of them is set!') + raise ConfigError(f'{base_error} missing mandatory public-key!') if peer['public_key'] in public_keys: - raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') + raise ConfigError(f'{base_error} duplicate public-key!') if 'disable' not in peer: if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): - raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"') + tmp = wireguard["ifname"] + raise ConfigError(f'{base_error} identical public key as interface "{tmp}"!') + + port_addr_error = f'{base_error} both port and address/host-name must '\ + 'be defined if either one of them is set!' + if 'port' not in peer: + if 'host_name' in peer or 'address' in peer: + raise ConfigError(port_addr_error) + else: + if 'host_name' not in peer and 'address' not in peer: + raise ConfigError(port_addr_error) public_keys.append(peer['public_key']) @@ -122,6 +140,23 @@ def apply(wireguard): wg = WireGuardIf(**wireguard) wg.update(wireguard) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] + + ## DOMAIN RESOLVER + domain_action = 'restart' + if 'peers_need_resolve' in wireguard and len(wireguard['peers_need_resolve']) > 0 and 'disable' not in wireguard: + from vyos.utils.file import write_file + + text = f'# Automatically generated by interfaces_wireguard.py\nThis file indicates that vyos-domain-resolver service is used by the interfaces_wireguard.\n' + text += "intefaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) + Path(domain_resolver_usage).write_text(text) + write_file(domain_resolver_usage, text) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 98b2f3f29..504b3e82a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -17,6 +17,7 @@ import os from sys import exit +from pathlib import Path from vyos.base import Warning from vyos.config import Config @@ -43,7 +44,6 @@ k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' domain_resolver_usage = '/run/use-vyos-domain-resolver-nat' -domain_resolver_usage_firewall = '/run/use-vyos-domain-resolver-firewall' valid_groups = [ 'address_group', @@ -265,9 +265,9 @@ def apply(nat): text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' write_file(domain_resolver_usage, text) elif os.path.exists(domain_resolver_usage): - os.unlink(domain_resolver_usage) - if not os.path.exists(domain_resolver_usage_firewall): - # Firewall not using domain resolver + Path(domain_resolver_usage).unlink(missing_ok=True) + + if not Path('/run').glob('use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index 0bd68b7d8..ac92c9d99 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 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 @@ -14,95 +14,112 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os +from sys import exit +from sys import argv +import ipaddress from vyos.config import Config -from vyos.configdict import node_changed from vyos.template import render +from vyos.configverify import has_frr_protocol_in_dict from vyos.utils.process import run +from vyos.utils.dict import dict_search from vyos import ConfigError from vyos import airbag +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.utils.process import is_systemd_service_running + airbag.enable() -opennhrp_conf = '/run/opennhrp/opennhrp.conf' +nflog_redirect = 1 +nflog_multicast = 2 nhrp_nftables_conf = '/run/nftables_nhrp.conf' + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['protocols', 'nhrp'] - - nhrp = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel']) - - if not conf.exists(base): - return nhrp - nhrp['if_tunnel'] = conf.get_config_dict(['interfaces', 'tunnel'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) + return get_frrender_dict(conf, argv) - nhrp['profile_map'] = {} - profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - for name, profile_conf in profile.items(): - if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']: - interfaces = profile_conf['bind']['tunnel'] - if isinstance(interfaces, str): - interfaces = [interfaces] - for interface in interfaces: - nhrp['profile_map'][interface] = name - - return nhrp - -def verify(nhrp): - if 'tunnel' in nhrp: - for name, nhrp_conf in nhrp['tunnel'].items(): - if not nhrp['if_tunnel'] or name not in nhrp['if_tunnel']: +def verify(config_dict): + if not config_dict or 'deleted' in config_dict: + return None + if 'tunnel' in config_dict: + for name, nhrp_conf in config_dict['tunnel'].items(): + if not config_dict['if_tunnel'] or name not in config_dict['if_tunnel']: raise ConfigError(f'Tunnel interface "{name}" does not exist') - tunnel_conf = nhrp['if_tunnel'][name] + tunnel_conf = config_dict['if_tunnel'][name] + if 'address' in tunnel_conf: + address_list = dict_search('address', tunnel_conf) + for tunip in address_list: + if ipaddress.ip_network(tunip, + strict=False).prefixlen != 32: + raise ConfigError( + f'Tunnel {name} is used for NHRP, Netmask should be /32!') if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre': raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel') + if 'network_id' not in nhrp_conf: + raise ConfigError(f'network-id is not specified in tunnel "{name}"') + if 'remote' in tunnel_conf: raise ConfigError(f'Tunnel "{name}" cannot have a remote address defined') - if 'map' in nhrp_conf: - for map_name, map_conf in nhrp_conf['map'].items(): - if 'nbma_address' not in map_conf: + map_tunnelip = dict_search('map.tunnel_ip', nhrp_conf) + if map_tunnelip: + for map_name, map_conf in map_tunnelip.items(): + if 'nbma' not in map_conf: raise ConfigError(f'nbma-address missing on map {map_name} on tunnel {name}') - if 'dynamic_map' in nhrp_conf: - for map_name, map_conf in nhrp_conf['dynamic_map'].items(): - if 'nbma_domain_name' not in map_conf: - raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}') + nhs_tunnelip = dict_search('nhs.tunnel_ip', nhrp_conf) + nbma_list = [] + if nhs_tunnelip: + for nhs_name, nhs_conf in nhs_tunnelip.items(): + if 'nbma' not in nhs_conf: + raise ConfigError(f'nbma-address missing on map nhs {nhs_name} on tunnel {name}') + if nhs_name != 'dynamic': + if len(list(dict_search('nbma', nhs_conf))) > 1: + raise ConfigError( + f'Static nhs tunnel-ip {nhs_name} cannot contain multiple nbma-addresses') + for nbma_ip in dict_search('nbma', nhs_conf): + if nbma_ip not in nbma_list: + nbma_list.append(nbma_ip) + else: + raise ConfigError( + f'Nbma address {nbma_ip} cannot be maped to several tunnel-ip') return None -def generate(nhrp): - if not os.path.exists(nhrp_nftables_conf): - nhrp['first_install'] = True - render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp) - render(nhrp_nftables_conf, 'nhrp/nftables.conf.j2', nhrp) +def generate(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'nhrp'): + return None + + if 'deleted' in config_dict['nhrp']: + return None + render(nhrp_nftables_conf, 'frr/nhrpd_nftables.conf.j2', config_dict['nhrp']) + + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) return None -def apply(nhrp): + +def apply(config_dict): + nft_rc = run(f'nft --file {nhrp_nftables_conf}') if nft_rc != 0: raise ConfigError('Failed to apply NHRP tunnel firewall rules') - action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop' - service_rc = run(f'systemctl {action} opennhrp.service') - if service_rc != 0: - raise ConfigError(f'Failed to {action} the NHRP service') - + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() return None + if __name__ == '__main__': try: c = get_config() @@ -112,3 +129,4 @@ if __name__ == '__main__': except ConfigError as e: print(e) exit(1) + diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 9c59aa63d..5a729af74 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 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 @@ -38,6 +38,7 @@ from vyos.utils.network import is_subnet_connected from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag + airbag.enable() ctrl_config_file = '/run/kea/kea-ctrl-agent.conf' @@ -45,13 +46,13 @@ ctrl_socket = '/run/kea/dhcp4-ctrl-socket' config_file = '/run/kea/kea-dhcp4.conf' lease_file = '/config/dhcp/dhcp4-leases.csv' lease_file_glob = '/config/dhcp/dhcp4-leases*' -systemd_override = r'/run/systemd/system/kea-ctrl-agent.service.d/10-override.conf' user_group = '_kea' ca_cert_file = '/run/kea/kea-failover-ca.pem' cert_file = '/run/kea/kea-failover.pem' cert_key_file = '/run/kea/kea-failover-key.pem' + def dhcp_slice_range(exclude_list, range_dict): """ This function is intended to slice a DHCP range. What does it mean? @@ -74,19 +75,17 @@ def dhcp_slice_range(exclude_list, range_dict): range_last_exclude = '' for e in exclude_list: - if (ip_address(e) >= ip_address(range_start)) and \ - (ip_address(e) <= ip_address(range_stop)): + if (ip_address(e) >= ip_address(range_start)) and ( + ip_address(e) <= ip_address(range_stop) + ): range_last_exclude = e for e in exclude_list: - if (ip_address(e) >= ip_address(range_start)) and \ - (ip_address(e) <= ip_address(range_stop)): - + if (ip_address(e) >= ip_address(range_start)) and ( + ip_address(e) <= ip_address(range_stop) + ): # Build new address range ending one address before exclude address - r = { - 'start' : range_start, - 'stop' : str(ip_address(e) -1) - } + r = {'start': range_start, 'stop': str(ip_address(e) - 1)} if 'option' in range_dict: r['option'] = range_dict['option'] @@ -104,10 +103,7 @@ def dhcp_slice_range(exclude_list, range_dict): # Take care of last IP address range spanning from the last exclude # address (+1) to the end of the initial configured range if ip_address(e) == ip_address(range_last_exclude): - r = { - 'start': str(ip_address(e) + 1), - 'stop': str(range_stop) - } + r = {'start': str(ip_address(e) + 1), 'stop': str(range_stop)} if 'option' in range_dict: r['option'] = range_dict['option'] @@ -115,14 +111,15 @@ def dhcp_slice_range(exclude_list, range_dict): if not (ip_address(r['start']) > ip_address(r['stop'])): output.append(r) else: - # if the excluded address was not part of the range, we simply return - # the entire ranga again - if not range_last_exclude: - if range_dict not in output: - output.append(range_dict) + # if the excluded address was not part of the range, we simply return + # the entire ranga again + if not range_last_exclude: + if range_dict not in output: + output.append(range_dict) return output + def get_config(config=None): if config: conf = config @@ -132,10 +129,13 @@ def get_config(config=None): if not conf.exists(base): return None - dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), - no_tag_node_value_mangle=True, - get_first_key=True, - with_recursive_defaults=True) + dhcp = conf.get_config_dict( + base, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True, + ) if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): @@ -147,22 +147,31 @@ def get_config(config=None): new_range_id = 0 new_range_dict = {} for r, r_config in subnet_config['range'].items(): - for slice in dhcp_slice_range(subnet_config['exclude'], r_config): - new_range_dict.update({new_range_id : slice}) - new_range_id +=1 + for slice in dhcp_slice_range( + subnet_config['exclude'], r_config + ): + new_range_dict.update({new_range_id: slice}) + new_range_id += 1 dhcp['shared_network_name'][network]['subnet'][subnet].update( - {'range' : new_range_dict}) + {'range': new_range_dict} + ) if len(dhcp['high_availability']) == 1: ## only default value for mode is set, need to remove ha node del dhcp['high_availability'] else: if dict_search('high_availability.certificate', dhcp): - dhcp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + dhcp['pki'] = conf.get_config_dict( + ['pki'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) return dhcp + def verify(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: @@ -170,13 +179,15 @@ def verify(dhcp): # If DHCP is enabled we need one share-network if 'shared_network_name' not in dhcp: - raise ConfigError('No DHCP shared networks configured.\n' \ - 'At least one DHCP shared network must be configured.') + raise ConfigError( + 'No DHCP shared networks configured.\n' + 'At least one DHCP shared network must be configured.' + ) # Inspect shared-network/subnet listen_ok = False subnets = [] - shared_networks = len(dhcp['shared_network_name']) + shared_networks = len(dhcp['shared_network_name']) disabled_shared_networks = 0 subnet_ids = [] @@ -187,12 +198,16 @@ def verify(dhcp): disabled_shared_networks += 1 if 'subnet' not in network_config: - raise ConfigError(f'No subnets defined for {network}. At least one\n' \ - 'lease subnet must be configured.') + raise ConfigError( + f'No subnets defined for {network}. At least one\n' + 'lease subnet must be configured.' + ) for subnet, subnet_config in network_config['subnet'].items(): if 'subnet_id' not in subnet_config: - raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') + raise ConfigError( + f'Unique subnet ID not specified for subnet "{subnet}"' + ) if subnet_config['subnet_id'] in subnet_ids: raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') @@ -203,32 +218,46 @@ def verify(dhcp): if 'static_route' in subnet_config: for route, route_option in subnet_config['static_route'].items(): if 'next_hop' not in route_option: - raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!') + raise ConfigError( + f'DHCP static-route "{route}" requires router to be defined!' + ) # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] for range, range_config in subnet_config['range'].items(): if not {'start', 'stop'} <= set(range_config): - raise ConfigError(f'DHCP range "{range}" start and stop address must be defined!') + raise ConfigError( + f'DHCP range "{range}" start and stop address must be defined!' + ) # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): - raise ConfigError(f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!' + ) # Stop address must be greater or equal to start address - if ip_address(range_config['stop']) < ip_address(range_config['start']): - raise ConfigError(f'DHCP range "{range}" stop address must be greater or equal\n' \ - 'to the ranges start address!') + if ip_address(range_config['stop']) < ip_address( + range_config['start'] + ): + raise ConfigError( + f'DHCP range "{range}" stop address must be greater or equal\n' + 'to the ranges start address!' + ) for network in networks: start = range_config['start'] stop = range_config['stop'] if start in network: - raise ConfigError(f'Range "{range}" start address "{start}" already part of another range!') + raise ConfigError( + f'Range "{range}" start address "{start}" already part of another range!' + ) if stop in network: - raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!') + raise ConfigError( + f'Range "{range}" stop address "{stop}" already part of another range!' + ) tmp = IPRange(range_config['start'], range_config['stop']) networks.append(tmp) @@ -237,12 +266,16 @@ def verify(dhcp): if 'exclude' in subnet_config: for exclude in subnet_config['exclude']: if ip_address(exclude) not in ip_network(subnet): - raise ConfigError(f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!' + ) # At least one DHCP address range or static-mapping required if 'range' not in subnet_config and 'static_mapping' not in subnet_config: - raise ConfigError(f'No DHCP address range or active static-mapping configured\n' \ - f'within shared-network "{network}, {subnet}"!') + raise ConfigError( + f'No DHCP address range or active static-mapping configured\n' + f'within shared-network "{network}, {subnet}"!' + ) if 'static_mapping' in subnet_config: # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) @@ -251,29 +284,42 @@ def verify(dhcp): used_duid = [] for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ip_address' in mapping_config: - if ip_address(mapping_config['ip_address']) not in ip_network(subnet): - raise ConfigError(f'Configured static lease address for mapping "{mapping}" is\n' \ - f'not within shared-network "{network}, {subnet}"!') - - if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ - ('mac' in mapping_config and 'duid' in mapping_config): - raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' - f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + if ip_address(mapping_config['ip_address']) not in ip_network( + subnet + ): + raise ConfigError( + f'Configured static lease address for mapping "{mapping}" is\n' + f'not within shared-network "{network}, {subnet}"!' + ) + + if ( + 'mac' not in mapping_config and 'duid' not in mapping_config + ) or ('mac' in mapping_config and 'duid' in mapping_config): + raise ConfigError( + f'Either MAC address or Client identifier (DUID) is required for ' + f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!' + ) if 'disable' not in mapping_config: if mapping_config['ip_address'] in used_ips: - raise ConfigError(f'Configured IP address for static mapping "{mapping}" already exists on another static mapping') + raise ConfigError( + f'Configured IP address for static mapping "{mapping}" already exists on another static mapping' + ) used_ips.append(mapping_config['ip_address']) if 'disable' not in mapping_config: if 'mac' in mapping_config: if mapping_config['mac'] in used_mac: - raise ConfigError(f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping') + raise ConfigError( + f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping' + ) used_mac.append(mapping_config['mac']) if 'duid' in mapping_config: if mapping_config['duid'] in used_duid: - raise ConfigError(f'Configured DUID for static mapping "{mapping}" already exists on another static mapping') + raise ConfigError( + f'Configured DUID for static mapping "{mapping}" already exists on another static mapping' + ) used_duid.append(mapping_config['duid']) # There must be one subnet connected to a listen interface. @@ -284,73 +330,102 @@ def verify(dhcp): # Subnets must be non overlapping if subnet in subnets: - raise ConfigError(f'Configured subnets must be unique! Subnet "{subnet}"\n' - 'defined multiple times!') + raise ConfigError( + f'Configured subnets must be unique! Subnet "{subnet}"\n' + 'defined multiple times!' + ) subnets.append(subnet) # Check for overlapping subnets net = ip_network(subnet) for n in subnets: net2 = ip_network(n) - if (net != net2): + if net != net2: if net.overlaps(net2): - raise ConfigError(f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!') + raise ConfigError( + f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!' + ) # Prevent 'disable' for shared-network if only one network is configured if (shared_networks - disabled_shared_networks) < 1: - raise ConfigError(f'At least one shared network must be active!') + raise ConfigError('At least one shared network must be active!') if 'high_availability' in dhcp: for key in ['name', 'remote', 'source_address', 'status']: if key not in dhcp['high_availability']: tmp = key.replace('_', '-') - raise ConfigError(f'DHCP high-availability requires "{tmp}" to be specified!') + raise ConfigError( + f'DHCP high-availability requires "{tmp}" to be specified!' + ) if len({'certificate', 'ca_certificate'} & set(dhcp['high_availability'])) == 1: - raise ConfigError(f'DHCP secured high-availability requires both certificate and CA certificate') + raise ConfigError( + 'DHCP secured high-availability requires both certificate and CA certificate' + ) if 'certificate' in dhcp['high_availability']: cert_name = dhcp['high_availability']['certificate'] if cert_name not in dhcp['pki']['certificate']: - raise ConfigError(f'Invalid certificate specified for DHCP high-availability') - - if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'): - raise ConfigError(f'Invalid certificate specified for DHCP high-availability') - - if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'): - raise ConfigError(f'Missing private key on certificate specified for DHCP high-availability') + raise ConfigError( + 'Invalid certificate specified for DHCP high-availability' + ) + + if not dict_search_args( + dhcp['pki']['certificate'], cert_name, 'certificate' + ): + raise ConfigError( + 'Invalid certificate specified for DHCP high-availability' + ) + + if not dict_search_args( + dhcp['pki']['certificate'], cert_name, 'private', 'key' + ): + raise ConfigError( + 'Missing private key on certificate specified for DHCP high-availability' + ) if 'ca_certificate' in dhcp['high_availability']: ca_cert_name = dhcp['high_availability']['ca_certificate'] if ca_cert_name not in dhcp['pki']['ca']: - raise ConfigError(f'Invalid CA certificate specified for DHCP high-availability') + raise ConfigError( + 'Invalid CA certificate specified for DHCP high-availability' + ) if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): - raise ConfigError(f'Invalid CA certificate specified for DHCP high-availability') + raise ConfigError( + 'Invalid CA certificate specified for DHCP high-availability' + ) - for address in (dict_search('listen_address', dhcp) or []): + for address in dict_search('listen_address', dhcp) or []: if is_addr_assigned(address, include_vrf=True): listen_ok = True # no need to probe further networks, we have one that is valid continue else: - raise ConfigError(f'listen-address "{address}" not configured on any interface') + raise ConfigError( + f'listen-address "{address}" not configured on any interface' + ) if not listen_ok: - raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n' - 'broadcast interface configured, nor was there an explicit listen-address\n' - 'configured for serving DHCP relay packets!') + raise ConfigError( + 'None of the configured subnets have an appropriate primary IP address on any\n' + 'broadcast interface configured, nor was there an explicit listen-address\n' + 'configured for serving DHCP relay packets!' + ) if 'listen_address' in dhcp and 'listen_interface' in dhcp: - raise ConfigError(f'Cannot define listen-address and listen-interface at the same time') + raise ConfigError( + 'Cannot define listen-address and listen-interface at the same time' + ) - for interface in (dict_search('listen_interface', dhcp) or []): + for interface in dict_search('listen_interface', dhcp) or []: if not interface_exists(interface): raise ConfigError(f'listen-interface "{interface}" does not exist') return None + def generate(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: @@ -382,8 +457,12 @@ def generate(dhcp): cert_name = dhcp['high_availability']['certificate'] cert_data = dhcp['pki']['certificate'][cert_name]['certificate'] key_data = dhcp['pki']['certificate'][cert_name]['private']['key'] - write_file(cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600) - write_file(cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600) + write_file( + cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600 + ) + write_file( + cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600 + ) dhcp['high_availability']['cert_file'] = cert_file dhcp['high_availability']['cert_key_file'] = cert_key_file @@ -391,17 +470,33 @@ def generate(dhcp): if 'ca_certificate' in dhcp['high_availability']: ca_cert_name = dhcp['high_availability']['ca_certificate'] ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate'] - write_file(ca_cert_file, wrap_certificate(ca_cert_data), user=user_group, mode=0o600) + write_file( + ca_cert_file, + wrap_certificate(ca_cert_data), + user=user_group, + mode=0o600, + ) dhcp['high_availability']['ca_cert_file'] = ca_cert_file - render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) - - render(ctrl_config_file, 'dhcp-server/kea-ctrl-agent.conf.j2', dhcp, user=user_group, group=user_group) - render(config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp, user=user_group, group=user_group) + render( + ctrl_config_file, + 'dhcp-server/kea-ctrl-agent.conf.j2', + dhcp, + user=user_group, + group=user_group, + ) + render( + config_file, + 'dhcp-server/kea-dhcp4.conf.j2', + dhcp, + user=user_group, + group=user_group, + ) return None + def apply(dhcp): services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] @@ -427,6 +522,7 @@ def apply(dhcp): return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_monitoring_network_event.py b/src/conf_mode/service_monitoring_network_event.py new file mode 100644 index 000000000..104e6ce23 --- /dev/null +++ b/src/conf_mode/service_monitoring_network_event.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>. + +import os +import json + +from sys import exit + +from vyos.config import Config +from vyos.utils.file import write_file +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +vyos_network_event_logger_config = r'/run/vyos-network-event-logger.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'monitoring', 'network-event'] + if not conf.exists(base): + return None + + monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + monitoring = conf.merge_defaults(monitoring, recursive=True) + + return monitoring + + +def verify(monitoring): + if not monitoring: + return None + + return None + + +def generate(monitoring): + if not monitoring: + # Delete config + if os.path.exists(vyos_network_event_logger_config): + os.unlink(vyos_network_event_logger_config) + + return None + + # Create config + log_conf_json = json.dumps(monitoring, indent=4) + write_file(vyos_network_event_logger_config, log_conf_json) + + return None + + +def apply(monitoring): + # Reload systemd manager configuration + systemd_service = 'vyos-network-event-logger.service' + + if not monitoring: + call(f'systemctl stop {systemd_service}') + return + + call(f'systemctl restart {systemd_service}') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py index a12ee363d..925c4a562 100755 --- a/src/conf_mode/system_flow-accounting.py +++ b/src/conf_mode/system_flow-accounting.py @@ -18,7 +18,6 @@ import os import re from sys import exit -from ipaddress import ip_address from vyos.config import Config from vyos.config import config_dict_merge @@ -159,9 +158,9 @@ def get_config(config=None): # delete individual flow type defaults - should only be added if user # sets this feature - for flow_type in ['sflow', 'netflow']: - if flow_type not in flow_accounting and flow_type in default_values: - del default_values[flow_type] + flow_type = 'netflow' + if flow_type not in flow_accounting and flow_type in default_values: + del default_values[flow_type] flow_accounting = config_dict_merge(default_values, flow_accounting) @@ -171,9 +170,9 @@ def verify(flow_config): if not flow_config: return None - # check if at least one collector is enabled - if 'sflow' not in flow_config and 'netflow' not in flow_config and 'disable_imt' in flow_config: - raise ConfigError('You need to configure at least sFlow or NetFlow, ' \ + # check if collector is enabled + if 'netflow' not in flow_config and 'disable_imt' in flow_config: + raise ConfigError('You need to configure NetFlow, ' \ 'or not set "disable-imt" for flow-accounting!') # Check if at least one interface is configured @@ -185,45 +184,7 @@ def verify(flow_config): for interface in flow_config['interface']: verify_interface_exists(flow_config, interface, warning_only=True) - # check sFlow configuration - if 'sflow' in flow_config: - # check if at least one sFlow collector is configured - if 'server' not in flow_config['sflow']: - raise ConfigError('You need to configure at least one sFlow server!') - - # check that all sFlow collectors use the same IP protocol version - sflow_collector_ipver = None - for server in flow_config['sflow']['server']: - if sflow_collector_ipver: - if sflow_collector_ipver != ip_address(server).version: - raise ConfigError("All sFlow servers must use the same IP protocol") - else: - sflow_collector_ipver = ip_address(server).version - - # check if vrf is defined for Sflow - verify_vrf(flow_config) - sflow_vrf = None - if 'vrf' in flow_config: - sflow_vrf = flow_config['vrf'] - - # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa - for server in flow_config['sflow']['server']: - if 'agent_address' in flow_config['sflow']: - if ip_address(server).version != ip_address(flow_config['sflow']['agent_address']).version: - raise ConfigError('IPv4 and IPv6 addresses can not be mixed in "sflow agent-address" and "sflow '\ - 'server". You need to set the same IP version for both "agent-address" and '\ - 'all sFlow servers') - - if 'agent_address' in flow_config['sflow']: - tmp = flow_config['sflow']['agent_address'] - if not is_addr_assigned(tmp, sflow_vrf): - raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!') - - # Check if configured sflow source-address exist in the system - if 'source_address' in flow_config['sflow']: - if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf): - tmp = flow_config['sflow']['source_address'] - raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!') + verify_vrf(flow_config) # check NetFlow configuration if 'netflow' in flow_config: diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index e22b7550c..25604d2a2 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 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 @@ -86,8 +86,6 @@ def get_config(config=None): conf = Config() base = ['vpn', 'ipsec'] l2tp_base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings'] - if not conf.exists(base): - return None # retrieve common dictionary keys ipsec = conf.get_config_dict(base, key_mangling=('-', '_'), @@ -95,6 +93,14 @@ def get_config(config=None): get_first_key=True, with_pki=True) + ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) + if ipsec['nhrp_exists']: + set_dependents('nhrp', conf) + + if not conf.exists(base): + ipsec.update({'deleted' : ''}) + return ipsec + # We have to cleanup the default dict, as default values could # enable features which are not explicitly enabled on the # CLI. E.g. dead-peer-detection defaults should not be injected @@ -115,7 +121,6 @@ def get_config(config=None): ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) - ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel']) if ipsec['nhrp_exists']: set_dependents('nhrp', conf) @@ -196,8 +201,8 @@ def verify_pki_rsa(pki, rsa_conf): return True def verify(ipsec): - if not ipsec: - return None + if not ipsec or 'deleted' in ipsec: + return if 'authentication' in ipsec: if 'psk' in ipsec['authentication']: @@ -624,7 +629,7 @@ def generate_pki_files_rsa(pki, rsa_conf): def generate(ipsec): cleanup_pki_files() - if not ipsec: + if not ipsec or 'deleted' in ipsec: for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]: if os.path.isfile(config_file): os.unlink(config_file) @@ -721,15 +726,12 @@ def generate(ipsec): def apply(ipsec): systemd_service = 'strongswan.service' - if not ipsec: + if not ipsec or 'deleted' in ipsec: call(f'systemctl stop {systemd_service}') - if vti_updown_db_exists(): remove_vti_updown_db() - else: call(f'systemctl reload-or-restart {systemd_service}') - if ipsec['enabled_vti_interfaces']: with open_vti_updown_db_for_create_or_update() as db: db.removeAllOtherInterfaces(ipsec['enabled_vti_interfaces']) @@ -737,7 +739,7 @@ def apply(ipsec): db.commit(lambda interface: ipsec['vti_interface_dicts'][interface]) elif vti_updown_db_exists(): remove_vti_updown_db() - + if ipsec: if ipsec.get('nhrp_exists', False): try: call_dependents() @@ -746,7 +748,6 @@ def apply(ipsec): # ConfigError("ConfigError('Interface ethN requires an IP address!')") pass - if __name__ == '__main__': try: ipsec = get_config() diff --git a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf index 0f5bf801e..c74fafb42 100644 --- a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf +++ b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf @@ -1,6 +1,7 @@ [Unit] After= After=vyos-router.service +ConditionFileNotEmpty= [Service] ExecStart= diff --git a/src/migration-scripts/flow-accounting/1-to-2 b/src/migration-scripts/flow-accounting/1-to-2 new file mode 100644 index 000000000..5ffb1eec8 --- /dev/null +++ b/src/migration-scripts/flow-accounting/1-to-2 @@ -0,0 +1,63 @@ +# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. + +# migrate 'system flow-accounting sflow' to 'system sflow' + +from vyos.configtree import ConfigTree + +base = ['system', 'flow-accounting'] +base_fa_sflow = base + ['sflow'] +base_sflow = ['system', 'sflow'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base_fa_sflow): + # Nothing to do + return + + if not config.exists(base_sflow): + + for iface in config.return_values(base + ['interface']): + config.set(base_sflow + ['interface'], value=iface, replace=False) + + if config.exists(base + ['vrf']): + vrf = config.return_value(base + ['vrf']) + config.set(base_sflow + ['vrf'], value=vrf) + + if config.exists(base + ['enable-egress']): + config.set(base_sflow + ['enable-egress']) + + if config.exists(base_fa_sflow + ['agent-address']): + address = config.return_value(base_fa_sflow + ['agent-address']) + config.set(base_sflow + ['agent-address'], value=address) + + if config.exists(base_fa_sflow + ['sampling-rate']): + sr = config.return_value(base_fa_sflow + ['sampling-rate']) + config.set(base_sflow + ['sampling-rate'], value=sr) + + for server in config.list_nodes(base_fa_sflow + ['server']): + config.set(base_sflow + ['server']) + config.set_tag(base_sflow + ['server']) + config.set(base_sflow + ['server', server]) + tmp = base_fa_sflow + ['server', server] + if config.exists(tmp + ['port']): + port = config.return_value(tmp + ['port']) + config.set(base_sflow + ['server', server, 'port'], value=port) + + if config.exists(base + ['netflow']): + # delete only sflow from flow-accounting if netflow is set + config.delete(base_fa_sflow) + else: + # delete all flow-accounting config otherwise + config.delete(base) diff --git a/src/migration-scripts/nhrp/0-to-1 b/src/migration-scripts/nhrp/0-to-1 new file mode 100644 index 000000000..badd88e04 --- /dev/null +++ b/src/migration-scripts/nhrp/0-to-1 @@ -0,0 +1,129 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. + +# Migration from Opennhrp to FRR NHRP +import ipaddress + +from vyos.configtree import ConfigTree + +base = ['protocols', 'nhrp', 'tunnel'] +interface_base = ['interfaces', 'tunnel'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + return + networkid = 1 + for tunnel_name in config.list_nodes(base): + ## Cisco Authentication migration + if config.exists(base + [tunnel_name,'cisco-authentication']): + auth = config.return_value(base + [tunnel_name,'cisco-authentication']) + config.delete(base + [tunnel_name,'cisco-authentication']) + config.set(base + [tunnel_name,'authentication'], value=auth) + ## Delete Dynamic-map to fqdn + if config.exists(base + [tunnel_name,'dynamic-map']): + config.delete(base + [tunnel_name,'dynamic-map']) + ## Holdtime migration + if config.exists(base + [tunnel_name,'holding-time']): + holdtime = config.return_value(base + [tunnel_name,'holding-time']) + config.delete(base + [tunnel_name,'holding-time']) + config.set(base + [tunnel_name,'holdtime'], value=holdtime) + ## Add network-id + config.set(base + [tunnel_name, 'network-id'], value=networkid) + networkid+=1 + ## Map and nhs migration + nhs_tunnelip_list = [] + nhs_nbmaip_list = [] + is_nhs = False + if config.exists(base + [tunnel_name,'map']): + is_map = False + for tunnel_ip in config.list_nodes(base + [tunnel_name, 'map']): + tunnel_ip_path = base + [tunnel_name, 'map', tunnel_ip] + tunnel_ip = tunnel_ip.split('/')[0] + if config.exists(tunnel_ip_path + ['cisco']): + config.delete(tunnel_ip_path + ['cisco']) + if config.exists(tunnel_ip_path + ['nbma-address']): + nbma = config.return_value(tunnel_ip_path + ['nbma-address']) + if config.exists (tunnel_ip_path + ['register']): + config.delete(tunnel_ip_path + ['register']) + config.delete(tunnel_ip_path + ['nbma-address']) + config.set(base + [tunnel_name, 'nhs', 'tunnel-ip', tunnel_ip, 'nbma'], value=nbma) + is_nhs = True + if tunnel_ip not in nhs_tunnelip_list: + nhs_tunnelip_list.append(tunnel_ip) + if nbma not in nhs_nbmaip_list: + nhs_nbmaip_list.append(nbma) + else: + config.delete(tunnel_ip_path + ['nbma-address']) + config.set(base + [tunnel_name, 'map_test', 'tunnel-ip', tunnel_ip, 'nbma'], value=nbma) + is_map = True + config.delete(base + [tunnel_name,'map']) + + if is_nhs: + config.set_tag(base + [tunnel_name, 'nhs', 'tunnel-ip']) + + if is_map: + config.copy(base + [tunnel_name, 'map_test'], base + [tunnel_name, 'map']) + config.delete(base + [tunnel_name, 'map_test']) + config.set_tag(base + [tunnel_name, 'map', 'tunnel-ip']) + + # + # Change netmask to /32 on tunnel interface + # If nhs is alone, add static route tunnel network to nhs + # + if config.exists(interface_base + [tunnel_name, 'address']): + tunnel_ip_list = [] + for tunnel_ip in config.return_values( + interface_base + [tunnel_name, 'address']): + tunnel_ip_ch = tunnel_ip.split('/')[0]+'/32' + if tunnel_ip_ch not in tunnel_ip_list: + tunnel_ip_list.append(tunnel_ip_ch) + for nhs in nhs_tunnelip_list: + config.set(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop', nhs, 'distance'], value='250') + if nhs_tunnelip_list: + if not config.is_tag(['protocols', 'static', 'route']): + config.set_tag(['protocols', 'static', 'route']) + if not config.is_tag(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop']): + config.set_tag(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop']) + + config.delete(interface_base + [tunnel_name, 'address']) + for tunnel_ip in tunnel_ip_list: + config.set( + interface_base + [tunnel_name, 'address'], value=tunnel_ip, replace=False) + + ## Map multicast migration + if config.exists(base + [tunnel_name, 'multicast']): + multicast_map = config.return_value( + base + [tunnel_name, 'multicast']) + if multicast_map == 'nhs': + config.delete(base + [tunnel_name, 'multicast']) + for nbma in nhs_nbmaip_list: + config.set(base + [tunnel_name, 'multicast'], value=nbma, + replace=False) + + ## Delete non-cahching + if config.exists(base + [tunnel_name, 'non-caching']): + config.delete(base + [tunnel_name, 'non-caching']) + ## Delete shortcut-destination + if config.exists(base + [tunnel_name, 'shortcut-destination']): + if not config.exists(base + [tunnel_name, 'shortcut']): + config.set(base + [tunnel_name, 'shortcut']) + config.delete(base + [tunnel_name, 'shortcut-destination']) + ## Delete shortcut-target + if config.exists(base + [tunnel_name, 'shortcut-target']): + if not config.exists(base + [tunnel_name, 'shortcut']): + config.set(base + [tunnel_name, 'shortcut']) + config.delete(base + [tunnel_name, 'shortcut-target']) + ## Set registration-no-unique + config.set(base + [tunnel_name, 'registration-no-unique'])
\ No newline at end of file diff --git a/src/migration-scripts/ntp/1-to-2 b/src/migration-scripts/ntp/1-to-2 index fd7b08221..d5f800922 100644 --- a/src/migration-scripts/ntp/1-to-2 +++ b/src/migration-scripts/ntp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -25,6 +25,11 @@ def migrate(config: ConfigTree) -> None: # Nothing to do return + # T6911: do not migrate NTP configuration if mandatory server is missing + if not config.exists(base_path + ['server']): + config.delete(base_path) + return + # config.copy does not recursively create a path, so create ['service'] if # it doesn't yet exist, such as for config.boot.default if not config.exists(['service']): diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 20f54df25..b3d7d4dd3 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright (C) 2022-2025 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 @@ -19,7 +19,6 @@ import sys import typing from datetime import datetime -from datetime import timezone from glob import glob from ipaddress import ip_address from tabulate import tabulate @@ -30,137 +29,78 @@ from vyos.base import Warning from vyos.configquery import ConfigTreeQuery from vyos.kea import kea_get_active_config +from vyos.kea import kea_get_dhcp_pools from vyos.kea import kea_get_leases -from vyos.kea import kea_get_pool_from_subnet_id +from vyos.kea import kea_get_server_leases +from vyos.kea import kea_get_static_mappings from vyos.kea import kea_delete_lease -from vyos.utils.process import is_systemd_service_running from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running -time_string = "%a %b %d %H:%M:%S %Z %Y" +time_string = '%a %b %d %H:%M:%S %Z %Y' config = ConfigTreeQuery() -lease_valid_states = ['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] -sort_valid_inet = ['end', 'mac', 'hostname', 'ip', 'pool', 'remaining', 'start', 'state'] -sort_valid_inet6 = ['end', 'duid', 'ip', 'last_communication', 'pool', 'remaining', 'state', 'type'] +lease_valid_states = [ + 'all', + 'active', + 'free', + 'expired', + 'released', + 'abandoned', + 'reset', + 'backup', +] +sort_valid_inet = [ + 'end', + 'mac', + 'hostname', + 'ip', + 'pool', + 'remaining', + 'start', + 'state', +] +sort_valid_inet6 = [ + 'end', + 'duid', + 'ip', + 'last_communication', + 'pool', + 'remaining', + 'state', + 'type', +] mapping_sort_valid = ['mac', 'ip', 'pool', 'duid'] +stale_warn_msg = 'DHCP server is configured but not started. Data may be stale.' + ArgFamily = typing.Literal['inet', 'inet6'] -ArgState = typing.Literal['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] +ArgState = typing.Literal[ + 'all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup' +] ArgOrigin = typing.Literal['local', 'remote'] -def _utc_to_local(utc_dt): - return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds()) - -def _format_hex_string(in_str): - out_str = "" - # if input is divisible by 2, add : every 2 chars - if len(in_str) > 0 and len(in_str) % 2 == 0: - out_str = ':'.join(a+b for a,b in zip(in_str[::2], in_str[1::2])) - else: - out_str = in_str - - return out_str - - -def _find_list_of_dict_index(lst, key='ip', value=''): - """ - Find the index entry of list of dict matching the dict value - Exampe: - % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}] - % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2') - % 1 - """ - idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) - return idx +def _utc_to_local(utc_dt): + return datetime.fromtimestamp( + (datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds() + ) -def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list: - """ - Get DHCP server leases - :return list - """ +def _get_raw_server_leases( + config, family='inet', pool=None, sorted=None, state=[], origin=None +) -> list: inet_suffix = '6' if family == 'inet6' else '4' - try: - leases = kea_get_leases(inet_suffix) - except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server lease information') + pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) - if pool is None: - pool = _get_dhcp_pools(family=family) - else: - pool = [pool] - - try: - active_config = kea_get_active_config(inet_suffix) - except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') - - data = [] - for lease in leases: - lifetime = lease['valid-lft'] - expiry = (lease['cltt'] + lifetime) - - lease['start_timestamp'] = datetime.fromtimestamp(expiry - lifetime, timezone.utc) - lease['expire_timestamp'] = datetime.fromtimestamp(expiry, timezone.utc) if expiry else None - - data_lease = {} - data_lease['ip'] = lease['ip-address'] - lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'} - data_lease['state'] = lease_state_long[lease['state']] - data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet-id']) if active_config else '-' - data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None - data_lease['origin'] = 'local' # TODO: Determine remote in HA - data_lease['hostname'] = lease.get('hostname', '-') - # remove trailing dot to ensure consistency for `vyos-hostsd-client` - if data_lease['hostname'][-1] == '.': - data_lease['hostname'] = data_lease['hostname'][:-1] - - if family == 'inet': - data_lease['mac'] = lease['hw-address'] - data_lease['start'] = lease['start_timestamp'].timestamp() - - if family == 'inet6': - data_lease['last_communication'] = lease['start_timestamp'].timestamp() - data_lease['duid'] = _format_hex_string(lease['duid']) - data_lease['type'] = lease['type'] - - if lease['type'] == 'IA_PD': - prefix_len = lease['prefix-len'] - data_lease['ip'] += f'/{prefix_len}' - - data_lease['remaining'] = '-' - - if lease['valid-lft'] > 0: - data_lease['remaining'] = lease['expire_timestamp'] - datetime.now(timezone.utc) - - if data_lease['remaining'].days >= 0: - # substraction gives us a timedelta object which can't be formatted with strftime - # so we use str(), split gets rid of the microseconds - data_lease['remaining'] = str(data_lease['remaining']).split('.')[0] - - # Do not add old leases - if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free': - if not state or state == 'all' or data_lease['state'] in state: - data.append(data_lease) - - # deduplicate - checked = [] - for entry in data: - addr = entry.get('ip') - if addr not in checked: - checked.append(addr) - else: - idx = _find_list_of_dict_index(data, key='ip', value=addr) - if idx is not None: - data.pop(idx) + mappings = kea_get_server_leases(config, inet_suffix, pools, state, origin) if sorted: if sorted == 'ip': - data.sort(key = lambda x:ip_address(x['ip'])) + mappings.sort(key=lambda x: ip_address(x['ip'])) else: - data.sort(key = lambda x:x[sorted]) - return data + mappings.sort(key=lambda x: x[sorted]) + return mappings def _get_formatted_server_leases(raw_data, family='inet'): @@ -171,45 +111,60 @@ def _get_formatted_server_leases(raw_data, family='inet'): hw_addr = lease.get('mac') state = lease.get('state') start = lease.get('start') - start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') + start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') end = lease.get('end') - end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-' + end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-' remain = lease.get('remaining') pool = lease.get('pool') hostname = lease.get('hostname') origin = lease.get('origin') - data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin]) - - headers = ['IP Address', 'MAC address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool', - 'Hostname', 'Origin'] + data_entries.append( + [ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin] + ) + + headers = [ + 'IP Address', + 'MAC address', + 'State', + 'Lease start', + 'Lease expiration', + 'Remaining', + 'Pool', + 'Hostname', + 'Origin', + ] if family == 'inet6': for lease in raw_data: ipaddr = lease.get('ip') state = lease.get('state') start = lease.get('last_communication') - start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') + start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') end = lease.get('end') - end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') + end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') host_identifier = lease.get('duid') - data_entries.append([ipaddr, state, start, end, remain, lease_type, pool, host_identifier]) - - headers = ['IPv6 address', 'State', 'Last communication', 'Lease expiration', 'Remaining', 'Type', 'Pool', - 'DUID'] + data_entries.append( + [ipaddr, state, start, end, remain, lease_type, pool, host_identifier] + ) + + headers = [ + 'IPv6 address', + 'State', + 'Last communication', + 'Lease expiration', + 'Remaining', + 'Type', + 'Pool', + 'DUID', + ] output = tabulate(data_entries, headers, numalign='left') return output -def _get_dhcp_pools(family='inet') -> list: - v = 'v6' if family == 'inet6' else '' - pools = config.list_nodes(f'service dhcp{v}-server shared-network-name') - return pools - - def _get_pool_size(pool, family='inet'): v = 'v6' if family == 'inet6' else '' base = f'service dhcp{v}-server shared-network-name {pool}' @@ -229,26 +184,27 @@ def _get_pool_size(pool, family='inet'): return size -def _get_raw_pool_statistics(family='inet', pool=None): - if pool is None: - pool = _get_dhcp_pools(family=family) - else: - pool = [pool] +def _get_raw_server_pool_statistics(config, family='inet', pool=None): + inet_suffix = '6' if family == 'inet6' else '4' + pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) - v = 'v6' if family == 'inet6' else '' stats = [] - for p in pool: - subnet = config.list_nodes(f'service dhcp{v}-server shared-network-name {p} subnet') + for p in pools: size = _get_pool_size(family=family, pool=p) - leases = len(_get_raw_server_leases(family=family, pool=p)) + leases = len(_get_raw_server_leases(config, family=family, pool=p)) use_percentage = round(leases / size * 100) if size != 0 else 0 - pool_stats = {'pool': p, 'size': size, 'leases': leases, - 'available': (size - leases), 'use_percentage': use_percentage, 'subnet': subnet} + pool_stats = { + 'pool': p, + 'size': size, + 'leases': leases, + 'available': (size - leases), + 'use_percentage': use_percentage, + } stats.append(pool_stats) return stats -def _get_formatted_pool_statistics(pool_data, family='inet'): +def _get_formatted_server_pool_statistics(pool_data, family='inet'): data_entries = [] for entry in pool_data: pool = entry.get('pool') @@ -259,67 +215,54 @@ def _get_formatted_pool_statistics(pool_data, family='inet'): use_percentage = f'{use_percentage}%' data_entries.append([pool, size, leases, available, use_percentage]) - headers = ['Pool', 'Size','Leases', 'Available', 'Usage'] + headers = ['Pool', 'Size', 'Leases', 'Available', 'Usage'] output = tabulate(data_entries, headers, numalign='left') return output -def _get_raw_server_static_mappings(family='inet', pool=None, sorted=None): - if pool is None: - pool = _get_dhcp_pools(family=family) - else: - pool = [pool] - v = 'v6' if family == 'inet6' else '' - mappings = [] - for p in pool: - pool_config = config.get_config_dict(['service', f'dhcp{v}-server', 'shared-network-name', p], - get_first_key=True) - if 'subnet' in pool_config: - for subnet, subnet_config in pool_config['subnet'].items(): - if 'static-mapping' in subnet_config: - for name, mapping_config in subnet_config['static-mapping'].items(): - mapping = {'pool': p, 'subnet': subnet, 'name': name} - mapping.update(mapping_config) - mappings.append(mapping) +def _get_raw_server_static_mappings(config, family='inet', pool=None, sorted=None): + inet_suffix = '6' if family == 'inet6' else '4' + pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) + + mappings = kea_get_static_mappings(config, inet_suffix, pools) if sorted: if sorted == 'ip': - if family == 'inet6': - mappings.sort(key = lambda x:ip_address(x['ipv6-address'])) - else: - mappings.sort(key = lambda x:ip_address(x['ip-address'])) + mappings.sort(key=lambda x: ip_address(x['ip'])) else: - mappings.sort(key = lambda x:x[sorted]) + mappings.sort(key=lambda x: x[sorted]) return mappings + def _get_formatted_server_static_mappings(raw_data, family='inet'): data_entries = [] - if family == 'inet': - for entry in raw_data: - pool = entry.get('pool') - subnet = entry.get('subnet') - name = entry.get('name') - ip_addr = entry.get('ip-address', 'N/A') - mac_addr = entry.get('mac', 'N/A') - duid = entry.get('duid', 'N/A') - description = entry.get('description', 'N/A') - data_entries.append([pool, subnet, name, ip_addr, mac_addr, duid, description]) - elif family == 'inet6': - for entry in raw_data: - pool = entry.get('pool') - subnet = entry.get('subnet') - name = entry.get('name') - ip_addr = entry.get('ipv6-address', 'N/A') - mac_addr = entry.get('mac', 'N/A') - duid = entry.get('duid', 'N/A') - description = entry.get('description', 'N/A') - data_entries.append([pool, subnet, name, ip_addr, mac_addr, duid, description]) - - headers = ['Pool', 'Subnet', 'Name', 'IP Address', 'MAC Address', 'DUID', 'Description'] + + for entry in raw_data: + pool = entry.get('pool') + subnet = entry.get('subnet') + hostname = entry.get('hostname') + ip_addr = entry.get('ip', 'N/A') + mac_addr = entry.get('mac', 'N/A') + duid = entry.get('duid', 'N/A') + description = entry.get('description', 'N/A') + data_entries.append( + [pool, subnet, hostname, ip_addr, mac_addr, duid, description] + ) + + headers = [ + 'Pool', + 'Subnet', + 'Hostname', + 'IP Address', + 'MAC Address', + 'DUID', + 'Description', + ] output = tabulate(data_entries, headers, numalign='left') return output -def _verify(func): + +def _verify_server(func): """Decorator checks if DHCP(v6) config exists""" from functools import wraps @@ -333,8 +276,10 @@ def _verify(func): if not config.exists(f'service dhcp{v}-server'): raise vyos.opmode.UnconfiguredSubsystem(unconf_message) return func(*args, **kwargs) + return _wrapper + def _verify_client(func): """Decorator checks if interface is configured as DHCP client""" from functools import wraps @@ -353,67 +298,124 @@ def _verify_client(func): if not config.exists(f'interfaces {interface_path} address dhcp{v}'): raise vyos.opmode.UnconfiguredObject(unconf_message) return func(*args, **kwargs) + return _wrapper -@_verify -def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str]): - pool_data = _get_raw_pool_statistics(family=family, pool=pool) + +@_verify_server +def show_server_pool_statistics( + raw: bool, family: ArgFamily, pool: typing.Optional[str] +): + v = 'v6' if family == 'inet6' else '' + inet_suffix = '6' if family == 'inet6' else '4' + + if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + Warning(stale_warn_msg) + + try: + active_config = kea_get_active_config(inet_suffix) + except Exception: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + + active_pools = kea_get_dhcp_pools(active_config, inet_suffix) + + if pool and active_pools and pool not in active_pools: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + + pool_data = _get_raw_server_pool_statistics(active_config, family=family, pool=pool) if raw: return pool_data else: - return _get_formatted_pool_statistics(pool_data, family=family) + return _get_formatted_server_pool_statistics(pool_data, family=family) + + +@_verify_server +def show_server_leases( + raw: bool, + family: ArgFamily, + pool: typing.Optional[str], + sorted: typing.Optional[str], + state: typing.Optional[ArgState], + origin: typing.Optional[ArgOrigin], +): + v = 'v6' if family == 'inet6' else '' + inet_suffix = '6' if family == 'inet6' else '4' + if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + Warning(stale_warn_msg) -@_verify -def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str], - sorted: typing.Optional[str], state: typing.Optional[ArgState], - origin: typing.Optional[ArgOrigin] ): - # if dhcp server is down, inactive leases may still be shown as active, so warn the user. - v = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{v}-server.service'): - Warning('DHCP server is configured but not started. Data may be stale.') + try: + active_config = kea_get_active_config(inet_suffix) + except Exception: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') - v = 'v6' if family == 'inet6' else '' - if pool and pool not in _get_dhcp_pools(family=family): - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + active_pools = kea_get_dhcp_pools(active_config, inet_suffix) - if state and state not in lease_valid_states: - raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!') + if pool and active_pools and pool not in active_pools: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet if sorted and sorted not in sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') - lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state, origin=origin) + if state and state not in lease_valid_states: + raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!') + + lease_data = _get_raw_server_leases( + config=active_config, + family=family, + pool=pool, + sorted=sorted, + state=state, + origin=origin, + ) if raw: return lease_data else: return _get_formatted_server_leases(lease_data, family=family) -@_verify -def show_server_static_mappings(raw: bool, family: ArgFamily, pool: typing.Optional[str], - sorted: typing.Optional[str]): + +@_verify_server +def show_server_static_mappings( + raw: bool, + family: ArgFamily, + pool: typing.Optional[str], + sorted: typing.Optional[str], +): v = 'v6' if family == 'inet6' else '' - if pool and pool not in _get_dhcp_pools(family=family): + inet_suffix = '6' if family == 'inet6' else '4' + + if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + Warning(stale_warn_msg) + + try: + active_config = kea_get_active_config(inet_suffix) + except Exception: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + + active_pools = kea_get_dhcp_pools(active_config, inet_suffix) + + if pool and active_pools and pool not in active_pools: raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') if sorted and sorted not in mapping_sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') - static_mappings = _get_raw_server_static_mappings(family=family, pool=pool, sorted=sorted) + static_mappings = _get_raw_server_static_mappings( + config=active_config, family=family, pool=pool, sorted=sorted + ) if raw: return static_mappings else: return _get_formatted_server_static_mappings(static_mappings, family=family) + def _lease_valid(inet, address): leases = kea_get_leases(inet) - for lease in leases: - if address == lease['ip-address']: - return True - return False + return any(lease['ip-address'] == address for lease in leases) + -@_verify +@_verify_server def clear_dhcp_server_lease(family: ArgFamily, address: str): v = 'v6' if family == 'inet6' else '' inet = '6' if family == 'inet6' else '4' @@ -428,6 +430,7 @@ def clear_dhcp_server_lease(family: ArgFamily, address: str): print(f'Lease "{address}" has been cleared') + def _get_raw_client_leases(family='inet', interface=None): from time import mktime from datetime import datetime @@ -456,21 +459,28 @@ def _get_raw_client_leases(family='inet', interface=None): # format this makes less sense for an API and also the expiry # timestamp is provided in UNIX time. Convert string (e.g. Sun Jul # 30 18:13:44 CEST 2023) to UNIX time (1690733624) - tmp.update({'last_update' : int(mktime(datetime.strptime(line, time_string).timetuple()))}) + tmp.update( + { + 'last_update': int( + mktime(datetime.strptime(line, time_string).timetuple()) + ) + } + ) continue k, v = line.split('=') - tmp.update({k : v.replace("'", "")}) + tmp.update({k: v.replace("'", '')}) if 'interface' in tmp: vrf = get_interface_vrf(tmp['interface']) if vrf: - tmp.update({'vrf' : vrf}) + tmp.update({'vrf': vrf}) lease_data.append(tmp) return lease_data + def _get_formatted_client_leases(lease_data, family): from time import localtime from time import strftime @@ -481,30 +491,34 @@ def _get_formatted_client_leases(lease_data, family): for lease in lease_data: if not lease.get('new_ip_address'): continue - data_entries.append(["Interface", lease['interface']]) + data_entries.append(['Interface', lease['interface']]) if 'new_ip_address' in lease: - tmp = '[Active]' if is_intf_addr_assigned(lease['interface'], lease['new_ip_address']) else '[Inactive]' - data_entries.append(["IP address", lease['new_ip_address'], tmp]) + tmp = ( + '[Active]' + if is_intf_addr_assigned(lease['interface'], lease['new_ip_address']) + else '[Inactive]' + ) + data_entries.append(['IP address', lease['new_ip_address'], tmp]) if 'new_subnet_mask' in lease: - data_entries.append(["Subnet Mask", lease['new_subnet_mask']]) + data_entries.append(['Subnet Mask', lease['new_subnet_mask']]) if 'new_domain_name' in lease: - data_entries.append(["Domain Name", lease['new_domain_name']]) + data_entries.append(['Domain Name', lease['new_domain_name']]) if 'new_routers' in lease: - data_entries.append(["Router", lease['new_routers']]) + data_entries.append(['Router', lease['new_routers']]) if 'new_domain_name_servers' in lease: - data_entries.append(["Name Server", lease['new_domain_name_servers']]) + data_entries.append(['Name Server', lease['new_domain_name_servers']]) if 'new_dhcp_server_identifier' in lease: - data_entries.append(["DHCP Server", lease['new_dhcp_server_identifier']]) + data_entries.append(['DHCP Server', lease['new_dhcp_server_identifier']]) if 'new_dhcp_lease_time' in lease: - data_entries.append(["DHCP Server", lease['new_dhcp_lease_time']]) + data_entries.append(['DHCP Server', lease['new_dhcp_lease_time']]) if 'vrf' in lease: - data_entries.append(["VRF", lease['vrf']]) + data_entries.append(['VRF', lease['vrf']]) if 'last_update' in lease: tmp = strftime(time_string, localtime(int(lease['last_update']))) - data_entries.append(["Last Update", tmp]) + data_entries.append(['Last Update', tmp]) if 'new_expiry' in lease: tmp = strftime(time_string, localtime(int(lease['new_expiry']))) - data_entries.append(["Expiry", tmp]) + data_entries.append(['Expiry', tmp]) # Add empty marker data_entries.append(['']) @@ -513,6 +527,7 @@ def _get_formatted_client_leases(lease_data, family): return output + def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[str]): lease_data = _get_raw_client_leases(family=family, interface=interface) if raw: @@ -520,6 +535,7 @@ def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[ else: return _get_formatted_client_leases(lease_data, family=family) + @_verify_client def renew_client_lease(raw: bool, family: ArgFamily, interface: str): if not raw: @@ -530,6 +546,7 @@ def renew_client_lease(raw: bool, family: ArgFamily, interface: str): else: call(f'systemctl restart dhclient@{interface}.service') + @_verify_client def release_client_lease(raw: bool, family: ArgFamily, interface: str): if not raw: @@ -540,6 +557,7 @@ def release_client_lease(raw: bool, family: ArgFamily, interface: str): else: call(f'systemctl stop dhclient@{interface}.service') + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 02ba126b4..1ab50b105 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright (C) 2022-2025 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 @@ -700,15 +700,6 @@ def reset_profile_dst(profile: str, tunnel: str, nbma_dst: str): ] ) ) - # initiate IKE SAs - for ike in sa_nbma_list: - if ike_sa_name in ike: - vyos.ipsec.vici_initiate( - ike_sa_name, - 'dmvpn', - ike[ike_sa_name]['local-host'], - ike[ike_sa_name]['remote-host'], - ) print( f'Profile {profile} tunnel {tunnel} remote-host {nbma_dst} reset result: success' ) @@ -732,18 +723,6 @@ def reset_profile_all(profile: str, tunnel: str): ) # terminate IKE SAs vyos.ipsec.terminate_vici_by_name(ike_sa_name, None) - # initiate IKE SAs - for ike in sa_list: - if ike_sa_name in ike: - vyos.ipsec.vici_initiate( - ike_sa_name, - 'dmvpn', - ike[ike_sa_name]['local-host'], - ike[ike_sa_name]['remote-host'], - ) - print( - f'Profile {profile} tunnel {tunnel} remote-host {ike[ike_sa_name]["remote-host"]} reset result: success' - ) print(f'Profile {profile} tunnel {tunnel} reset result: success') except vyos.ipsec.ViciInitiateError as err: raise vyos.opmode.UnconfiguredSubsystem(err) diff --git a/src/op_mode/nhrp.py b/src/op_mode/nhrp.py deleted file mode 100755 index e66f33079..000000000 --- a/src/op_mode/nhrp.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2023 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 <http://www.gnu.org/licenses/>. - -import sys -import tabulate -import vyos.opmode - -from vyos.utils.process import cmd -from vyos.utils.process import process_named_running -from vyos.utils.dict import colon_separated_to_dict - - -def _get_formatted_output(output_dict: dict) -> str: - """ - Create formatted table for CLI output - :param output_dict: dictionary for API - :type output_dict: dict - :return: tabulate string - :rtype: str - """ - print(f"Status: {output_dict['Status']}") - output: str = tabulate.tabulate(output_dict['routes'], headers='keys', - numalign="left") - return output - - -def _get_formatted_dict(output_string: str) -> dict: - """ - Format string returned from CMD to API list - :param output_string: String received by CMD - :type output_string: str - :return: dictionary for API - :rtype: dict - """ - formatted_dict: dict = { - 'Status': '', - 'routes': [] - } - output_list: list = output_string.split('\n\n') - for list_a in output_list: - output_dict = colon_separated_to_dict(list_a, True) - if 'Status' in output_dict: - formatted_dict['Status'] = output_dict['Status'] - else: - formatted_dict['routes'].append(output_dict) - return formatted_dict - - -def show_interface(raw: bool): - """ - Command 'show nhrp interface' - :param raw: if API - :type raw: bool - """ - if not process_named_running('opennhrp'): - raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.') - interface_string: str = cmd('sudo opennhrpctl interface show') - interface_dict: dict = _get_formatted_dict(interface_string) - if raw: - return interface_dict - else: - return _get_formatted_output(interface_dict) - - -def show_tunnel(raw: bool): - """ - Command 'show nhrp tunnel' - :param raw: if API - :type raw: bool - """ - if not process_named_running('opennhrp'): - raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.') - tunnel_string: str = cmd('sudo opennhrpctl show') - tunnel_dict: list = _get_formatted_dict(tunnel_string) - if raw: - return tunnel_dict - else: - return _get_formatted_output(tunnel_dict) - - -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/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py new file mode 100755 index 000000000..1fcfb31b5 --- /dev/null +++ b/src/op_mode/reset_wireguard.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +import sys +import typing + +import vyos.opmode + +from vyos.ifconfig import WireGuardIf +from vyos.configquery import ConfigTreeQuery + + +def _verify(func): + """Decorator checks if WireGuard interface config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + interface = kwargs.get('interface') + if not config.exists(['interfaces', 'wireguard', interface]): + unconf_message = f'WireGuard interface {interface} is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + + return _wrapper + + +@_verify +def reset_peer(interface: str, peer: typing.Optional[str] = None): + intf = WireGuardIf(interface, create=False, debug=False) + return intf.operational.reset_peer(peer) + + +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/op_mode/vtysh_wrapper.sh b/src/op_mode/vtysh_wrapper.sh index 25d09ce77..bc472f7bb 100755 --- a/src/op_mode/vtysh_wrapper.sh +++ b/src/op_mode/vtysh_wrapper.sh @@ -2,5 +2,5 @@ declare -a tmp # FRR uses ospf6 where we use ospfv3, and we use reset over clear for BGP, # thus alter the commands -tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/") +tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/"| sed -e "s/^reset ip nhrp/clear ip nhrp/") vtysh -c "$tmp" diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index bc74a05d1..fe0f40a07 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -22,8 +22,10 @@ from vyos.configdict import dict_merge from vyos.configquery import ConfigTreeQuery from vyos.firewall import fqdn_config_parse from vyos.firewall import fqdn_resolve +from vyos.ifconfig import WireGuardIf from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args +from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults @@ -33,6 +35,7 @@ timeout = 300 cache = False base_firewall = ['firewall'] base_nat = ['nat'] +base_interfaces = ['interfaces'] domain_state = {} @@ -171,8 +174,45 @@ def update_fqdn(config, node): logger.info(f'Updated {count} sets in {node} - result: {code}') +def update_interfaces(config, node): + if node == 'interfaces': + wg_interfaces = dict_search_args(config, 'wireguard') + + peer_public_keys = {} + # for each wireguard interfaces + for interface, wireguard in wg_interfaces.items(): + peer_public_keys[interface] = [] + for peer, peer_config in wireguard['peer'].items(): + # check peer if peer host-name or address is set + if 'host_name' in peer_config or 'address' in peer_config: + # check latest handshake + peer_public_keys[interface].append( + peer_config['public_key'] + ) + + now_time = time.time() + for (interface, check_peer_public_keys) in peer_public_keys.items(): + if len(check_peer_public_keys) == 0: + continue + + intf = WireGuardIf(interface, create=False, debug=False) + handshakes = intf.operational.get_latest_handshakes() + + # WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME + # if data is being transmitted between the peers. If no data is + # transmitted, the handshake will not be initiated unless new + # data begins to flow. Each handshake generates a new session + # key, and the key is rotated at least every 120 seconds or + # upon data transmission after a prolonged silence. + for public_key, handshake_time in handshakes.items(): + if public_key in check_peer_public_keys and ( + handshake_time == 0 + or (now_time - handshake_time > 3*WIREGUARD_REKEY_AFTER_TIME) + ): + intf.operational.reset_peer(public_key=public_key) + if __name__ == '__main__': - logger.info(f'VyOS domain resolver') + logger.info('VyOS domain resolver') count = 1 while commit_in_progress(): @@ -184,10 +224,12 @@ if __name__ == '__main__': conf = ConfigTreeQuery() firewall = get_config(conf, base_firewall) nat = get_config(conf, base_nat) + interfaces = get_config(conf, base_interfaces) logger.info(f'interval: {timeout}s - cache: {cache}') while True: update_fqdn(firewall, 'firewall') update_fqdn(nat, 'nat') + update_interfaces(interfaces, 'interfaces') time.sleep(timeout) diff --git a/src/services/vyos-network-event-logger b/src/services/vyos-network-event-logger new file mode 100644 index 000000000..840ff3cda --- /dev/null +++ b/src/services/vyos-network-event-logger @@ -0,0 +1,1218 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 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 <http://www.gnu.org/licenses/>. + +import argparse +import logging +import multiprocessing +import queue +import signal +import socket +import threading +from pathlib import Path +from time import sleep +from typing import Dict, AnyStr, List, Union + +from pyroute2.common import AF_MPLS +from pyroute2.iproute import IPRoute +from pyroute2.netlink import rtnl, nlmsg +from pyroute2.netlink.nfnetlink.nfctsocket import nfct_msg +from pyroute2.netlink.rtnl import (rt_proto as RT_PROTO, rt_type as RT_TYPES, + rtypes as RTYPES + ) +from pyroute2.netlink.rtnl.fibmsg import FR_ACT_GOTO, FR_ACT_NOP, FR_ACT_TO_TBL, \ + fibmsg +from pyroute2.netlink.rtnl import ifaddrmsg +from pyroute2.netlink.rtnl import ifinfmsg +from pyroute2.netlink.rtnl import ndmsg +from pyroute2.netlink.rtnl import rtmsg +from pyroute2.netlink.rtnl.rtmsg import nh, rtmsg_base + +from vyos.include.uapi.linux.fib_rules import * +from vyos.include.uapi.linux.icmpv6 import * +from vyos.include.uapi.linux.if_arp import * +from vyos.include.uapi.linux.lwtunnel import * +from vyos.include.uapi.linux.neighbour import * +from vyos.include.uapi.linux.rtnetlink import * + +from vyos.utils.file import read_json + + +manager = multiprocessing.Manager() +cache = manager.dict() + + +class UnsupportedMessageType(Exception): + pass + +shutdown_event = multiprocessing.Event() + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +class DebugFormatter(logging.Formatter): + def format(self, record): + self._style._fmt = '[%(asctime)s] %(levelname)s: %(message)s' + return super().format(record) + + +def set_log_level(level: str) -> None: + if level == 'debug': + logger.setLevel(logging.DEBUG) + logger.parent.handlers[0].setFormatter(DebugFormatter()) + else: + logger.setLevel(logging.INFO) + +IFF_FLAGS = { + 'RUNNING': ifinfmsg.IFF_RUNNING, + 'LOOPBACK': ifinfmsg.IFF_LOOPBACK, + 'BROADCAST': ifinfmsg.IFF_BROADCAST, + 'POINTOPOINT': ifinfmsg.IFF_POINTOPOINT, + 'MULTICAST': ifinfmsg.IFF_MULTICAST, + 'NOARP': ifinfmsg.IFF_NOARP, + 'ALLMULTI': ifinfmsg.IFF_ALLMULTI, + 'PROMISC': ifinfmsg.IFF_PROMISC, + 'MASTER': ifinfmsg.IFF_MASTER, + 'SLAVE': ifinfmsg.IFF_SLAVE, + 'DEBUG': ifinfmsg.IFF_DEBUG, + 'DYNAMIC': ifinfmsg.IFF_DYNAMIC, + 'AUTOMEDIA': ifinfmsg.IFF_AUTOMEDIA, + 'PORTSEL': ifinfmsg.IFF_PORTSEL, + 'NOTRAILERS': ifinfmsg.IFF_NOTRAILERS, + 'UP': ifinfmsg.IFF_UP, + 'LOWER_UP': ifinfmsg.IFF_LOWER_UP, + 'DORMANT': ifinfmsg.IFF_DORMANT, + 'ECHO': ifinfmsg.IFF_ECHO, +} + +NEIGH_STATE_FLAGS = { + 'INCOMPLETE': ndmsg.NUD_INCOMPLETE, + 'REACHABLE': ndmsg.NUD_REACHABLE, + 'STALE': ndmsg.NUD_STALE, + 'DELAY': ndmsg.NUD_DELAY, + 'PROBE': ndmsg.NUD_PROBE, + 'FAILED': ndmsg.NUD_FAILED, + 'NOARP': ndmsg.NUD_NOARP, + 'PERMANENT': ndmsg.NUD_PERMANENT, +} + +IFA_FLAGS = { + 'secondary': ifaddrmsg.IFA_F_SECONDARY, + 'temporary': ifaddrmsg.IFA_F_SECONDARY, + 'nodad': ifaddrmsg.IFA_F_NODAD, + 'optimistic': ifaddrmsg.IFA_F_OPTIMISTIC, + 'dadfailed': ifaddrmsg.IFA_F_DADFAILED, + 'home': ifaddrmsg.IFA_F_HOMEADDRESS, + 'deprecated': ifaddrmsg.IFA_F_DEPRECATED, + 'tentative': ifaddrmsg.IFA_F_TENTATIVE, + 'permanent': ifaddrmsg.IFA_F_PERMANENT, + 'mngtmpaddr': ifaddrmsg.IFA_F_MANAGETEMPADDR, + 'noprefixroute': ifaddrmsg.IFA_F_NOPREFIXROUTE, + 'autojoin': ifaddrmsg.IFA_F_MCAUTOJOIN, + 'stable-privacy': ifaddrmsg.IFA_F_STABLE_PRIVACY, +} + +RT_SCOPE_TO_NAME = { + rtmsg.RT_SCOPE_UNIVERSE: 'global', + rtmsg.RT_SCOPE_SITE: 'site', + rtmsg.RT_SCOPE_LINK: 'link', + rtmsg.RT_SCOPE_HOST: 'host', + rtmsg.RT_SCOPE_NOWHERE: 'nowhere', +} + +FAMILY_TO_NAME = { + socket.AF_INET: 'inet', + socket.AF_INET6: 'inet6', + socket.AF_PACKET: 'link', + AF_MPLS: 'mpls', + socket.AF_BRIDGE: 'bridge', +} + +_INFINITY = 4294967295 + + +def _get_iif_name(idx: int) -> str: + """ + Retrieves the interface name associated with a given index. + """ + try: + if_info = IPRoute().link("get", index=idx) + if if_info: + return if_info[0].get_attr('IFLA_IFNAME') + except Exception as e: + pass + + return '' + + +def remember_if_index(idx: int, event_type: int) -> None: + """ + Manages the caching of network interface names based on their index and event type. + + - For RTM_DELLINK event, the interface name is removed from the cache if exists. + - For RTM_NEWLINK event, the interface name is retrieved and updated in the cache. + """ + name = cache.get(idx) + if name: + if event_type == rtnl.RTM_DELLINK: + del cache[idx] + else: + name = _get_iif_name(idx) + if name: + cache[idx] = name + else: + cache[idx] = _get_iif_name(idx) + + +class BaseFormatter: + """ + A base class providing utility methods for formatting network message data. + """ + def _get_if_name_by_index(self, idx: int) -> str: + """ + Retrieves the name of a network interface based on its index. + + Uses a cached lookup for efficiency. If the name is not found in the cache, + it queries the system and updates the cache. + """ + if_name = cache.get(idx) + if not if_name: + if_name = _get_iif_name(idx) + cache[idx] = if_name + + return if_name + + def _format_rttable(self, idx: int) -> str: + """ + Formats a route table identifier into a readable name. + """ + return f'{RT_TABLE_TO_NAME.get(idx, idx)}' + + def _parse_flag(self, data: int, flags: dict) -> list: + """ + Extracts and returns flag names equal the bits set in a numeric value. + """ + result = list() + if data: + for key, val in flags.items(): + if data & val: + result.append(key) + data &= ~val + + if data: + result.append(f"{data:#x}") + + return result + + def af_bit_len(self, af: int) -> int: + """ + Gets the bit length of a given address family. + Supports common address families like IPv4, IPv6, and MPLS. + """ + _map = { + socket.AF_INET6: 128, + socket.AF_INET: 32, + AF_MPLS: 20, + } + + return _map.get(af) + + def _format_simple_field(self, data: str, prefix: str='') -> str: + """ + Formats a simple field with an optional prefix. + + A simple field represents a value that does not require additional + parsing and is used as is. + """ + return self._output(f'{prefix} {data}') if data is not None else '' + + def _output(self, data: str) -> str: + """ + Standardizes the output format. + + Ensures that the output is enclosed with single spaces and has no leading + or trailing whitespace. + """ + return f' {data.strip()} ' if data else '' + + +class BaseMSGFormatter(BaseFormatter): + """ + A base formatter class for network messages. + This class provides common methods for formatting network-related messages, + """ + + def _prepare_start_message(self, event: str) -> str: + """ + Prepares a starting message string based on the event type. + """ + if event in ['RTM_DELROUTE', 'RTM_DELLINK', 'RTM_DELNEIGH', + 'RTM_DELADDR', 'RTM_DELADDRLABEL', 'RTM_DELRULE', + 'RTM_DELNETCONF']: + return 'Deleted ' + if event == 'RTM_GETNEIGH': + return 'Miss ' + return '' + + def _format_flow_field(self, data: int) -> str: + """ + Formats a flow field to represent traffic realms. + """ + to = data & 0xFFFF + from_ = data >> 16 + result = f"realm{'s' if from_ else ''} " + if from_: + result += f'{from_}/' + result += f'{to}' + + return self._output(result) + + def format(self, msg: nlmsg) -> str: + """ + Abstract method to format a complete message. + + This method must be implemented by subclasses to provide specific formatting + logic for different types of messages. + """ + raise NotImplementedError(f'{msg.get("event")}: {msg}') + + +class LinkFormatter(BaseMSGFormatter): + """ + A formatter class for handling link-related network messages + `RTM_NEWLINK` and `RTM_DELLINK`. + """ + def _format_iff_flags(self, flags: int) -> str: + """ + Formats interface flags into a human-readable string. + """ + result = list() + if flags: + if flags & IFF_FLAGS['UP'] and not flags & IFF_FLAGS['RUNNING']: + result.append('NO-CARRIER') + + flags &= ~IFF_FLAGS['RUNNING'] + + result.extend(self._parse_flag(flags, IFF_FLAGS)) + + return self._output(f'<{(",").join(result)}>') + + def _format_if_props(self, data: ifinfmsg.ifinfbase.proplist) -> str: + """ + Formats interface alternative name properties. + """ + result = '' + for rec in data.altnames(): + result += f'[altname {rec}] ' + return self._output(result) + + def _format_link(self, msg: ifinfmsg.ifinfmsg) -> str: + """ + Formats the link attribute of a network interface message. + """ + if msg.get_attr("IFLA_LINK") is not None: + iflink = msg.get_attr("IFLA_LINK") + if iflink: + if msg.get_attr("IFLA_LINK_NETNSID"): + return f'if{iflink}' + else: + return self._get_if_name_by_index(iflink) + return 'NONE' + + def _format_link_info(self, msg: ifinfmsg.ifinfmsg) -> str: + """ + Formats detailed information about the link, including type, address, + broadcast address, and permanent address. + """ + result = f'link/{ARPHRD_TO_NAME.get(msg.get("ifi_type"), msg.get("ifi_type"))}' + result += self._format_simple_field(msg.get_attr('IFLA_ADDRESS')) + + if msg.get_attr("IFLA_BROADCAST"): + if msg.get('flags') & ifinfmsg.IFF_POINTOPOINT: + result += f' peer' + else: + result += f' brd' + result += f' {msg.get_attr("IFLA_BROADCAST")}' + + if msg.get_attr("IFLA_PERM_ADDRESS"): + if not msg.get_attr("IFLA_ADDRESS") or \ + msg.get_attr("IFLA_ADDRESS") != msg.get_attr("IFLA_PERM_ADDRESS"): + result += f' permaddr {msg.get_attr("IFLA_PERM_ADDRESS")}' + + return self._output(result) + + def format(self, msg: ifinfmsg.ifinfmsg): + """ + Formats a network link message into a structured output string. + """ + if msg.get("family") not in [socket.AF_UNSPEC, socket.AF_BRIDGE]: + return None + + message = self._prepare_start_message(msg.get('event')) + + link = self._format_link(msg) + + message += f'{msg.get("index")}: {msg.get_attr("IFLA_IFNAME")}' + message += f'@{link}' if link else '' + message += f': {self._format_iff_flags(msg.get("flags"))}' + + message += self._format_simple_field(msg.get_attr('IFLA_MTU'), prefix='mtu') + message += self._format_simple_field(msg.get_attr('IFLA_QDISC'), prefix='qdisc') + message += self._format_simple_field(msg.get_attr('IFLA_OPERSTATE'), prefix='state') + message += self._format_simple_field(msg.get_attr('IFLA_GROUP'), prefix='group') + message += self._format_simple_field(msg.get_attr('IFLA_MASTER'), prefix='master') + + message += self._format_link_info(msg) + + if msg.get_attr('IFLA_PROP_LIST'): + message += self._format_if_props(msg.get_attr('IFLA_PROP_LIST')) + + return self._output(message) + + +class EncapFormatter(BaseFormatter): + """ + A formatter class for handling encapsulation attributes in routing messages. + """ + # TODO: implement other lwtunnel decoder in pyroute2 + # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L657 + def __init__(self): + """ + Initializes the EncapFormatter with supported encapsulation types. + """ + self.formatters = { + rtmsg.LWTUNNEL_ENCAP_MPLS: self.mpls_format, + rtmsg.LWTUNNEL_ENCAP_SEG6: self.seg6_format, + rtmsg.LWTUNNEL_ENCAP_BPF: self.bpf_format, + rtmsg.LWTUNNEL_ENCAP_SEG6_LOCAL: self.seg6local_format, + } + + def _format_srh(self, data: rtmsg_base.seg6_encap_info.ipv6_sr_hdr): + """ + Formats Segment Routing Header (SRH) attributes. + """ + result = '' + # pyroute2 decode mode only as inline or encap (encap, l2encap, encap.red, l2encap.red") + # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L220 + for key in ['mode', 'segs']: + + val = data.get(key) + + if val: + if key == 'segs': + result += f'{key} {len(val)} {val} ' + else: + result += f'{key} {val} ' + + return self._output(result) + + def _format_bpf_object(self, data: rtmsg_base.bpf_encap_info, attr_name: str, attr_key: str): + """ + Formats eBPF program attributes. + """ + attr = data.get_attr(attr_name) + if not attr: + return '' + result = '' + if attr.get_attr("LWT_BPF_PROG_NAME"): + result += f'{attr.get_attr("LWT_BPF_PROG_NAME")} ' + if attr.get_attr("LWT_BPF_PROG_FD"): + result += f'{attr.get_attr("LWT_BPF_PROG_FD")} ' + + return self._output(f'{attr_key} {result.strip()}') + + def mpls_format(self, data: rtmsg_base.mpls_encap_info): + """ + Formats MPLS encapsulation attributes. + """ + result = '' + if data.get_attr("MPLS_IPTUNNEL_DST"): + for rec in data.get_attr("MPLS_IPTUNNEL_DST"): + for key, val in rec.items(): + if val: + result += f'{key} {val} ' + + if data.get_attr("MPLS_IPTUNNEL_TTL"): + result += f' ttl {data.get_attr("MPLS_IPTUNNEL_TTL")}' + + return self._output(result) + + def bpf_format(self, data: rtmsg_base.bpf_encap_info): + """ + Formats eBPF encapsulation attributes. + """ + result = '' + result += self._format_bpf_object(data, 'LWT_BPF_IN', 'in') + result += self._format_bpf_object(data, 'LWT_BPF_OUT', 'out') + result += self._format_bpf_object(data, 'LWT_BPF_XMIT', 'xmit') + + if data.get_attr('LWT_BPF_XMIT_HEADROOM'): + result += f'headroom {data.get_attr("LWT_BPF_XMIT_HEADROOM")} ' + + return self._output(result) + + def seg6_format(self, data: rtmsg_base.seg6_encap_info): + """ + Formats Segment Routing (SEG6) encapsulation attributes. + """ + result = '' + if data.get_attr("SEG6_IPTUNNEL_SRH"): + result += self._format_srh(data.get_attr("SEG6_IPTUNNEL_SRH")) + + return self._output(result) + + def seg6local_format(self, data: rtmsg_base.seg6local_encap_info): + """ + Formats SEG6 local encapsulation attributes. + """ + result = '' + formatters = { + 'SEG6_LOCAL_ACTION': lambda val: f' action {next((k for k, v in data.action.actions.items() if v == val), "unknown")}', + 'SEG6_LOCAL_SRH': lambda val: f' {self._format_srh(val)}', + 'SEG6_LOCAL_TABLE': lambda val: f' table {self._format_rttable(val)}', + 'SEG6_LOCAL_NH4': lambda val: f' nh4 {val}', + 'SEG6_LOCAL_NH6': lambda val: f' nh6 {val}', + 'SEG6_LOCAL_IIF': lambda val: f' iif {self._get_if_name_by_index(val)}', + 'SEG6_LOCAL_OIF': lambda val: f' oif {self._get_if_name_by_index(val)}', + 'SEG6_LOCAL_BPF': lambda val: f' endpoint {val.get("LWT_BPF_PROG_NAME")}', + 'SEG6_LOCAL_VRFTABLE': lambda val: f' vrftable {self._format_rttable(val)}', + } + + for rec in data.get('attrs'): + if rec[0] in formatters: + result += formatters[rec[0]](rec[1]) + + return self._output(result) + + def format(self, type: int, data: Union[rtmsg_base.mpls_encap_info, + rtmsg_base.bpf_encap_info, + rtmsg_base.seg6_encap_info, + rtmsg_base.seg6local_encap_info]): + """ + Formats encapsulation attributes based on their type. + """ + result = '' + formatter = self.formatters.get(type) + + result += f'encap {ENCAP_TO_NAME.get(type, "unknown")}' + + if formatter: + result += f' {formatter(data)}' + + return self._output(result) + + +class RouteFormatter(BaseMSGFormatter): + """ + A formatter class for handling network routing messages + `RTM_NEWROUTE` and `RTM_DELROUTE`. + """ + + def _format_rt_flags(self, flags: int) -> str: + """ + Formats route flags into a comma-separated string. + """ + result = list() + result.extend(self._parse_flag(flags, RT_FlAGS)) + + return self._output(",".join(result)) + + def _format_rta_encap(self, type: int, data: Union[rtmsg_base.mpls_encap_info, + rtmsg_base.bpf_encap_info, + rtmsg_base.seg6_encap_info, + rtmsg_base.seg6local_encap_info]) -> str: + """ + Formats encapsulation attributes. + """ + return EncapFormatter().format(type, data) + + def _format_rta_newdest(self, data: str) -> str: + """ + Formats a new destination attribute. + """ + return self._output(f'as to {data}') + + def _format_rta_gateway(self, data: str) -> str: + """ + Formats a gateway attribute. + """ + return self._output(f'via {data}') + + def _format_rta_via(self, data: str) -> str: + """ + Formats a 'via' route attribute. + """ + return self._output(f'{data}') + + def _format_rta_metrics(self, data: rtmsg_base.metrics): + """ + Formats routing metrics. + """ + result = '' + + def __format_metric_time(_val: int) -> str: + """Formats metric time values into seconds or milliseconds.""" + return f"{_val / 1000}s" if _val >= 1000 else f"{_val}ms" + + def __format_reatures(_val: int) -> str: + """Parse and formats routing feature flags.""" + result = self._parse_flag(_val, {'ecn': RTAX_FEATURE_ECN, + 'tcp_usec_ts': RTAX_FEATURE_TCP_USEC_TS}) + return ",".join(result) + + formatters = { + 'RTAX_MTU': lambda val: f' mtu {val}', + 'RTAX_WINDOW': lambda val: f' window {val}', + 'RTAX_RTT': lambda val: f' rtt {__format_metric_time(val / 8)}', + 'RTAX_RTTVAR': lambda val: f' rttvar {__format_metric_time(val / 4)}', + 'RTAX_SSTHRESH': lambda val: f' ssthresh {val}', + 'RTAX_CWND': lambda val: f' cwnd {val}', + 'RTAX_ADVMSS': lambda val: f' advmss {val}', + 'RTAX_REORDERING': lambda val: f' reordering {val}', + 'RTAX_HOPLIMIT': lambda val: f' hoplimit {val}', + 'RTAX_INITCWND': lambda val: f' initcwnd {val}', + 'RTAX_FEATURES': lambda val: f' features {__format_reatures(val)}', + 'RTAX_RTO_MIN': lambda val: f' rto_min {__format_metric_time(val)}', + 'RTAX_INITRWND': lambda val: f' initrwnd {val}', + 'RTAX_QUICKACK': lambda val: f' quickack {val}', + } + + for rec in data.get('attrs'): + if rec[0] in formatters: + result += formatters[rec[0]](rec[1]) + + return self._output(result) + + def _format_rta_pref(self, data: int) -> str: + """ + Formats a pref attribute. + """ + pref = { + ICMPV6_ROUTER_PREF_LOW: "low", + ICMPV6_ROUTER_PREF_MEDIUM: "medium", + ICMPV6_ROUTER_PREF_HIGH: "high", + } + + return self._output(f' pref {pref.get(data, data)}') + + def _format_rta_multipath(self, mcast_cloned: bool, family: int, data: List[nh]) -> str: + """ + Formats multipath route attributes. + """ + result = '' + first = True + for rec in data: + if mcast_cloned: + if first: + result += ' Oifs: ' + first = False + else: + result += ' ' + else: + result += ' nexthop ' + + if rec.get_attr('RTA_ENCAP'): + result += self._format_rta_encap(rec.get_attr('RTA_ENCAP_TYPE'), + rec.get_attr('RTA_ENCAP')) + + if rec.get_attr('RTA_NEWDST'): + result += self._format_rta_newdest(rec.get_attr('RTA_NEWDST')) + + if rec.get_attr('RTA_GATEWAY'): + result += self._format_rta_gateway(rec.get_attr('RTA_GATEWAY')) + + if rec.get_attr('RTA_VIA'): + result += self._format_rta_via(rec.get_attr('RTA_VIA')) + + if rec.get_attr('RTA_FLOW'): + result += self._format_flow_field(rec.get_attr('RTA_FLOW')) + + result += f' dev {self._get_if_name_by_index(rec.get("oif"))}' + if mcast_cloned: + if rec.get("hops") != 1: + result += f' (ttl>{rec.get("hops")})' + else: + if family != AF_MPLS: + result += f' weight {rec.get("hops") + 1}' + + result += self._format_rt_flags(rec.get("flags")) + + return self._output(result) + + def format(self, msg: rtmsg.rtmsg) -> str: + """ + Formats a network route message into a human-readable string representation. + """ + message = self._prepare_start_message(msg.get('event')) + + message += RT_TYPES.get(msg.get('type')) + + if msg.get_attr('RTA_DST'): + host_len = self.af_bit_len(msg.get('family')) + if msg.get('dst_len') != host_len: + message += f' {msg.get_attr("RTA_DST")}/{msg.get("dst_len")}' + else: + message += f' {msg.get_attr("RTA_DST")}' + elif msg.get('dst_len'): + message += f' 0/{msg.get("dst_len")}' + else: + message += ' default' + + if msg.get_attr('RTA_SRC'): + message += f' from {msg.get_attr("RTA_SRC")}' + elif msg.get('src_len'): + message += f' from 0/{msg.get("src_len")}' + + message += self._format_simple_field(msg.get_attr('RTA_NH_ID'), prefix='nhid') + + if msg.get_attr('RTA_NEWDST'): + message += self._format_rta_newdest(msg.get_attr('RTA_NEWDST')) + + if msg.get_attr('RTA_ENCAP'): + message += self._format_rta_encap(msg.get_attr('RTA_ENCAP_TYPE'), + msg.get_attr('RTA_ENCAP')) + + message += self._format_simple_field(msg.get('tos'), prefix='tos') + + if msg.get_attr('RTA_GATEWAY'): + message += self._format_rta_gateway(msg.get_attr('RTA_GATEWAY')) + + if msg.get_attr('RTA_VIA'): + message += self._format_rta_via(msg.get_attr('RTA_VIA')) + + if msg.get_attr('RTA_OIF') is not None: + message += f' dev {self._get_if_name_by_index(msg.get_attr("RTA_OIF"))}' + + if msg.get_attr("RTA_TABLE"): + message += f' table {self._format_rttable(msg.get_attr("RTA_TABLE"))}' + + if not msg.get('flags') & RTM_F_CLONED: + message += f' proto {RT_PROTO.get(msg.get("proto"))}' + + if not msg.get('scope') == rtmsg.RT_SCOPE_UNIVERSE: + message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}' + + message += self._format_simple_field(msg.get_attr('RTA_PREFSRC'), prefix='src') + message += self._format_simple_field(msg.get_attr('RTA_PRIORITY'), prefix='metric') + + message += self._format_rt_flags(msg.get("flags")) + + if msg.get_attr('RTA_MARK'): + mark = msg.get_attr("RTA_MARK") + if mark >= 16: + message += f' mark 0x{mark:x}' + else: + message += f' mark {mark}' + + if msg.get_attr('RTA_FLOW'): + message += self._format_flow_field(msg.get_attr('RTA_FLOW')) + + message += self._format_simple_field(msg.get_attr('RTA_UID'), prefix='uid') + + if msg.get_attr('RTA_METRICS'): + message += self._format_rta_metrics(msg.get_attr("RTA_METRICS")) + + if msg.get_attr('RTA_IIF') is not None: + message += f' iif {self._get_if_name_by_index(msg.get_attr("RTA_IIF"))}' + + if msg.get_attr('RTA_PREF') is not None: + message += self._format_rta_pref(msg.get_attr("RTA_PREF")) + + if msg.get_attr('RTA_TTL_PROPAGATE') is not None: + message += f' ttl-propogate {"enabled" if msg.get_attr("RTA_TTL_PROPAGATE") else "disabled"}' + + if msg.get_attr('RTA_MULTIPATH') is not None: + _tmp = self._format_rta_multipath( + mcast_cloned=msg.get('flags') & RTM_F_CLONED and msg.get('type') == RTYPES['RTN_MULTICAST'], + family=msg.get('family'), + data=msg.get_attr("RTA_MULTIPATH")) + message += f' {_tmp}' + + return self._output(message) + + +class AddrFormatter(BaseMSGFormatter): + """ + A formatter class for handling address-related network messages + `RTM_NEWADDR` and `RTM_DELADDR`. + """ + INFINITY_LIFE_TIME = _INFINITY + + def _format_ifa_flags(self, flags: int, family: int) -> str: + """ + Formats address flags into a human-readable string. + """ + result = list() + if flags: + if not flags & IFA_FLAGS['permanent']: + result.append('dynamic') + flags &= ~IFA_FLAGS['permanent'] + + if flags & IFA_FLAGS['temporary'] and family == socket.AF_INET6: + result.append('temporary') + flags &= ~IFA_FLAGS['temporary'] + + result.extend(self._parse_flag(flags, IFA_FLAGS)) + + return self._output(",".join(result)) + + def _format_ifa_addr(self, local: str, addr: str, preflen: int, priority: int) -> str: + """ + Formats address information into a shuman-readable string. + """ + result = '' + local = local or addr + addr = addr or local + + if local: + result += f'{local}' + if addr and addr != local: + result += f' peer {addr}' + result += f'/{preflen}' + + if priority: + result += f' {priority}' + + return self._output(result) + + def _format_ifa_cacheinfo(self, data: ifaddrmsg.ifaddrmsg.cacheinfo) -> str: + """ + Formats cache information for an address. + """ + result = '' + _map = { + 'ifa_valid': 'valid_lft', + 'ifa_preferred': 'preferred_lft', + } + + for key in ['ifa_valid', 'ifa_preferred']: + val = data.get(key) + if val == self.INFINITY_LIFE_TIME: + result += f'{_map.get(key)} forever ' + else: + result += f'{_map.get(key)} {val}sec ' + + return self._output(result) + + def format(self, msg: ifaddrmsg.ifaddrmsg) -> str: + """ + Formats a full network address message. + Combine attributes such as index, family, address, flags, and cache + information into a structured output string. + """ + message = self._prepare_start_message(msg.get('event')) + + message += f'{msg.get("index")}: {self._get_if_name_by_index(msg.get("index"))} ' + message += f'{FAMILY_TO_NAME.get(msg.get("family"), msg.get("family"))} ' + + message += self._format_ifa_addr( + msg.get_attr('IFA_LOCAL'), + msg.get_attr('IFA_ADDRESS'), + msg.get('prefixlen'), + msg.get_attr('IFA_RT_PRIORITY') + ) + message += self._format_simple_field(msg.get_attr('IFA_BROADCAST'), prefix='brd') + message += self._format_simple_field(msg.get_attr('IFA_ANYCAST'), prefix='any') + + if msg.get('scope') is not None: + message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}' + + message += self._format_ifa_flags(msg.get_attr("IFA_FLAGS"), msg.get("family")) + message += self._format_simple_field(msg.get_attr('IFA_LABEL'), prefix='label:') + + if msg.get_attr('IFA_CACHEINFO'): + message += self._format_ifa_cacheinfo(msg.get_attr('IFA_CACHEINFO')) + + return self._output(message) + + +class NeighFormatter(BaseMSGFormatter): + """ + A formatter class for handling neighbor-related network messages + `RTM_NEWNEIGH`, `RTM_DELNEIGH` and `RTM_GETNEIGH` + """ + def _format_ntf_flags(self, flags: int) -> str: + """ + Formats neighbor table entry flags into a human-readable string. + """ + result = list() + result.extend(self._parse_flag(flags, NTF_FlAGS)) + + return self._output(",".join(result)) + + def _format_neigh_state(self, data: int) -> str: + """ + Formats the state of a neighbor entry. + """ + result = list() + result.extend(self._parse_flag(data, NEIGH_STATE_FLAGS)) + + return self._output(",".join(result)) + + def format(self, msg: ndmsg.ndmsg) -> str: + """ + Formats a full neighbor-related network message. + Combine attributes such as destination, device, link-layer address, + flags, state, and protocol into a structured output string. + """ + message = self._prepare_start_message(msg.get('event')) + message += self._format_simple_field(msg.get_attr('NDA_DST'), prefix='') + + if msg.get("ifindex") is not None: + message += f' dev {self._get_if_name_by_index(msg.get("ifindex"))}' + + message += self._format_simple_field(msg.get_attr('NDA_LLADDR'), prefix='lladdr') + message += f' {self._format_ntf_flags(msg.get("flags"))}' + message += f' {self._format_neigh_state(msg.get("state"))}' + + if msg.get_attr('NDA_PROTOCOL'): + message += f' proto {RT_PROTO.get(msg.get_attr("NDA_PROTOCOL"), msg.get_attr("NDA_PROTOCOL"))}' + + return self._output(message) + + +class RuleFormatter(BaseMSGFormatter): + """ + A formatter class for handling ruting tule network messages + `RTM_NEWRULE` and `RTM_DELRULE` + """ + def _format_direction(self, data: str, length: int, host_len: int): + """ + Formats the direction of traffic based on source or destination and prefix length. + """ + result = '' + if data: + result += f' {data}' + if length != host_len: + result += f'/{length}' + elif length: + result += f' 0/{length}' + + return self._output(result) + + def _format_fra_interface(self, data: str, flags: int, prefix: str): + """ + Formats interface-related attributes. + """ + result = f'{prefix} {data}' + if flags & FIB_RULE_IIF_DETACHED: + result += '[detached]' + + return self._output(result) + + def _format_fra_range(self, data: [str, dict], prefix: str): + """ + Formats a range of values (e.g., UID, sport, or dport). + """ + result = '' + if data: + if isinstance(data, str): + result += f' {prefix} {data}' + else: + result += f' {prefix} {data.get("start")}:{data.get("end")}' + return self._output(result) + + def _format_fra_table(self, msg: fibmsg): + """ + Formats the lookup table and associated attributes in the message. + """ + def __format_field(data: int, prefix: str): + if data and data not in [-1, _INFINITY]: + return f' {prefix} {data}' + return '' + + result = '' + table = msg.get_attr('FRA_TABLE') or msg.get('table') + if table: + result += f' lookup {self._format_rttable(table)}' + result += __format_field(msg.get_attr('FRA_SUPPRESS_PREFIXLEN'), 'suppress_prefixlength') + result += __format_field(msg.get_attr('FRA_SUPPRESS_IFGROUP'), 'suppress_ifgroup') + + return self._output(result) + + def _format_fra_action(self, msg: fibmsg): + """ + Formats the action associated with the rule. + """ + result = '' + if msg.get('action') == RTYPES.get('RTN_NAT'): + if msg.get_attr('RTA_GATEWAY'): # looks like deprecated but still use in iproute2 + result += f' map-to {msg.get_attr("RTA_GATEWAY")}' + else: + result += ' masquerade' + + elif msg.get('action') == FR_ACT_GOTO: + result += f' goto {msg.get_attr("FRA_GOTO") or "none"}' + if msg.get('flags') & FIB_RULE_UNRESOLVED: + result += ' [unresolved]' + + elif msg.get('action') == FR_ACT_NOP: + result += ' nop' + + elif msg.get('action') != FR_ACT_TO_TBL: + result += f' {RTYPES.get(msg.get("action"))}' + + return self._output(result) + + def format(self, msg: fibmsg): + """ + Formats a complete routing rule message. + Combines information about source, destination, interfaces, actions, + and other attributes into a single formatted string. + """ + message = self._prepare_start_message(msg.get('event')) + host_len = self.af_bit_len(msg.get('family')) + message += self._format_simple_field(msg.get_attr('FRA_PRIORITY'), prefix='') + + if msg.get('flags') & FIB_RULE_INVERT: + message += ' not' + + tmp = self._format_direction(msg.get_attr('FRA_SRC'), msg.get('src_len'), host_len) + message += ' from' + (tmp if tmp else ' all ') + + if msg.get_attr('FRA_DST'): + tmp = self._format_direction(msg.get_attr('FRA_DST'), msg.get('dst_len'), host_len) + message += ' to' + tmp + + if msg.get('tos'): + message += f' tos {hex(msg.get("tos"))}' + + if msg.get_attr('FRA_FWMARK') or msg.get_attr('FRA_FWMASK'): + mark = msg.get_attr('FRA_FWMARK') or 0 + mask = msg.get_attr('FRA_FWMASK') or 0 + if mask != 0xFFFFFFFF: + message += f' fwmark {mark}/{mask}' + else: + message += f' fwmark {mark}' + + if msg.get_attr('FRA_IIFNAME'): + message += self._format_fra_interface( + msg.get_attr('FRA_IIFNAME'), + msg.get('flags'), + 'iif' + ) + + if msg.get_attr('FRA_OIFNAME'): + message += self._format_fra_interface( + msg.get_attr('FRA_OIFNAME'), + msg.get('flags'), + 'oif' + ) + + if msg.get_attr('FRA_L3MDEV'): + message += f' lookup [l3mdev-table]' + + if msg.get_attr('FRA_UID_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_UID_RANGE'), 'uidrange') + + message += self._format_simple_field(msg.get_attr('FRA_IP_PROTO'), prefix='ipproto') + + if msg.get_attr('FRA_SPORT_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_SPORT_RANGE'), 'sport') + + if msg.get_attr('FRA_DPORT_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_DPORT_RANGE'), 'dport') + + message += self._format_simple_field(msg.get_attr('FRA_TUN_ID'), prefix='tun_id') + + message += self._format_fra_table(msg) + + if msg.get_attr('FRA_FLOW'): + message += self._format_flow_field(msg.get_attr('FRA_FLOW')) + + message += self._format_fra_action(msg) + + if msg.get_attr('FRA_PROTOCOL'): + message += f' proto {RT_PROTO.get(msg.get_attr("FRA_PROTOCOL"), msg.get_attr("FRA_PROTOCOL"))}' + + return self._output(message) + + +class AddrlabelFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +class PrefixFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +class NetconfFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +EVENT_MAP = { + rtnl.RTM_NEWROUTE: {'parser': RouteFormatter, 'event': 'route'}, + rtnl.RTM_DELROUTE: {'parser': RouteFormatter, 'event': 'route'}, + rtnl.RTM_NEWLINK: {'parser': LinkFormatter, 'event': 'link'}, + rtnl.RTM_DELLINK: {'parser': LinkFormatter, 'event': 'link'}, + rtnl.RTM_NEWADDR: {'parser': AddrFormatter, 'event': 'addr'}, + rtnl.RTM_DELADDR: {'parser': AddrFormatter, 'event': 'addr'}, + # rtnl.RTM_NEWADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'}, + # rtnl.RTM_DELADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'}, + rtnl.RTM_NEWNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + rtnl.RTM_DELNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + rtnl.RTM_GETNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + # rtnl.RTM_NEWPREFIX: {'parser': PrefixFormatter, 'event': 'prefix'}, + rtnl.RTM_NEWRULE: {'parser': RuleFormatter, 'event': 'rule'}, + rtnl.RTM_DELRULE: {'parser': RuleFormatter, 'event': 'rule'}, + # rtnl.RTM_NEWNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'}, + # rtnl.RTM_DELNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'}, +} + + +def sig_handler(signum, frame): + process_name = multiprocessing.current_process().name + logger.debug( + f'[{process_name}]: {"Shutdown" if signum == signal.SIGTERM else "Reload"} signal received...' + ) + shutdown_event.set() + + +def parse_event_type(header: Dict) -> tuple: + """ + Extract event type and parser. + """ + event_type = EVENT_MAP.get(header['type'], {}).get('event', 'unknown') + _parser = EVENT_MAP.get(header['type'], {}).get('parser') + + if _parser is None: + raise UnsupportedMessageType(f'Unsupported message type: {header["type"]}') + + return event_type, _parser + + +def is_need_to_log(event_type: AnyStr, conf_event: Dict): + """ + Filter message by event type and protocols + """ + conf = conf_event.get(event_type) + if conf == {}: + return True + return False + + +def parse_event(msg: nfct_msg, conf_event: Dict) -> str: + """ + Convert nfct_msg to internal data dict. + """ + data = '' + event_type, parser = parse_event_type(msg['header']) + if event_type == 'link': + remember_if_index(idx=msg.get('index'), event_type=msg['header'].get('type')) + + if not is_need_to_log(event_type, conf_event): + return data + + message = parser().format(msg) + if message: + data = f'{f"[{event_type}]".upper():<{7}} {message}' + + return data + + +def worker(ct: IPRoute, shutdown_event: multiprocessing.Event, conf_event: Dict) -> None: + """ + Main function of parser worker process + """ + process_name = multiprocessing.current_process().name + logger.debug(f'[{process_name}] started') + timeout = 0.1 + while not shutdown_event.is_set(): + if not ct.buffer_queue.empty(): + msg = None + try: + for msg in ct.get(): + message = parse_event(msg, conf_event) + if message: + if logger.level == logging.DEBUG: + logger.debug(f'[{process_name}]: {message} raw: {msg}') + else: + logger.info(message) + except queue.Full: + logger.error('IPRoute message queue if full.') + except UnsupportedMessageType as e: + logger.debug(f'{e} =====> raw msg: {msg}') + except Exception as e: + logger.error(f'Unexpected error: {e.__class__} {e} [{msg}]') + else: + sleep(timeout) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', + '--config', + action='store', + help='Path to vyos-network-event-logger configuration', + required=True, + type=Path, + ) + + args = parser.parse_args() + try: + config = read_json(args.config) + except Exception as err: + logger.error(f'Configuration file "{args.config}" does not exist or malformed: {err}') + exit(1) + + set_log_level(config.get('log_level', 'info')) + + signal.signal(signal.SIGHUP, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + if 'event' in config: + event_groups = list(config.get('event').keys()) + else: + logger.error(f'Configuration is wrong. Event filter is empty.') + exit(1) + + conf_event = config['event'] + qsize = config.get('queue_size') + ct = IPRoute(async_qsize=int(qsize) if qsize else None) + ct.buffer_queue = multiprocessing.Queue(ct.async_qsize) + ct.bind(async_cache=True) + + processes = list() + try: + for _ in range(multiprocessing.cpu_count()): + p = multiprocessing.Process(target=worker, args=(ct, shutdown_event, conf_event)) + processes.append(p) + p.start() + logger.info('IPRoute socket bound and listening for messages.') + + while not shutdown_event.is_set(): + if not ct.pthread.is_alive(): + if ct.buffer_queue.qsize() / ct.async_qsize < 0.9: + if not shutdown_event.is_set(): + logger.debug('Restart listener thread') + # restart listener thread after queue overloaded when queue size low than 90% + ct.pthread = threading.Thread(name='Netlink async cache', target=ct.async_recv) + ct.pthread.daemon = True + ct.pthread.start() + else: + sleep(0.1) + finally: + for p in processes: + p.join() + if not p.is_alive(): + logger.debug(f'[{p.name}]: finished') + ct.close() + logging.info('IPRoute socket closed.') + exit() diff --git a/src/systemd/vyos-network-event-logger.service b/src/systemd/vyos-network-event-logger.service new file mode 100644 index 000000000..990dc43ba --- /dev/null +++ b/src/systemd/vyos-network-event-logger.service @@ -0,0 +1,21 @@ +[Unit] +Description=VyOS network-event logger daemon + +# Seemingly sensible way to say "as early as the system is ready" +# All vyos-configd needs is read/write mounted root +After=vyos.target + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-network-event-logger -c /run/vyos-network-event-logger.conf +Type=idle + +SyslogIdentifier=vyos-network-event-logger +SyslogFacility=daemon + +Restart=on-failure + +User=root +Group=vyattacfg + +[Install] +WantedBy=multi-user.target diff --git a/src/tests/test_configd_inspect.py b/src/tests/test_configd_inspect.py index ccd631893..a0470221d 100644 --- a/src/tests/test_configd_inspect.py +++ b/src/tests/test_configd_inspect.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright (C) 2020-2025 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 @@ -12,93 +12,151 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os -import re +import ast import json -import warnings -import importlib.util -from inspect import signature -from inspect import getsource -from functools import wraps from unittest import TestCase INC_FILE = 'data/configd-include.json' CONF_DIR = 'src/conf_mode' -f_list = ['get_config', 'verify', 'generate', 'apply'] - -def import_script(s): - path = os.path.join(CONF_DIR, s) - name = os.path.splitext(s)[0].replace('-', '_') - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - -# importing conf_mode scripts imports jinja2 with deprecation warning -def ignore_deprecation_warning(f): - @wraps(f) - def decorated_function(*args, **kwargs): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - f(*args, **kwargs) - return decorated_function +funcs = ['get_config', 'verify', 'generate', 'apply'] + + +class FunctionSig(ast.NodeVisitor): + def __init__(self): + self.func_sig_len = dict.fromkeys(funcs, None) + self.get_config_default_values = [] + + def visit_FunctionDef(self, node): + func_name = node.name + if func_name in funcs: + self.func_sig_len[func_name] = len(node.args.args) + + if func_name == 'get_config': + for default in node.args.defaults: + if isinstance(default, ast.Constant): + self.get_config_default_values.append(default.value) + + self.generic_visit(node) + + def get_sig_lengths(self): + return self.func_sig_len + + def get_config_default(self): + return self.get_config_default_values[0] + + +class LegacyCall(ast.NodeVisitor): + def __init__(self): + self.legacy_func_count = 0 + + def visit_Constant(self, node): + value = node.value + if isinstance(value, str): + if 'my_set' in value or 'my_delete' in value: + self.legacy_func_count += 1 + + self.generic_visit(node) + + def get_legacy_func_count(self): + return self.legacy_func_count + + +class ConfigInstance(ast.NodeVisitor): + def __init__(self): + self.count = 0 + + def visit_Call(self, node): + if isinstance(node.func, ast.Name): + name = node.func.id + if name == 'Config': + self.count += 1 + self.generic_visit(node) + + def get_count(self): + return self.count + + +class FunctionConfigInstance(ast.NodeVisitor): + def __init__(self): + self.func_config_instance = dict.fromkeys(funcs, 0) + + def visit_FunctionDef(self, node): + func_name = node.name + if func_name in funcs: + config_instance = ConfigInstance() + config_instance.visit(node) + self.func_config_instance[func_name] = config_instance.get_count() + self.generic_visit(node) + + def get_func_config_instance(self): + return self.func_config_instance + class TestConfigdInspect(TestCase): def setUp(self): + self.ast_list = [] + with open(INC_FILE) as f: self.inc_list = json.load(f) - @ignore_deprecation_warning - def test_signatures(self): for s in self.inc_list: - m = import_script(s) - for i in f_list: - f = getattr(m, i, None) - self.assertIsNotNone(f, f"'{s}': missing function '{i}'") - sig = signature(f) - par = sig.parameters - l = len(par) - self.assertEqual(l, 1, - f"'{s}': '{i}' incorrect signature") - if i == 'get_config': - for p in par.values(): - self.assertTrue(p.default is None, - f"'{s}': '{i}' incorrect signature") - - @ignore_deprecation_warning - def test_function_instance(self): - for s in self.inc_list: - m = import_script(s) - for i in f_list: - f = getattr(m, i, None) - if not f: - continue - str_f = getsource(f) - # Regex not XXXConfig() T3108 - n = len(re.findall(r'[^a-zA-Z]Config\(\)', str_f)) - if i == 'get_config': - self.assertEqual(n, 1, - f"'{s}': '{i}' no instance of Config") - if i != 'get_config': - self.assertEqual(n, 0, - f"'{s}': '{i}' instance of Config") - - @ignore_deprecation_warning - def test_file_instance(self): - for s in self.inc_list: - m = import_script(s) - str_m = getsource(m) - # Regex not XXXConfig T3108 - n = len(re.findall(r'[^a-zA-Z]Config\(\)', str_m)) - self.assertEqual(n, 1, - f"'{s}' more than one instance of Config") - - @ignore_deprecation_warning + s_path = f'{CONF_DIR}/{s}' + with open(s_path) as f: + s_str = f.read() + s_tree = ast.parse(s_str) + self.ast_list.append((s, s_tree)) + + def test_signatures(self): + for s, t in self.ast_list: + visitor = FunctionSig() + visitor.visit(t) + sig_lens = visitor.get_sig_lengths() + + for f in funcs: + self.assertIsNotNone(sig_lens[f], f"'{s}': '{f}' missing") + self.assertEqual(sig_lens[f], 1, f"'{s}': '{f}' incorrect signature") + + self.assertEqual( + visitor.get_config_default(), + None, + f"'{s}': 'get_config' incorrect signature", + ) + + def test_file_config_instance(self): + for s, t in self.ast_list: + visitor = ConfigInstance() + visitor.visit(t) + count = visitor.get_count() + + self.assertEqual(count, 1, f"'{s}' more than one instance of Config") + + def test_function_config_instance(self): + for s, t in self.ast_list: + visitor = FunctionConfigInstance() + visitor.visit(t) + func_config_instance = visitor.get_func_config_instance() + + for f in funcs: + if f == 'get_config': + self.assertTrue( + func_config_instance[f] > 0, + f"'{s}': '{f}' no instance of Config", + ) + self.assertTrue( + func_config_instance[f] < 2, + f"'{s}': '{f}' more than one instance of Config", + ) + else: + self.assertEqual( + func_config_instance[f], 0, f"'{s}': '{f}' instance of Config" + ) + def test_config_modification(self): - for s in self.inc_list: - m = import_script(s) - str_m = getsource(m) - n = str_m.count('my_set') - self.assertEqual(n, 0, f"'{s}' modifies config") + for s, t in self.ast_list: + visitor = LegacyCall() + visitor.visit(t) + legacy_func_count = visitor.get_legacy_func_count() + + self.assertEqual(legacy_func_count, 0, f"'{s}' modifies config") |