summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/activation-scripts/20-ethernet_offload.py106
-rw-r--r--src/completion/list_bgp_neighbors.sh67
-rw-r--r--src/completion/list_consoles.sh4
-rw-r--r--src/completion/list_container_sysctl_parameters.sh20
-rw-r--r--src/completion/list_ddclient_protocols.sh17
-rw-r--r--src/completion/list_disks.py45
-rw-r--r--src/completion/list_dumpable_interfaces.py12
-rw-r--r--src/completion/list_esi.sh20
-rw-r--r--src/completion/list_images.py44
-rw-r--r--src/completion/list_ipoe.py30
-rw-r--r--src/completion/list_ipsec_profile_tunnels.py45
-rw-r--r--src/completion/list_local_ips.sh29
-rw-r--r--src/completion/list_login_ttys.py25
-rw-r--r--src/completion/list_openconnect_users.py36
-rw-r--r--src/completion/list_openvpn_clients.py57
-rw-r--r--src/completion/list_openvpn_users.py45
-rw-r--r--src/completion/list_protocols.sh3
-rw-r--r--src/completion/list_raidset.sh3
-rw-r--r--src/completion/list_sysctl_parameters.sh20
-rw-r--r--src/completion/list_vni.sh20
-rw-r--r--src/completion/list_webproxy_category.sh5
-rw-r--r--src/completion/list_wireless_phys.sh5
-rw-r--r--src/completion/qos/list_traffic_match_group.py35
-rw-r--r--src/conf_mode/container.py525
-rw-r--r--src/conf_mode/firewall.py597
-rw-r--r--src/conf_mode/high-availability.py236
-rw-r--r--src/conf_mode/interfaces_bonding.py305
-rw-r--r--src/conf_mode/interfaces_bridge.py219
-rw-r--r--src/conf_mode/interfaces_dummy.py76
-rw-r--r--src/conf_mode/interfaces_ethernet.py360
-rw-r--r--src/conf_mode/interfaces_geneve.py104
-rw-r--r--src/conf_mode/interfaces_input.py70
-rw-r--r--src/conf_mode/interfaces_l2tpv3.py113
-rw-r--r--src/conf_mode/interfaces_loopback.py64
-rw-r--r--src/conf_mode/interfaces_macsec.py207
-rw-r--r--src/conf_mode/interfaces_openvpn.py808
-rw-r--r--src/conf_mode/interfaces_pppoe.py144
-rw-r--r--src/conf_mode/interfaces_pseudo-ethernet.py106
-rw-r--r--src/conf_mode/interfaces_sstpc.py141
-rw-r--r--src/conf_mode/interfaces_tunnel.py230
-rw-r--r--src/conf_mode/interfaces_virtual-ethernet.py113
-rw-r--r--src/conf_mode/interfaces_vti.py68
-rw-r--r--src/conf_mode/interfaces_vxlan.py259
-rw-r--r--src/conf_mode/interfaces_wireguard.py136
-rw-r--r--src/conf_mode/interfaces_wireless.py344
-rw-r--r--src/conf_mode/interfaces_wwan.py189
-rw-r--r--src/conf_mode/load-balancing_reverse-proxy.py206
-rw-r--r--src/conf_mode/load-balancing_wan.py151
-rw-r--r--src/conf_mode/nat.py264
-rw-r--r--src/conf_mode/nat64.py225
-rw-r--r--src/conf_mode/nat66.py150
-rw-r--r--src/conf_mode/nat_cgnat.py475
-rw-r--r--src/conf_mode/netns.py115
-rw-r--r--src/conf_mode/pki.py486
-rw-r--r--src/conf_mode/policy.py323
-rw-r--r--src/conf_mode/policy_local-route.py310
-rw-r--r--src/conf_mode/policy_route.py216
-rw-r--r--src/conf_mode/protocols_babel.py159
-rw-r--r--src/conf_mode/protocols_bfd.py112
-rw-r--r--src/conf_mode/protocols_bgp.py655
-rw-r--r--src/conf_mode/protocols_eigrp.py119
-rw-r--r--src/conf_mode/protocols_failover.py116
-rw-r--r--src/conf_mode/protocols_igmp-proxy.py112
-rw-r--r--src/conf_mode/protocols_isis.py312
-rw-r--r--src/conf_mode/protocols_mpls.py148
-rw-r--r--src/conf_mode/protocols_nhrp.py114
-rw-r--r--src/conf_mode/protocols_openfabric.py145
-rw-r--r--src/conf_mode/protocols_ospf.py290
-rw-r--r--src/conf_mode/protocols_ospfv3.py191
-rw-r--r--src/conf_mode/protocols_pim.py172
-rw-r--r--src/conf_mode/protocols_pim6.py133
-rw-r--r--src/conf_mode/protocols_rip.py139
-rw-r--r--src/conf_mode/protocols_ripng.py124
-rw-r--r--src/conf_mode/protocols_rpki.py130
-rw-r--r--src/conf_mode/protocols_segment-routing.py116
-rw-r--r--src/conf_mode/protocols_static.py132
-rw-r--r--src/conf_mode/protocols_static_arp.py74
-rw-r--r--src/conf_mode/protocols_static_multicast.py135
-rw-r--r--src/conf_mode/protocols_static_neighbor-proxy.py85
-rw-r--r--src/conf_mode/qos.py332
-rw-r--r--src/conf_mode/service_aws_glb.py76
-rw-r--r--src/conf_mode/service_broadcast-relay.py111
-rw-r--r--src/conf_mode/service_config-sync.py105
-rw-r--r--src/conf_mode/service_conntrack-sync.py141
-rw-r--r--src/conf_mode/service_console-server.py128
-rw-r--r--src/conf_mode/service_dhcp-relay.py104
-rw-r--r--src/conf_mode/service_dhcp-server.py430
-rw-r--r--src/conf_mode/service_dhcpv6-relay.py106
-rw-r--r--src/conf_mode/service_dhcpv6-server.py263
-rw-r--r--src/conf_mode/service_dns_dynamic.py192
-rw-r--r--src/conf_mode/service_dns_forwarding.py402
-rw-r--r--src/conf_mode/service_event-handler.py92
-rw-r--r--src/conf_mode/service_https.py221
-rw-r--r--src/conf_mode/service_ids_ddos-protection.py104
-rw-r--r--src/conf_mode/service_ipoe-server.py116
-rw-r--r--src/conf_mode/service_lldp.py122
-rw-r--r--src/conf_mode/service_mdns_repeater.py146
-rw-r--r--src/conf_mode/service_monitoring_telegraf.py238
-rw-r--r--src/conf_mode/service_monitoring_zabbix-agent.py98
-rw-r--r--src/conf_mode/service_ndp-proxy.py91
-rw-r--r--src/conf_mode/service_ntp.py154
-rw-r--r--src/conf_mode/service_pppoe-server.py167
-rw-r--r--src/conf_mode/service_router-advert.py125
-rw-r--r--src/conf_mode/service_salt-minion.py118
-rw-r--r--src/conf_mode/service_sla.py107
-rw-r--r--src/conf_mode/service_snmp.py282
-rw-r--r--src/conf_mode/service_ssh.py140
-rw-r--r--src/conf_mode/service_stunnel.py264
-rw-r--r--src/conf_mode/service_suricata.py161
-rw-r--r--src/conf_mode/service_tftp-server.py141
-rw-r--r--src/conf_mode/service_webproxy.py252
-rw-r--r--src/conf_mode/system_acceleration.py109
-rw-r--r--src/conf_mode/system_config-management.py96
-rw-r--r--src/conf_mode/system_conntrack.py273
-rw-r--r--src/conf_mode/system_console.py147
-rw-r--r--src/conf_mode/system_flow-accounting.py316
-rw-r--r--src/conf_mode/system_frr.py85
-rw-r--r--src/conf_mode/system_host-name.py194
-rw-r--r--src/conf_mode/system_ip.py146
-rw-r--r--src/conf_mode/system_ipv6.py130
-rw-r--r--src/conf_mode/system_lcd.py91
-rw-r--r--src/conf_mode/system_login.py413
-rw-r--r--src/conf_mode/system_login_banner.py107
-rw-r--r--src/conf_mode/system_logs.py79
-rw-r--r--src/conf_mode/system_option.py218
-rw-r--r--src/conf_mode/system_proxy.py71
-rw-r--r--src/conf_mode/system_sflow.py102
-rw-r--r--src/conf_mode/system_sysctl.py73
-rw-r--r--src/conf_mode/system_syslog.py121
-rw-r--r--src/conf_mode/system_task-scheduler.py153
-rw-r--r--src/conf_mode/system_timezone.py60
-rw-r--r--src/conf_mode/system_update-check.py90
-rw-r--r--src/conf_mode/system_wireless.py64
-rw-r--r--src/conf_mode/vpn_ipsec.py745
-rw-r--r--src/conf_mode/vpn_l2tp.py109
-rw-r--r--src/conf_mode/vpn_openconnect.py289
-rw-r--r--src/conf_mode/vpn_pptp.py104
-rw-r--r--src/conf_mode/vpn_sstp.py143
-rw-r--r--src/conf_mode/vrf.py364
-rw-r--r--src/etc/bash_completion.d/vyatta-op685
-rw-r--r--src/etc/commit/post-hooks.d/00vyos-sync7
-rw-r--r--src/etc/cron.d/vyos-geoip1
-rw-r--r--src/etc/default/vyatta217
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging20
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient38
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper110
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf32
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/05-vyos-mtureplace38
-rw-r--r--src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks5
-rw-r--r--src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup115
-rw-r--r--src/etc/dhcp/dhclient-exit-hooks.d/02-vyos-dhcp-renew-rfc3442148
-rw-r--r--src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook46
-rw-r--r--src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks5
-rw-r--r--src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook45
-rw-r--r--src/etc/ipsec.d/key-pair.template67
-rw-r--r--src/etc/ipsec.d/vti-up-down67
-rw-r--r--src/etc/logrotate.d/conntrackd9
-rw-r--r--src/etc/logrotate.d/vyos-atop20
-rw-r--r--src/etc/logrotate.d/vyos-rsyslog12
-rw-r--r--src/etc/modprobe.d/ifb.conf1
-rw-r--r--src/etc/modprobe.d/openvpn.conf1
-rw-r--r--src/etc/netplug/linkup.d/vyos-python-helper4
-rw-r--r--src/etc/netplug/netplug41
-rw-r--r--src/etc/netplug/netplugd.conf4
-rw-r--r--src/etc/netplug/vyos-netplug-dhcp-client62
-rw-r--r--src/etc/opennhrp/opennhrp-script.py371
-rw-r--r--src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers14
-rw-r--r--src/etc/ppp/ip-up.d/96-vyos-sstpc-callback49
-rw-r--r--src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers23
-rw-r--r--src/etc/ppp/ip-up.d/99-vyos-pppoe-callback49
-rw-r--r--src/etc/rsyslog.conf67
-rw-r--r--src/etc/securetty83
-rw-r--r--src/etc/security/capability.conf10
-rw-r--r--src/etc/skel/.bashrc119
-rw-r--r--src/etc/skel/.profile22
-rw-r--r--src/etc/sudoers.d/vyos63
-rw-r--r--src/etc/sysctl.d/30-vyos-router.conf117
-rw-r--r--src/etc/sysctl.d/31-vyos-addr_gen_mode.conf14
-rw-r--r--src/etc/sysctl.d/32-vyos-podman.conf5
-rw-r--r--src/etc/systemd/system-generators/vyos-generator94
-rw-r--r--src/etc/systemd/system/ModemManager.service.d/override.conf7
-rw-r--r--src/etc/systemd/system/atop.service.d/10-override.conf6
-rw-r--r--src/etc/systemd/system/certbot.service.d/10-override.conf7
-rw-r--r--src/etc/systemd/system/conntrackd.service.d/override.conf8
-rw-r--r--src/etc/systemd/system/conserver-server.service.d/override.conf10
-rw-r--r--src/etc/systemd/system/fastnetmon.service.d/override.conf12
-rw-r--r--src/etc/systemd/system/frr.service.d/override.conf11
-rw-r--r--src/etc/systemd/system/getty@.service.d/aftervyos.conf3
-rw-r--r--src/etc/systemd/system/hostapd@.service.d/override.conf12
-rw-r--r--src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf9
-rw-r--r--src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf7
-rw-r--r--src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf7
-rw-r--r--src/etc/systemd/system/logrotate.timer.d/10-override.conf2
-rw-r--r--src/etc/systemd/system/nginx.service.d/10-override.conf3
-rw-r--r--src/etc/systemd/system/ocserv.service.d/override.conf14
-rw-r--r--src/etc/systemd/system/openvpn@.service.d/10-override.conf14
-rw-r--r--src/etc/systemd/system/radvd.service.d/override.conf19
-rw-r--r--src/etc/systemd/system/serial-getty@.service.d/aftervyos.conf3
-rw-r--r--src/etc/systemd/system/ssh@.service.d/vrf-override.conf13
-rw-r--r--src/etc/systemd/system/suricata.service.d/10-override.conf9
-rw-r--r--src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf11
-rw-r--r--src/etc/systemd/system/wpa_supplicant@.service.d/override.conf11
-rw-r--r--src/etc/telegraf/custom_scripts/show_firewall_input_filter.py76
-rw-r--r--src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py74
-rw-r--r--src/etc/telegraf/custom_scripts/vyos_services_input_filter.py62
-rw-r--r--src/etc/udev/rules.d/42-qemu-usb.rules14
-rw-r--r--src/etc/udev/rules.d/62-temporary-interface-rename.rules1
-rw-r--r--src/etc/udev/rules.d/63-hyperv-vf-net.rules5
-rw-r--r--src/etc/udev/rules.d/64-vyos-vmware-net.rules14
-rw-r--r--src/etc/udev/rules.d/65-vyos-net.rules23
-rw-r--r--src/etc/udev/rules.d/90-vyos-serial.rules28
-rw-r--r--src/etc/udev/rules.d/99-vyos-systemd.rules79
-rw-r--r--src/etc/update-motd.d/99-reboot7
-rw-r--r--src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py64
-rw-r--r--src/etc/vmware-tools/tools.conf2
-rw-r--r--src/helpers/add-system-version.py20
-rw-r--r--src/helpers/commit-confirm-notify.py31
-rw-r--r--src/helpers/config_dependency.py113
-rw-r--r--src/helpers/geoip-update.py44
-rw-r--r--src/helpers/priority.py42
-rw-r--r--src/helpers/read-saved-value.py30
-rw-r--r--src/helpers/run-config-activation.py83
-rw-r--r--src/helpers/run-config-migration.py78
-rw-r--r--src/helpers/simple-download.py20
-rw-r--r--src/helpers/strip-private.py153
-rw-r--r--src/helpers/vyos-boot-config-loader.py179
-rw-r--r--src/helpers/vyos-certbot-renew-pki.sh3
-rw-r--r--src/helpers/vyos-check-wwan.py35
-rw-r--r--src/helpers/vyos-config-encrypt.py273
-rw-r--r--src/helpers/vyos-domain-resolver.py178
-rw-r--r--src/helpers/vyos-failover.py235
-rw-r--r--src/helpers/vyos-interface-rescan.py206
-rw-r--r--src/helpers/vyos-load-config.py99
-rw-r--r--src/helpers/vyos-merge-config.py108
-rw-r--r--src/helpers/vyos-save-config.py72
-rw-r--r--src/helpers/vyos-sudo.py33
-rw-r--r--src/helpers/vyos-vrrp-conntracksync.sh156
-rw-r--r--src/helpers/vyos_config_sync.py205
-rw-r--r--src/helpers/vyos_net_name276
-rw-r--r--src/init/vyos-config16
-rw-r--r--src/init/vyos-router575
-rw-r--r--src/migration-scripts/bgp/0-to-140
-rw-r--r--src/migration-scripts/bgp/1-to-264
-rw-r--r--src/migration-scripts/bgp/2-to-330
-rw-r--r--src/migration-scripts/bgp/3-to-443
-rw-r--r--src/migration-scripts/bgp/4-to-546
-rw-r--r--src/migration-scripts/cluster/1-to-2178
-rw-r--r--src/migration-scripts/config-management/0-to-124
-rw-r--r--src/migration-scripts/conntrack-sync/1-to-246
-rw-r--r--src/migration-scripts/conntrack/1-to-226
-rw-r--r--src/migration-scripts/conntrack/2-to-331
-rw-r--r--src/migration-scripts/conntrack/3-to-430
-rw-r--r--src/migration-scripts/conntrack/4-to-539
-rw-r--r--src/migration-scripts/container/0-to-165
-rw-r--r--src/migration-scripts/container/1-to-232
-rw-r--r--src/migration-scripts/dhcp-relay/1-to-229
-rw-r--r--src/migration-scripts/dhcp-server/10-to-1128
-rw-r--r--src/migration-scripts/dhcp-server/4-to-5118
-rw-r--r--src/migration-scripts/dhcp-server/5-to-669
-rw-r--r--src/migration-scripts/dhcp-server/6-to-758
-rw-r--r--src/migration-scripts/dhcp-server/7-to-869
-rw-r--r--src/migration-scripts/dhcp-server/8-to-947
-rw-r--r--src/migration-scripts/dhcp-server/9-to-1057
-rw-r--r--src/migration-scripts/dhcpv6-server/0-to-144
-rw-r--r--src/migration-scripts/dhcpv6-server/1-to-268
-rw-r--r--src/migration-scripts/dhcpv6-server/2-to-360
-rw-r--r--src/migration-scripts/dhcpv6-server/3-to-472
-rw-r--r--src/migration-scripts/dhcpv6-server/4-to-573
-rw-r--r--src/migration-scripts/dhcpv6-server/5-to-631
-rw-r--r--src/migration-scripts/dns-dynamic/0-to-1109
-rw-r--r--src/migration-scripts/dns-dynamic/1-to-251
-rw-r--r--src/migration-scripts/dns-dynamic/2-to-399
-rw-r--r--src/migration-scripts/dns-dynamic/3-to-457
-rw-r--r--src/migration-scripts/dns-forwarding/0-to-131
-rw-r--r--src/migration-scripts/dns-forwarding/1-to-267
-rw-r--r--src/migration-scripts/dns-forwarding/2-to-332
-rw-r--r--src/migration-scripts/dns-forwarding/3-to-431
-rw-r--r--src/migration-scripts/firewall/10-to-11187
-rw-r--r--src/migration-scripts/firewall/11-to-1251
-rw-r--r--src/migration-scripts/firewall/12-to-1369
-rw-r--r--src/migration-scripts/firewall/13-to-1439
-rw-r--r--src/migration-scripts/firewall/14-to-1525
-rw-r--r--src/migration-scripts/firewall/15-to-1637
-rw-r--r--src/migration-scripts/firewall/16-to-1760
-rw-r--r--src/migration-scripts/firewall/5-to-685
-rw-r--r--src/migration-scripts/firewall/6-to-7304
-rw-r--r--src/migration-scripts/firewall/7-to-881
-rw-r--r--src/migration-scripts/firewall/8-to-968
-rw-r--r--src/migration-scripts/firewall/9-to-1057
-rw-r--r--src/migration-scripts/flow-accounting/0-to-151
-rw-r--r--src/migration-scripts/https/0-to-150
-rw-r--r--src/migration-scripts/https/1-to-235
-rw-r--r--src/migration-scripts/https/2-to-366
-rw-r--r--src/migration-scripts/https/3-to-434
-rw-r--r--src/migration-scripts/https/4-to-543
-rw-r--r--src/migration-scripts/https/5-to-689
-rw-r--r--src/migration-scripts/ids/0-to-138
-rw-r--r--src/migration-scripts/interfaces/0-to-1113
-rw-r--r--src/migration-scripts/interfaces/1-to-259
-rw-r--r--src/migration-scripts/interfaces/10-to-1138
-rw-r--r--src/migration-scripts/interfaces/11-to-1239
-rw-r--r--src/migration-scripts/interfaces/12-to-1351
-rw-r--r--src/migration-scripts/interfaces/13-to-1442
-rw-r--r--src/migration-scripts/interfaces/14-to-1538
-rw-r--r--src/migration-scripts/interfaces/15-to-1630
-rw-r--r--src/migration-scripts/interfaces/16-to-1733
-rw-r--r--src/migration-scripts/interfaces/17-to-1852
-rw-r--r--src/migration-scripts/interfaces/18-to-1986
-rw-r--r--src/migration-scripts/interfaces/19-to-2041
-rw-r--r--src/migration-scripts/interfaces/2-to-340
-rw-r--r--src/migration-scripts/interfaces/20-to-21107
-rw-r--r--src/migration-scripts/interfaces/21-to-2229
-rw-r--r--src/migration-scripts/interfaces/22-to-2340
-rw-r--r--src/migration-scripts/interfaces/23-to-24125
-rw-r--r--src/migration-scripts/interfaces/24-to-2541
-rw-r--r--src/migration-scripts/interfaces/25-to-26368
-rw-r--r--src/migration-scripts/interfaces/26-to-2735
-rw-r--r--src/migration-scripts/interfaces/27-to-2829
-rw-r--r--src/migration-scripts/interfaces/28-to-2935
-rw-r--r--src/migration-scripts/interfaces/29-to-3030
-rw-r--r--src/migration-scripts/interfaces/3-to-493
-rw-r--r--src/migration-scripts/interfaces/30-to-3156
-rw-r--r--src/migration-scripts/interfaces/31-to-3237
-rw-r--r--src/migration-scripts/interfaces/32-to-3340
-rw-r--r--src/migration-scripts/interfaces/4-to-5106
-rw-r--r--src/migration-scripts/interfaces/5-to-6114
-rw-r--r--src/migration-scripts/interfaces/6-to-745
-rw-r--r--src/migration-scripts/interfaces/7-to-859
-rw-r--r--src/migration-scripts/interfaces/8-to-933
-rw-r--r--src/migration-scripts/interfaces/9-to-1045
-rw-r--r--src/migration-scripts/ipoe-server/1-to-294
-rw-r--r--src/migration-scripts/ipoe-server/2-to-340
-rw-r--r--src/migration-scripts/ipoe-server/3-to-430
-rw-r--r--src/migration-scripts/ipsec/10-to-1163
-rw-r--r--src/migration-scripts/ipsec/11-to-1231
-rw-r--r--src/migration-scripts/ipsec/12-to-1337
-rw-r--r--src/migration-scripts/ipsec/4-to-528
-rw-r--r--src/migration-scripts/ipsec/5-to-673
-rw-r--r--src/migration-scripts/ipsec/6-to-7155
-rw-r--r--src/migration-scripts/ipsec/7-to-8103
-rw-r--r--src/migration-scripts/ipsec/8-to-930
-rw-r--r--src/migration-scripts/ipsec/9-to-10114
-rw-r--r--src/migration-scripts/isis/0-to-136
-rw-r--r--src/migration-scripts/isis/1-to-227
-rw-r--r--src/migration-scripts/isis/2-to-343
-rw-r--r--src/migration-scripts/l2tp/0-to-156
-rw-r--r--src/migration-scripts/l2tp/1-to-228
-rw-r--r--src/migration-scripts/l2tp/2-to-392
-rw-r--r--src/migration-scripts/l2tp/3-to-4148
-rw-r--r--src/migration-scripts/l2tp/4-to-568
-rw-r--r--src/migration-scripts/l2tp/5-to-688
-rw-r--r--src/migration-scripts/l2tp/6-to-739
-rw-r--r--src/migration-scripts/l2tp/7-to-847
-rw-r--r--src/migration-scripts/l2tp/8-to-928
-rw-r--r--src/migration-scripts/lldp/0-to-131
-rw-r--r--src/migration-scripts/lldp/1-to-230
-rw-r--r--src/migration-scripts/monitoring/0-to-166
-rw-r--r--src/migration-scripts/nat/4-to-545
-rw-r--r--src/migration-scripts/nat/5-to-682
-rw-r--r--src/migration-scripts/nat/6-to-754
-rw-r--r--src/migration-scripts/nat/7-to-843
-rw-r--r--src/migration-scripts/nat66/0-to-152
-rw-r--r--src/migration-scripts/nat66/1-to-261
-rw-r--r--src/migration-scripts/nat66/2-to-345
-rw-r--r--src/migration-scripts/ntp/0-to-132
-rw-r--r--src/migration-scripts/ntp/1-to-253
-rw-r--r--src/migration-scripts/ntp/2-to-343
-rw-r--r--src/migration-scripts/openconnect/0-to-1116
-rw-r--r--src/migration-scripts/openconnect/1-to-235
-rw-r--r--src/migration-scripts/openconnect/2-to-330
-rw-r--r--src/migration-scripts/openvpn/0-to-143
-rw-r--r--src/migration-scripts/openvpn/1-to-251
-rw-r--r--src/migration-scripts/openvpn/2-to-339
-rw-r--r--src/migration-scripts/openvpn/3-to-426
-rw-r--r--src/migration-scripts/ospf/0-to-166
-rw-r--r--src/migration-scripts/ospf/1-to-260
-rw-r--r--src/migration-scripts/pim/0-to-154
-rw-r--r--src/migration-scripts/policy/0-to-143
-rw-r--r--src/migration-scripts/policy/1-to-267
-rw-r--r--src/migration-scripts/policy/2-to-338
-rw-r--r--src/migration-scripts/policy/3-to-4143
-rw-r--r--src/migration-scripts/policy/4-to-5106
-rw-r--r--src/migration-scripts/policy/5-to-642
-rw-r--r--src/migration-scripts/policy/6-to-756
-rw-r--r--src/migration-scripts/policy/7-to-836
-rw-r--r--src/migration-scripts/pppoe-server/0-to-133
-rw-r--r--src/migration-scripts/pppoe-server/1-to-241
-rw-r--r--src/migration-scripts/pppoe-server/10-to-1130
-rw-r--r--src/migration-scripts/pppoe-server/2-to-331
-rw-r--r--src/migration-scripts/pppoe-server/3-to-4121
-rw-r--r--src/migration-scripts/pppoe-server/4-to-530
-rw-r--r--src/migration-scripts/pppoe-server/5-to-633
-rw-r--r--src/migration-scripts/pppoe-server/6-to-799
-rw-r--r--src/migration-scripts/pppoe-server/7-to-840
-rw-r--r--src/migration-scripts/pppoe-server/8-to-948
-rw-r--r--src/migration-scripts/pppoe-server/9-to-1038
-rw-r--r--src/migration-scripts/pptp/0-to-154
-rw-r--r--src/migration-scripts/pptp/1-to-253
-rw-r--r--src/migration-scripts/pptp/2-to-355
-rw-r--r--src/migration-scripts/pptp/3-to-429
-rw-r--r--src/migration-scripts/pptp/4-to-543
-rw-r--r--src/migration-scripts/qos/1-to-2168
-rw-r--r--src/migration-scripts/quagga/10-to-1131
-rw-r--r--src/migration-scripts/quagga/2-to-3181
-rw-r--r--src/migration-scripts/quagga/3-to-452
-rw-r--r--src/migration-scripts/quagga/4-to-540
-rw-r--r--src/migration-scripts/quagga/5-to-640
-rw-r--r--src/migration-scripts/quagga/6-to-797
-rw-r--r--src/migration-scripts/quagga/7-to-842
-rw-r--r--src/migration-scripts/quagga/8-to-9117
-rw-r--r--src/migration-scripts/quagga/9-to-1042
-rw-r--r--src/migration-scripts/reverse-proxy/0-to-131
-rw-r--r--src/migration-scripts/rip/0-to-131
-rw-r--r--src/migration-scripts/rpki/0-to-144
-rw-r--r--src/migration-scripts/rpki/1-to-253
-rw-r--r--src/migration-scripts/salt/0-to-138
-rw-r--r--src/migration-scripts/snmp/0-to-138
-rw-r--r--src/migration-scripts/snmp/1-to-270
-rw-r--r--src/migration-scripts/snmp/2-to-333
-rw-r--r--src/migration-scripts/ssh/0-to-126
-rw-r--r--src/migration-scripts/ssh/1-to-263
-rw-r--r--src/migration-scripts/sstp/0-to-1109
-rw-r--r--src/migration-scripts/sstp/1-to-293
-rw-r--r--src/migration-scripts/sstp/2-to-359
-rw-r--r--src/migration-scripts/sstp/3-to-4116
-rw-r--r--src/migration-scripts/sstp/4-to-541
-rw-r--r--src/migration-scripts/sstp/5-to-640
-rw-r--r--src/migration-scripts/system/10-to-1132
-rw-r--r--src/migration-scripts/system/11-to-1269
-rw-r--r--src/migration-scripts/system/12-to-1344
-rw-r--r--src/migration-scripts/system/13-to-1467
-rw-r--r--src/migration-scripts/system/14-to-1537
-rw-r--r--src/migration-scripts/system/15-to-1632
-rw-r--r--src/migration-scripts/system/16-to-1736
-rw-r--r--src/migration-scripts/system/17-to-1859
-rw-r--r--src/migration-scripts/system/18-to-1981
-rw-r--r--src/migration-scripts/system/19-to-2044
-rw-r--r--src/migration-scripts/system/20-to-2130
-rw-r--r--src/migration-scripts/system/21-to-2238
-rw-r--r--src/migration-scripts/system/22-to-2331
-rw-r--r--src/migration-scripts/system/23-to-2471
-rw-r--r--src/migration-scripts/system/24-to-2535
-rw-r--r--src/migration-scripts/system/25-to-2665
-rw-r--r--src/migration-scripts/system/26-to-2730
-rw-r--r--src/migration-scripts/system/6-to-736
-rw-r--r--src/migration-scripts/system/7-to-839
-rw-r--r--src/migration-scripts/system/8-to-926
-rw-r--r--src/migration-scripts/vrf/0-to-1113
-rw-r--r--src/migration-scripts/vrf/1-to-243
-rw-r--r--src/migration-scripts/vrf/2-to-3125
-rw-r--r--src/migration-scripts/vrrp/1-to-2250
-rw-r--r--src/migration-scripts/vrrp/2-to-344
-rw-r--r--src/migration-scripts/vrrp/3-to-432
-rw-r--r--src/migration-scripts/webproxy/1-to-233
-rw-r--r--src/op_mode/accelppp.py155
-rw-r--r--src/op_mode/bgp.py153
-rw-r--r--src/op_mode/bonding.py103
-rw-r--r--src/op_mode/bridge.py293
-rw-r--r--src/op_mode/cgnat.py96
-rw-r--r--src/op_mode/clear_conntrack.py27
-rw-r--r--src/op_mode/config_mgmt.py85
-rw-r--r--src/op_mode/connect_disconnect.py119
-rw-r--r--src/op_mode/conntrack.py172
-rw-r--r--src/op_mode/conntrack_sync.py218
-rw-r--r--src/op_mode/container.py119
-rw-r--r--src/op_mode/cpu.py82
-rw-r--r--src/op_mode/dhcp.py530
-rw-r--r--src/op_mode/dns.py209
-rw-r--r--src/op_mode/evpn.py46
-rw-r--r--src/op_mode/execute_bandwidth_test.sh33
-rw-r--r--src/op_mode/execute_port-scan.py155
-rw-r--r--src/op_mode/file.py383
-rw-r--r--src/op_mode/firewall.py728
-rw-r--r--src/op_mode/flow_accounting_op.py257
-rw-r--r--src/op_mode/force_mtu_host.sh49
-rw-r--r--src/op_mode/force_root-partition-auto-resize.sh60
-rw-r--r--src/op_mode/format_disk.py140
-rw-r--r--src/op_mode/generate_interfaces_debug_archive.py115
-rw-r--r--src/op_mode/generate_ipsec_debug_archive.py88
-rw-r--r--src/op_mode/generate_openconnect_otp_key.py65
-rw-r--r--src/op_mode/generate_ovpn_client_file.py161
-rw-r--r--src/op_mode/generate_public_key_command.py69
-rw-r--r--src/op_mode/generate_service_rule-resequence.py145
-rw-r--r--src/op_mode/generate_ssh_server_key.py31
-rw-r--r--src/op_mode/generate_system_login_user.py77
-rw-r--r--src/op_mode/generate_tech-support_archive.py148
-rw-r--r--src/op_mode/igmp-proxy.py97
-rw-r--r--src/op_mode/ikev2_profile_generator.py318
-rw-r--r--src/op_mode/image_info.py111
-rw-r--r--src/op_mode/image_installer.py1056
-rw-r--r--src/op_mode/image_manager.py282
-rw-r--r--src/op_mode/interfaces.py511
-rw-r--r--src/op_mode/interfaces_wireguard.py53
-rw-r--r--src/op_mode/interfaces_wireless.py186
-rw-r--r--src/op_mode/ipoe-control.py70
-rw-r--r--src/op_mode/ipsec.py1053
-rw-r--r--src/op_mode/kernel_modules.py82
-rw-r--r--src/op_mode/lldp.py163
-rw-r--r--src/op_mode/log.py94
-rw-r--r--src/op_mode/maya_date.py208
-rw-r--r--src/op_mode/memory.py87
-rw-r--r--src/op_mode/mtr.py306
-rw-r--r--src/op_mode/multicast.py72
-rw-r--r--src/op_mode/nat.py365
-rw-r--r--src/op_mode/neighbor.py122
-rw-r--r--src/op_mode/nhrp.py101
-rw-r--r--src/op_mode/ntp.py177
-rw-r--r--src/op_mode/openconnect-control.py70
-rw-r--r--src/op_mode/openconnect.py75
-rw-r--r--src/op_mode/openvpn.py254
-rw-r--r--src/op_mode/otp.py120
-rw-r--r--src/op_mode/ping.py282
-rw-r--r--src/op_mode/pki.py1111
-rw-r--r--src/op_mode/policy_route.py150
-rw-r--r--src/op_mode/powerctrl.py239
-rw-r--r--src/op_mode/ppp-server-ctrl.py75
-rw-r--r--src/op_mode/qos.py242
-rw-r--r--src/op_mode/raid.py44
-rw-r--r--src/op_mode/reset_openvpn.py35
-rw-r--r--src/op_mode/reset_vpn.py61
-rw-r--r--src/op_mode/restart.py149
-rw-r--r--src/op_mode/restart_dhcp_relay.py61
-rw-r--r--src/op_mode/restart_frr.py181
-rw-r--r--src/op_mode/reverseproxy.py237
-rw-r--r--src/op_mode/route.py144
-rw-r--r--src/op_mode/secure_boot.py50
-rw-r--r--src/op_mode/serial.py38
-rw-r--r--src/op_mode/sflow.py107
-rw-r--r--src/op_mode/show-bond.py92
-rw-r--r--src/op_mode/show_acceleration.py114
-rw-r--r--src/op_mode/show_configuration_files.sh10
-rw-r--r--src/op_mode/show_configuration_json.py36
-rw-r--r--src/op_mode/show_current_user.sh18
-rw-r--r--src/op_mode/show_disk_format.sh8
-rw-r--r--src/op_mode/show_ntp.sh34
-rw-r--r--src/op_mode/show_openconnect_otp.py107
-rw-r--r--src/op_mode/show_openvpn.py198
-rw-r--r--src/op_mode/show_openvpn_mfa.py64
-rw-r--r--src/op_mode/show_raid.sh25
-rw-r--r--src/op_mode/show_sensors.py41
-rw-r--r--src/op_mode/show_techsupport_report.py313
-rw-r--r--src/op_mode/show_usb_serial.py57
-rw-r--r--src/op_mode/show_users.py114
-rw-r--r--src/op_mode/show_virtual_server.py33
-rw-r--r--src/op_mode/show_wwan.py88
-rw-r--r--src/op_mode/snmp.py72
-rw-r--r--src/op_mode/snmp_ifmib.py121
-rw-r--r--src/op_mode/snmp_v3.py179
-rw-r--r--src/op_mode/snmp_v3_showcerts.sh8
-rw-r--r--src/op_mode/ssh.py100
-rw-r--r--src/op_mode/storage.py62
-rw-r--r--src/op_mode/system.py87
-rw-r--r--src/op_mode/tcpdump.py165
-rw-r--r--src/op_mode/tech_support.py394
-rw-r--r--src/op_mode/toggle_help_binding.sh25
-rw-r--r--src/op_mode/traceroute.py238
-rw-r--r--src/op_mode/uptime.py57
-rw-r--r--src/op_mode/version.py97
-rw-r--r--src/op_mode/vpn_ike_sa.py76
-rw-r--r--src/op_mode/vpn_ipsec.py82
-rw-r--r--src/op_mode/vrf.py82
-rw-r--r--src/op_mode/vrrp.py59
-rw-r--r--src/op_mode/vtysh_wrapper.sh6
-rw-r--r--src/op_mode/vyos-op-cmd-wrapper.sh6
-rw-r--r--src/op_mode/webproxy_update_blacklist.sh138
-rw-r--r--src/op_mode/wireguard_client.py119
-rw-r--r--src/op_mode/zone.py215
-rw-r--r--src/opt/vyatta/bin/restricted-shell11
-rw-r--r--src/opt/vyatta/bin/vyatta-op-cmd-wrapper6
-rw-r--r--src/opt/vyatta/etc/LICENSE340
-rw-r--r--src/opt/vyatta/etc/shell/level/users/allowed-op21
-rw-r--r--src/opt/vyatta/etc/shell/level/users/allowed-op.in17
-rw-r--r--src/opt/vyatta/sbin/if-mib-alias130
-rw-r--r--src/opt/vyatta/sbin/vyos-persistpath19
-rw-r--r--src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-common82
-rw-r--r--src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run240
-rw-r--r--src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv97
-rw-r--r--src/pam-configs/mfa-google-authenticator8
-rw-r--r--src/pam-configs/radius-mandatory19
-rw-r--r--src/pam-configs/radius-optional19
-rw-r--r--src/pam-configs/tacplus-mandatory17
-rw-r--r--src/pam-configs/tacplus-optional17
-rw-r--r--src/services/api/graphql/README.graphql218
-rw-r--r--src/services/api/graphql/__init__.py0
-rw-r--r--src/services/api/graphql/bindings.py36
-rw-r--r--src/services/api/graphql/generate/composite_function.py7
-rw-r--r--src/services/api/graphql/generate/config_session_function.py30
-rw-r--r--src/services/api/graphql/generate/generate_schema.py26
-rw-r--r--src/services/api/graphql/generate/schema_from_composite.py178
-rw-r--r--src/services/api/graphql/generate/schema_from_config_session.py178
-rw-r--r--src/services/api/graphql/generate/schema_from_op_mode.py301
-rw-r--r--src/services/api/graphql/graphql/__init__.py0
-rw-r--r--src/services/api/graphql/graphql/auth_token_mutation.py61
-rw-r--r--src/services/api/graphql/graphql/client_op/auth_token.graphql10
-rw-r--r--src/services/api/graphql/graphql/directives.py87
-rw-r--r--src/services/api/graphql/graphql/errors.py8
-rw-r--r--src/services/api/graphql/graphql/mutations.py139
-rw-r--r--src/services/api/graphql/graphql/queries.py139
-rw-r--r--src/services/api/graphql/graphql/schema/auth_token.graphql19
-rw-r--r--src/services/api/graphql/graphql/schema/schema.graphql16
-rw-r--r--src/services/api/graphql/libs/key_auth.py18
-rw-r--r--src/services/api/graphql/libs/op_mode.py103
-rw-r--r--src/services/api/graphql/libs/token_auth.py70
-rw-r--r--src/services/api/graphql/session/__init__.py0
-rw-r--r--src/services/api/graphql/session/composite/system_status.py29
-rw-r--r--src/services/api/graphql/session/errors/op_mode_errors.py19
-rw-r--r--src/services/api/graphql/session/override/remove_firewall_address_group_members.py35
-rw-r--r--src/services/api/graphql/session/session.py211
-rw-r--r--src/services/api/graphql/session/templates/create_dhcp_server.tmpl9
-rw-r--r--src/services/api/graphql/session/templates/create_firewall_address_group.tmpl4
-rw-r--r--src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl4
-rw-r--r--src/services/api/graphql/session/templates/create_interface_ethernet.tmpl5
-rw-r--r--src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl3
-rw-r--r--src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl3
-rw-r--r--src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl3
-rw-r--r--src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl3
-rw-r--r--src/services/api/graphql/state.py4
-rw-r--r--src/services/vyos-configd340
-rw-r--r--src/services/vyos-conntrack-logger458
-rw-r--r--src/services/vyos-hostsd651
-rw-r--r--src/services/vyos-http-api-server1036
-rw-r--r--src/shim/Makefile20
-rw-r--r--src/shim/mkjson/LICENSE21
-rw-r--r--src/shim/mkjson/makefile30
-rw-r--r--src/shim/mkjson/mkjson.c307
-rw-r--r--src/shim/mkjson/mkjson.h50
-rw-r--r--src/shim/vyshim.c371
-rw-r--r--src/system/grub_update.py112
-rw-r--r--src/system/keepalived-fifo.py194
-rw-r--r--src/system/normalize-ip43
-rw-r--r--src/system/on-dhcp-event.sh98
-rw-r--r--src/system/on-dhcpv6-event.sh87
-rw-r--r--src/system/post-upgrade3
-rw-r--r--src/system/standalone_root_pw_reset178
-rw-r--r--src/system/uacctd_stop.py68
-rw-r--r--src/system/vyos-config-cloud-init.py169
-rw-r--r--src/system/vyos-event-handler.py168
-rw-r--r--src/system/vyos-system-update-check.py70
-rw-r--r--src/systemd/LCDd.service14
-rw-r--r--src/systemd/accel-ppp@.service16
-rw-r--r--src/systemd/aws-gwlbtun.service11
-rw-r--r--src/systemd/dhclient@.service21
-rw-r--r--src/systemd/dhcp6c@.service19
-rw-r--r--src/systemd/dropbear@.service16
-rw-r--r--src/systemd/dropbearkey.service11
-rw-r--r--src/systemd/isc-dhcp-relay.service20
-rw-r--r--src/systemd/isc-dhcp-relay6.service20
-rw-r--r--src/systemd/lcdproc.service13
-rw-r--r--src/systemd/ndppd.service15
-rw-r--r--src/systemd/opennhrp.service13
-rw-r--r--src/systemd/podman.service16
-rw-r--r--src/systemd/podman.socket10
-rw-r--r--src/systemd/ppp@.service11
-rw-r--r--src/systemd/root-partition-auto-resize.service12
-rw-r--r--src/systemd/stunnel.service15
-rw-r--r--src/systemd/telegraf.service15
-rw-r--r--src/systemd/tftpd@.service14
-rw-r--r--src/systemd/vyos-beep.service11
-rw-r--r--src/systemd/vyos-config-cloud-init.service19
-rw-r--r--src/systemd/vyos-configd.service27
-rw-r--r--src/systemd/vyos-conntrack-logger.service21
-rw-r--r--src/systemd/vyos-domain-resolver.service13
-rw-r--r--src/systemd/vyos-event-handler.service11
-rw-r--r--src/systemd/vyos-grub-update.service14
-rw-r--r--src/systemd/vyos-hostsd.service34
-rw-r--r--src/systemd/vyos-router.service18
-rw-r--r--src/systemd/vyos-system-update.service11
-rw-r--r--src/systemd/vyos-wan-load-balance.service15
-rw-r--r--src/systemd/vyos.target3
-rw-r--r--src/systemd/wpa_supplicant-macsec@.service18
-rw-r--r--src/tests/test_configd_inspect.py104
-rw-r--r--src/validators/port-range-exclude7
-rw-r--r--src/validators/psk-secret39
673 files changed, 68995 insertions, 0 deletions
diff --git a/src/activation-scripts/20-ethernet_offload.py b/src/activation-scripts/20-ethernet_offload.py
new file mode 100644
index 0000000..ca72135
--- /dev/null
+++ b/src/activation-scripts/20-ethernet_offload.py
@@ -0,0 +1,106 @@
+# 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/>.
+
+# T3619: mirror Linux Kernel defaults for ethernet offloading options into VyOS
+# CLI. See https://vyos.dev/T3619#102254 for all the details.
+# T3787: Remove deprecated UDP fragmentation offloading option
+# T6006: add to activation-scripts: migration-scripts/interfaces/20-to-21
+# T6716: Honor the configured offload settings and don't automatically add
+# them to the config if the kernel has them set (unless its a live boot)
+
+from vyos.ethtool import Ethtool
+from vyos.configtree import ConfigTree
+from vyos.system.image import is_live_boot
+
+def activate(config: ConfigTree):
+ base = ['interfaces', 'ethernet']
+
+ if not config.exists(base):
+ return
+
+ for ifname in config.list_nodes(base):
+ eth = Ethtool(ifname)
+
+ # If GRO is enabled by the Kernel - we reflect this on the CLI. If GRO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'gro'])
+ enabled, fixed = eth.get_generic_receive_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'gro'])
+ elif is_live_boot() and enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'gro'])
+
+ # If GSO is enabled by the Kernel - we reflect this on the CLI. If GSO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'gso'])
+ enabled, fixed = eth.get_generic_segmentation_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'gso'])
+ elif is_live_boot() and enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'gso'])
+
+ # If LRO is enabled by the Kernel - we reflect this on the CLI. If LRO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'lro'])
+ enabled, fixed = eth.get_large_receive_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'lro'])
+ elif is_live_boot() and enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'lro'])
+
+ # If SG is enabled by the Kernel - we reflect this on the CLI. If SG is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'sg'])
+ enabled, fixed = eth.get_scatter_gather()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'sg'])
+ elif is_live_boot() and enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'sg'])
+
+ # If TSO is enabled by the Kernel - we reflect this on the CLI. If TSO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'tso'])
+ enabled, fixed = eth.get_tcp_segmentation_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'tso'])
+ elif is_live_boot() and enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'tso'])
+
+ # Remove deprecated UDP fragmentation offloading option
+ if config.exists(base + [ifname, 'offload', 'ufo']):
+ config.delete(base + [ifname, 'offload', 'ufo'])
+
+ # Also while processing the interface configuration, not all adapters support
+ # changing the speed and duplex settings. If the desired speed and duplex
+ # values do not work for the NIC driver, we change them back to the default
+ # value of "auto" - which will be applied if the CLI node is deleted.
+ speed_path = base + [ifname, 'speed']
+ duplex_path = base + [ifname, 'duplex']
+ # speed and duplex must always be set at the same time if not set to "auto"
+ if config.exists(speed_path) and config.exists(duplex_path):
+ speed = config.return_value(speed_path)
+ duplex = config.return_value(duplex_path)
+ if speed != 'auto' and duplex != 'auto':
+ if not eth.check_speed_duplex(speed, duplex):
+ config.delete(speed_path)
+ config.delete(duplex_path)
+
+ # Also while processing the interface configuration, not all adapters support
+ # changing disabling flow-control - or change this setting. If disabling
+ # flow-control is not supported by the NIC, we remove the setting from CLI
+ flow_control_path = base + [ifname, 'disable-flow-control']
+ if config.exists(flow_control_path):
+ if not eth.check_flow_control():
+ config.delete(flow_control_path)
diff --git a/src/completion/list_bgp_neighbors.sh b/src/completion/list_bgp_neighbors.sh
new file mode 100644
index 0000000..869a7ab
--- /dev/null
+++ b/src/completion/list_bgp_neighbors.sh
@@ -0,0 +1,67 @@
+#!/bin/sh
+# Copyright (C) 2021-2022 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/>.
+
+# Return BGP neighbor addresses from CLI, can either request IPv4 only, IPv6
+# only or both address-family neighbors
+
+ipv4=0
+ipv6=0
+vrf=""
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ -4|--ipv4) ipv4=1 ;;
+ -6|--ipv6) ipv6=1 ;;
+ -b|--both) ipv4=1; ipv6=1 ;;
+ --vrf) vrf="vrf name $2"; shift ;;
+ *) echo "Unknown parameter passed: $1" ;;
+ esac
+ shift
+done
+
+declare -a vals
+eval "vals=($(cli-shell-api listActiveNodes $vrf protocols bgp neighbor))"
+
+if [ $ipv4 -eq 1 ] && [ $ipv6 -eq 1 ]; then
+ echo -n '<x.x.x.x>' '<h:h:h:h:h:h:h:h>' ${vals[@]}
+elif [ $ipv4 -eq 1 ] ; then
+ echo -n '<x.x.x.x> '
+ for peer in "${vals[@]}"
+ do
+ ipaddrcheck --is-ipv4-single $peer
+ if [ $? -eq "0" ]; then
+ echo -n "$peer "
+ fi
+ done
+elif [ $ipv6 -eq 1 ] ; then
+ echo -n '<h:h:h:h:h:h:h:h> '
+ for peer in "${vals[@]}"
+ do
+ ipaddrcheck --is-ipv6-single $peer
+ if [ $? -eq "0" ]; then
+ echo -n "$peer "
+ fi
+ done
+else
+ echo "Usage:"
+ echo "-4|--ipv4 list only IPv4 peers"
+ echo "-6|--ipv6 list only IPv6 peers"
+ echo "--both list both IP4 and IPv6 peers"
+ echo "--vrf <name> apply command to given VRF (optional)"
+ echo ""
+ exit 1
+fi
+
+exit 0
diff --git a/src/completion/list_consoles.sh b/src/completion/list_consoles.sh
new file mode 100644
index 0000000..52278c4
--- /dev/null
+++ b/src/completion/list_consoles.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+# For lines like `aliases "foo";`, regex matches everything between the quotes
+grep -oP '(?<=aliases ").+(?=";)' /run/conserver/conserver.cf \ No newline at end of file
diff --git a/src/completion/list_container_sysctl_parameters.sh b/src/completion/list_container_sysctl_parameters.sh
new file mode 100644
index 0000000..cf8d006
--- /dev/null
+++ b/src/completion/list_container_sysctl_parameters.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+#
+# 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/>.
+
+declare -a vals
+eval "vals=($(/sbin/sysctl -N -a|grep -E '^(fs.mqueue|net)\.|^(kernel.msgmax|kernel.msgmnb|kernel.msgmni|kernel.sem|kernel.shmall|kernel.shmmax|kernel.shmmni|kernel.shm_rmid_forced)$'))"
+echo ${vals[@]}
+exit 0
diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh
new file mode 100644
index 0000000..6349816
--- /dev/null
+++ b/src/completion/list_ddclient_protocols.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+#
+# 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/>.
+
+echo -n $(ddclient -list-protocols | grep -vE 'cloudns|porkbun')
diff --git a/src/completion/list_disks.py b/src/completion/list_disks.py
new file mode 100644
index 0000000..0aa872a
--- /dev/null
+++ b/src/completion/list_disks.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-2021 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/>.
+
+# Completion script used by show disks to collect physical disk
+
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-e", "--exclude", type=str, help="Exclude specified device from the result list")
+args = parser.parse_args()
+
+disks = set()
+with open('/proc/partitions') as f:
+ table = f.read()
+
+for line in table.splitlines()[1:]:
+ fields = line.strip().split()
+ # probably an empty line at the top
+ if len(fields) == 0:
+ continue
+ disks.add(fields[3])
+
+if 'loop0' in disks:
+ disks.remove('loop0')
+if 'sr0' in disks:
+ disks.remove('sr0')
+
+if args.exclude:
+ disks.remove(args.exclude)
+
+for disk in disks:
+ print(disk)
diff --git a/src/completion/list_dumpable_interfaces.py b/src/completion/list_dumpable_interfaces.py
new file mode 100644
index 0000000..f974835
--- /dev/null
+++ b/src/completion/list_dumpable_interfaces.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+
+# Extract the list of interfaces available for traffic dumps from tcpdump -D
+
+import re
+
+from vyos.utils.process import cmd
+
+if __name__ == '__main__':
+ out = cmd('tcpdump -D').split('\n')
+ intfs = " ".join(map(lambda s: re.search(r'\d+\.(\S+)\s', s).group(1), out))
+ print(intfs)
diff --git a/src/completion/list_esi.sh b/src/completion/list_esi.sh
new file mode 100644
index 0000000..b8373fa
--- /dev/null
+++ b/src/completion/list_esi.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+#
+# 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/>.
+#
+# This script is completion helper to list all valid ESEs that are visible to FRR
+
+esiJson=$(vtysh -c 'show evpn es json')
+echo "$(echo "$esiJson" | jq -r '.[] | .esi')"
diff --git a/src/completion/list_images.py b/src/completion/list_images.py
new file mode 100644
index 0000000..eae29c0
--- /dev/null
+++ b/src/completion/list_images.py
@@ -0,0 +1,44 @@
+#!/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 argparse
+import os
+import sys
+
+from vyos.system.image import is_live_boot
+from vyos.system.image import get_running_image
+
+
+parser = argparse.ArgumentParser(description='list available system images')
+parser.add_argument('--no-running', action='store_true',
+ help='do not display the currently running image')
+
+def get_images(omit_running: bool = False) -> list[str]:
+ if is_live_boot():
+ return []
+ images = os.listdir("/lib/live/mount/persistence/boot")
+ if omit_running:
+ images.remove(get_running_image())
+ if 'grub' in images:
+ images.remove('grub')
+ if 'efi' in images:
+ images.remove('efi')
+ return sorted(images)
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+ print("\n".join(get_images(omit_running=args.no_running)))
+ sys.exit(0)
diff --git a/src/completion/list_ipoe.py b/src/completion/list_ipoe.py
new file mode 100644
index 0000000..5a8f4b0
--- /dev/null
+++ b/src/completion/list_ipoe.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# Copyright 2020-2023 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/>.
+
+import argparse
+from vyos.utils.process import popen
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--selector', help='Selector: username|ifname|sid', required=True)
+ args = parser.parse_args()
+
+ output, err = popen("accel-cmd -p 2002 show sessions {0}".format(args.selector))
+ if not err:
+ res = output.split("\r\n")
+ # Delete header from list
+ del res[:2]
+ print(' '.join(res))
diff --git a/src/completion/list_ipsec_profile_tunnels.py b/src/completion/list_ipsec_profile_tunnels.py
new file mode 100644
index 0000000..95a4ca3
--- /dev/null
+++ b/src/completion/list_ipsec_profile_tunnels.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 argparse
+
+from vyos.config import Config
+from vyos.utils.dict import dict_search
+
+def get_tunnels_from_ipsecprofile(profile):
+ config = Config()
+ base = ['vpn', 'ipsec', 'profile', profile, 'bind']
+ profile_conf = config.get_config_dict(base, effective=True, key_mangling=('-', '_'))
+ tunnels = []
+
+ try:
+ for tunnel in (dict_search('bind.tunnel', profile_conf) or []):
+ tunnels.append(tunnel)
+ except:
+ pass
+
+ return tunnels
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-p", "--profile", type=str, help="List tunnels per profile")
+ args = parser.parse_args()
+
+ tunnels = []
+
+ tunnels = get_tunnels_from_ipsecprofile(args.profile)
+
+ print(" ".join(tunnels))
diff --git a/src/completion/list_local_ips.sh b/src/completion/list_local_ips.sh
new file mode 100644
index 0000000..32df8a8
--- /dev/null
+++ b/src/completion/list_local_ips.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+ipv4=0
+ipv6=0
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ -4|--ipv4) ipv4=1 ;;
+ -6|--ipv6) ipv6=1 ;;
+ -b|--both) ipv4=1; ipv6=1 ;;
+ *) echo "Unknown parameter passed: $1" ;;
+ esac
+ shift
+done
+
+if [ $ipv4 -eq 1 ] && [ $ipv6 -eq 1 ]; then
+ ip a | grep inet | awk '{print $2}' | sed -e /^fe80::/d | awk -F/ '{print $1}' | sort -u
+elif [ $ipv4 -eq 1 ] ; then
+ ip a | grep 'inet ' | awk '{print $2}' | awk -F/ '{print $1}' | sort -u
+elif [ $ipv6 -eq 1 ] ; then
+ ip a | grep 'inet6 ' | awk '{print $2}' | sed -e /^fe80::/d | awk -F/ '{print $1}' | sort -u
+else
+ echo "Usage:"
+ echo "-4|--ipv4 list only IPv4 addresses"
+ echo "-6|--ipv6 list only IPv6 addresses"
+ echo "--both list both IP4 and IPv6 addresses"
+ echo ""
+ exit 1
+fi
diff --git a/src/completion/list_login_ttys.py b/src/completion/list_login_ttys.py
new file mode 100644
index 0000000..4d77a1b
--- /dev/null
+++ b/src/completion/list_login_ttys.py
@@ -0,0 +1,25 @@
+#!/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/>.
+
+from vyos.utils.serial import get_serial_units
+
+if __name__ == '__main__':
+ # Autocomplete uses runtime state rather than the config tree, as a manual
+ # restart/cleanup may be needed for deleted devices.
+ tty_completions = [ '<text>' ] + [ x['device'] for x in get_serial_units() if 'device' in x ]
+ print(' '.join(tty_completions))
+
+
diff --git a/src/completion/list_openconnect_users.py b/src/completion/list_openconnect_users.py
new file mode 100644
index 0000000..db2f4b4
--- /dev/null
+++ b/src/completion/list_openconnect_users.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-2022 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/>.
+
+from vyos.config import Config
+from vyos.utils.dict import dict_search
+
+def get_user_from_ocserv():
+ config = Config()
+ base = ['vpn', 'openconnect', 'authentication', 'local-users', 'username']
+ openconnect = config.get_config_dict(base, effective=True, key_mangling=('-', '_'))
+ users = []
+ try:
+ for user in (dict_search('username', openconnect) or []):
+ users.append(user)
+ except:
+ pass
+ return users
+
+if __name__ == "__main__":
+ users = []
+ users = get_user_from_ocserv()
+ print(" ".join(users))
+
diff --git a/src/completion/list_openvpn_clients.py b/src/completion/list_openvpn_clients.py
new file mode 100644
index 0000000..c1d8eae
--- /dev/null
+++ b/src/completion/list_openvpn_clients.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 argparse
+
+from vyos.ifconfig import Section
+
+def get_client_from_interface(interface):
+ clients = []
+ try:
+ with open('/run/openvpn/' + interface + '.status', 'r') as f:
+ dump = False
+ for line in f:
+ if line.startswith("Common Name,"):
+ dump = True
+ continue
+ if line.startswith("ROUTING TABLE"):
+ dump = False
+ continue
+ if dump:
+ # client entry in this file looks like
+ # client1,172.18.202.10:47495,2957,2851,Sat Aug 17 00:07:11 2019
+ # we are only interested in the client name 'client1'
+ clients.append(line.split(',')[0])
+ except:
+ pass
+
+ return clients
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-i", "--interface", type=str, help="List connected clients per interface")
+ parser.add_argument("-a", "--all", action='store_true', help="List all connected OpenVPN clients")
+ args = parser.parse_args()
+
+ clients = []
+
+ if args.interface:
+ clients = get_client_from_interface(args.interface)
+ elif args.all:
+ for interface in Section.interfaces("openvpn"):
+ clients += get_client_from_interface(interface)
+
+ print(" ".join(clients))
diff --git a/src/completion/list_openvpn_users.py b/src/completion/list_openvpn_users.py
new file mode 100644
index 0000000..f2c6484
--- /dev/null
+++ b/src/completion/list_openvpn_users.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 argparse
+
+from vyos.config import Config
+from vyos.utils.dict import dict_search
+
+def get_user_from_interface(interface):
+ config = Config()
+ base = ['interfaces', 'openvpn', interface]
+ openvpn = config.get_config_dict(base, effective=True, key_mangling=('-', '_'))
+ users = []
+
+ try:
+ for user in (dict_search('server.client', openvpn[interface]) or []):
+ users.append(user.split(',')[0])
+ except:
+ pass
+
+ return users
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-i", "--interface", type=str, help="List users per interface")
+ args = parser.parse_args()
+
+ users = []
+
+ users = get_user_from_interface(args.interface)
+
+ print(" ".join(users))
diff --git a/src/completion/list_protocols.sh b/src/completion/list_protocols.sh
new file mode 100644
index 0000000..e9d50a7
--- /dev/null
+++ b/src/completion/list_protocols.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+grep -v '^#' /etc/protocols | awk 'BEGIN {ORS=""} {if ($3) {print TRS $1; TRS=" "}}'
diff --git a/src/completion/list_raidset.sh b/src/completion/list_raidset.sh
new file mode 100644
index 0000000..9ff3523
--- /dev/null
+++ b/src/completion/list_raidset.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+echo -n `cat /proc/partitions | grep md | awk '{ print $4 }'`
diff --git a/src/completion/list_sysctl_parameters.sh b/src/completion/list_sysctl_parameters.sh
new file mode 100644
index 0000000..c111716
--- /dev/null
+++ b/src/completion/list_sysctl_parameters.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+#
+# Copyright (C) 2021 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/>.
+
+declare -a vals
+eval "vals=($(/sbin/sysctl -N -a))"
+echo ${vals[@]}
+exit 0
diff --git a/src/completion/list_vni.sh b/src/completion/list_vni.sh
new file mode 100644
index 0000000..f8bd4a9
--- /dev/null
+++ b/src/completion/list_vni.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+#
+# 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/>.
+#
+# This script is completion helper to list all configured VNIs that are visible to FRR
+
+vniJson=$(vtysh -c 'show evpn vni json')
+echo "$(echo "$vniJson" | jq -r 'keys | .[]')"
diff --git a/src/completion/list_webproxy_category.sh b/src/completion/list_webproxy_category.sh
new file mode 100644
index 0000000..a5ad239
--- /dev/null
+++ b/src/completion/list_webproxy_category.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+DB_DIR="/opt/vyatta/etc/config/url-filtering/squidguard/db/"
+if [ -d ${DB_DIR} ]; then
+ ls -ald ${DB_DIR}/* | grep -E '^(d|l)' | awk '{print $9}' | sed s#${DB_DIR}/##
+fi
diff --git a/src/completion/list_wireless_phys.sh b/src/completion/list_wireless_phys.sh
new file mode 100644
index 0000000..70b8d1f
--- /dev/null
+++ b/src/completion/list_wireless_phys.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+
+if [ -d /sys/class/ieee80211 ]; then
+ ls -x /sys/class/ieee80211
+fi
diff --git a/src/completion/qos/list_traffic_match_group.py b/src/completion/qos/list_traffic_match_group.py
new file mode 100644
index 0000000..015d7ad
--- /dev/null
+++ b/src/completion/qos/list_traffic_match_group.py
@@ -0,0 +1,35 @@
+#!/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/>.
+
+from vyos.config import Config
+
+
+def get_qos_traffic_match_group():
+ config = Config()
+ base = ['qos', 'traffic-match-group']
+ conf = config.get_config_dict(base, key_mangling=('-', '_'))
+ groups = []
+
+ for group in conf.get('traffic_match_group', []):
+ groups.append(group)
+
+ return groups
+
+
+if __name__ == "__main__":
+ groups = get_qos_traffic_match_group()
+ print(" ".join(groups))
+
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py
new file mode 100644
index 0000000..14387cb
--- /dev/null
+++ b/src/conf_mode/container.py
@@ -0,0 +1,525 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from decimal import Decimal
+from hashlib import sha256
+from ipaddress import ip_address
+from ipaddress import ip_network
+from json import dumps as json_write
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import Interface
+from vyos.utils.cpu import get_core_count
+from vyos.utils.file import write_file
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.utils.network import interface_exists
+from vyos.template import bracketize_ipv6
+from vyos.template import inc_ip
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+from vyos.template import render
+from vyos.xml_ref import default_value
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+config_containers = '/etc/containers/containers.conf'
+config_registry = '/etc/containers/registries.conf'
+config_storage = '/etc/containers/storage.conf'
+systemd_unit_path = '/run/systemd/system'
+
+
+def _cmd(command):
+ if os.path.exists('/tmp/vyos.container.debug'):
+ print(command)
+ return cmd(command)
+
+
+def network_exists(name):
+ # Check explicit name for network, returns True if network exists
+ c = _cmd(f'podman network ls --quiet --filter name=^{name}$')
+ return bool(c)
+
+
+# Common functions
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['container']
+ container = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ for name in container.get('name', []):
+ # T5047: Any container related configuration changed? We only
+ # wan't to restart the required containers and not all of them ...
+ tmp = is_node_changed(conf, base + ['name', name])
+ if tmp:
+ if 'container_restart' not in container:
+ container['container_restart'] = [name]
+ else:
+ container['container_restart'].append(name)
+
+ # registry is a tagNode with default values - merge the list from
+ # default_values['registry'] into the tagNode variables
+ if 'registry' not in container:
+ container.update({'registry': {}})
+ default_values = default_value(base + ['registry'])
+ for registry in default_values:
+ tmp = {registry: {}}
+ container['registry'] = dict_merge(tmp, container['registry'])
+
+ # Delete container network, delete containers
+ tmp = node_changed(conf, base + ['network'])
+ if tmp: container.update({'network_remove': tmp})
+
+ tmp = node_changed(conf, base + ['name'])
+ if tmp: container.update({'container_remove': tmp})
+
+ return container
+
+
+def verify(container):
+ # bail out early - looks like removal from running config
+ if not container:
+ return None
+
+ # Add new container
+ if 'name' in container:
+ for name, container_config in container['name'].items():
+ # Container image is a mandatory option
+ if 'image' not in container_config:
+ raise ConfigError(f'Container image for "{name}" is mandatory!')
+
+ # Check if requested container image exists locally. If it does not
+ # exist locally - inform the user. This is required as there is a
+ # shared container image storage accross all VyOS images. A user can
+ # delete a container image from the system, boot into another version
+ # of VyOS and then it would fail to boot. This is to prevent any
+ # configuration error when container images are deleted from the
+ # global storage. A per image local storage would be a super waste
+ # of diskspace as there will be a full copy (up tu several GB/image)
+ # on upgrade. This is the "cheapest" and fastest solution in terms
+ # of image upgrade and deletion.
+ image = container_config['image']
+ if run(f'podman image exists {image}') != 0:
+ Warning(f'Image "{image}" used in container "{name}" does not exist ' \
+ f'locally. Please use "add container image {image}" to add it ' \
+ f'to the system! Container "{name}" will not be started!')
+
+ if 'cpu_quota' in container_config:
+ cores = get_core_count()
+ if Decimal(container_config['cpu_quota']) > cores:
+ raise ConfigError(f'Cannot set limit to more cores than available "{name}"!')
+
+ if 'network' in container_config:
+ if len(container_config['network']) > 1:
+ raise ConfigError(f'Only one network can be specified for container "{name}"!')
+
+ # Check if the specified container network exists
+ network_name = list(container_config['network'])[0]
+ if network_name not in container.get('network', {}):
+ raise ConfigError(f'Container network "{network_name}" does not exist!')
+
+ if 'address' in container_config['network'][network_name]:
+ cnt_ipv4 = 0
+ cnt_ipv6 = 0
+ for address in container_config['network'][network_name]['address']:
+ network = None
+ if is_ipv4(address):
+ try:
+ network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0]
+ cnt_ipv4 += 1
+ except:
+ raise ConfigError(f'Network "{network_name}" does not contain an IPv4 prefix!')
+ elif is_ipv6(address):
+ try:
+ network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0]
+ cnt_ipv6 += 1
+ except:
+ raise ConfigError(f'Network "{network_name}" does not contain an IPv6 prefix!')
+
+ # Specified container IP address must belong to network prefix
+ if ip_address(address) not in ip_network(network):
+ raise ConfigError(f'Used container address "{address}" not in network "{network}"!')
+
+ # We can not use the first IP address of a network prefix as this is used by podman
+ if ip_address(address) == ip_network(network)[1]:
+ raise ConfigError(f'IP address "{address}" can not be used for a container, ' \
+ 'reserved for the container engine!')
+
+ if cnt_ipv4 > 1 or cnt_ipv6 > 1:
+ raise ConfigError(f'Only one IP address per address family can be used for ' \
+ f'container "{name}". {cnt_ipv4} IPv4 and {cnt_ipv6} IPv6 address(es)!')
+
+ if 'device' in container_config:
+ for dev, dev_config in container_config['device'].items():
+ if 'source' not in dev_config:
+ raise ConfigError(f'Device "{dev}" has no source path configured!')
+
+ if 'destination' not in dev_config:
+ raise ConfigError(f'Device "{dev}" has no destination path configured!')
+
+ source = dev_config['source']
+ if not os.path.exists(source):
+ raise ConfigError(f'Device "{dev}" source path "{source}" does not exist!')
+
+ if 'sysctl' in container_config and 'parameter' in container_config['sysctl']:
+ for var, cfg in container_config['sysctl']['parameter'].items():
+ if 'value' not in cfg:
+ raise ConfigError(f'sysctl parameter {var} has no value assigned!')
+ if var.startswith('net.') and 'allow_host_networks' in container_config:
+ raise ConfigError(f'sysctl parameter {var} cannot be set when using host networking!')
+
+ if 'environment' in container_config:
+ for var, cfg in container_config['environment'].items():
+ if 'value' not in cfg:
+ raise ConfigError(f'Environment variable {var} has no value assigned!')
+
+ if 'label' in container_config:
+ for var, cfg in container_config['label'].items():
+ if 'value' not in cfg:
+ raise ConfigError(f'Label variable {var} has no value assigned!')
+
+ if 'volume' in container_config:
+ for volume, volume_config in container_config['volume'].items():
+ if 'source' not in volume_config:
+ raise ConfigError(f'Volume "{volume}" has no source path configured!')
+
+ if 'destination' not in volume_config:
+ raise ConfigError(f'Volume "{volume}" has no destination path configured!')
+
+ source = volume_config['source']
+ if not os.path.exists(source):
+ raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!')
+
+ if 'port' in container_config:
+ for tmp in container_config['port']:
+ if not {'source', 'destination'} <= set(container_config['port'][tmp]):
+ raise ConfigError(f'Both "source" and "destination" must be specified for a port mapping!')
+
+ # If 'allow-host-networks' or 'network' not set.
+ if 'allow_host_networks' not in container_config and 'network' not in container_config:
+ raise ConfigError(f'Must either set "network" or "allow-host-networks" for container "{name}"!')
+
+ # Can not set both allow-host-networks and network at the same time
+ if {'allow_host_networks', 'network'} <= set(container_config):
+ raise ConfigError(
+ f'"allow-host-networks" and "network" for "{name}" cannot be both configured at the same time!')
+
+ # gid cannot be set without uid
+ if 'gid' in container_config and 'uid' not in container_config:
+ raise ConfigError(f'Cannot set "gid" without "uid" for container')
+
+ # Add new network
+ if 'network' in container:
+ for network, network_config in container['network'].items():
+ v4_prefix = 0
+ v6_prefix = 0
+ # If ipv4-prefix not defined for user-defined network
+ if 'prefix' not in network_config:
+ raise ConfigError(f'prefix for network "{network}" must be defined!')
+
+ for prefix in network_config['prefix']:
+ if is_ipv4(prefix):
+ v4_prefix += 1
+ elif is_ipv6(prefix):
+ v6_prefix += 1
+
+ if v4_prefix > 1:
+ raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!')
+ if v6_prefix > 1:
+ raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!')
+
+ # Verify VRF exists
+ verify_vrf(network_config)
+
+ # A network attached to a container can not be deleted
+ if {'network_remove', 'name'} <= set(container):
+ for network in container['network_remove']:
+ for c, c_config in container['name'].items():
+ if 'network' in c_config and network in c_config['network']:
+ raise ConfigError(f'Can not remove network "{network}", used by container "{c}"!')
+
+ if 'registry' in container:
+ for registry, registry_config in container['registry'].items():
+ if 'authentication' not in registry_config:
+ continue
+ if not {'username', 'password'} <= set(registry_config['authentication']):
+ raise ConfigError('Container registry requires both username and password to be set!')
+
+ return None
+
+
+def generate_run_arguments(name, container_config):
+ image = container_config['image']
+ cpu_quota = container_config['cpu_quota']
+ memory = container_config['memory']
+ shared_memory = container_config['shared_memory']
+ restart = container_config['restart']
+
+ # Add sysctl options
+ sysctl_opt = ''
+ if 'sysctl' in container_config and 'parameter' in container_config['sysctl']:
+ for k, v in container_config['sysctl']['parameter'].items():
+ sysctl_opt += f" --sysctl {k}={v['value']}"
+
+ # Add capability options. Should be in uppercase
+ capabilities = ''
+ if 'capability' in container_config:
+ for cap in container_config['capability']:
+ cap = cap.upper().replace('-', '_')
+ capabilities += f' --cap-add={cap}'
+
+ # Add a host device to the container /dev/x:/dev/x
+ device = ''
+ if 'device' in container_config:
+ for dev, dev_config in container_config['device'].items():
+ source_dev = dev_config['source']
+ dest_dev = dev_config['destination']
+ device += f' --device={source_dev}:{dest_dev}'
+
+ # Check/set environment options "-e foo=bar"
+ env_opt = ''
+ if 'environment' in container_config:
+ for k, v in container_config['environment'].items():
+ env_opt += f" --env \"{k}={v['value']}\""
+
+ # Check/set label options "--label foo=bar"
+ label = ''
+ if 'label' in container_config:
+ for k, v in container_config['label'].items():
+ label += f" --label \"{k}={v['value']}\""
+
+ hostname = ''
+ if 'host_name' in container_config:
+ hostname = container_config['host_name']
+ hostname = f'--hostname {hostname}'
+
+ # Publish ports
+ port = ''
+ if 'port' in container_config:
+ protocol = ''
+ for portmap in container_config['port']:
+ protocol = container_config['port'][portmap]['protocol']
+ sport = container_config['port'][portmap]['source']
+ dport = container_config['port'][portmap]['destination']
+ listen_addresses = container_config['port'][portmap].get('listen_address', [])
+
+ # If listen_addresses is not empty, include them in the publish command
+ if listen_addresses:
+ for listen_address in listen_addresses:
+ port += f' --publish {bracketize_ipv6(listen_address)}:{sport}:{dport}/{protocol}'
+ else:
+ # If listen_addresses is empty, just include the standard publish command
+ port += f' --publish {sport}:{dport}/{protocol}'
+
+ # Set uid and gid
+ uid = ''
+ if 'uid' in container_config:
+ uid = container_config['uid']
+ if 'gid' in container_config:
+ uid += ':' + container_config['gid']
+ uid = f'--user {uid}'
+
+ # Bind volume
+ volume = ''
+ if 'volume' in container_config:
+ for vol, vol_config in container_config['volume'].items():
+ svol = vol_config['source']
+ dvol = vol_config['destination']
+ mode = vol_config['mode']
+ prop = vol_config['propagation']
+ volume += f' --volume {svol}:{dvol}:{mode},{prop}'
+
+ host_pid = ''
+ if 'allow_host_pid' in container_config:
+ host_pid = '--pid host'
+
+ container_base_cmd = f'--detach --interactive --tty --replace {capabilities} --cpus {cpu_quota} {sysctl_opt} ' \
+ f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \
+ f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid} {host_pid}'
+
+ entrypoint = ''
+ if 'entrypoint' in container_config:
+ # it needs to be json-formatted with single quote on the outside
+ entrypoint = json_write(container_config['entrypoint'].split()).replace('"', "&quot;")
+ entrypoint = f'--entrypoint &apos;{entrypoint}&apos;'
+
+ command = ''
+ if 'command' in container_config:
+ command = container_config['command'].strip()
+
+ command_arguments = ''
+ if 'arguments' in container_config:
+ command_arguments = container_config['arguments'].strip()
+
+ if 'allow_host_networks' in container_config:
+ return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip()
+
+ ip_param = ''
+ networks = ",".join(container_config['network'])
+ for network in container_config['network']:
+ if 'address' not in container_config['network'][network]:
+ continue
+ for address in container_config['network'][network]['address']:
+ if is_ipv6(address):
+ ip_param += f' --ip6 {address}'
+ else:
+ ip_param += f' --ip {address}'
+
+ return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip()
+
+
+def generate(container):
+ # bail out early - looks like removal from running config
+ if not container:
+ for file in [config_containers, config_registry, config_storage]:
+ if os.path.exists(file):
+ os.unlink(file)
+ return None
+
+ if 'network' in container:
+ for network, network_config in container['network'].items():
+ tmp = {
+ 'name': network,
+ 'id': sha256(f'{network}'.encode()).hexdigest(),
+ 'driver': 'bridge',
+ 'network_interface': f'pod-{network}',
+ 'subnets': [],
+ 'ipv6_enabled': False,
+ 'internal': False,
+ 'dns_enabled': True,
+ 'ipam_options': {
+ 'driver': 'host-local'
+ }
+ }
+
+ if 'no_name_server' in network_config:
+ tmp['dns_enabled'] = False
+
+ for prefix in network_config['prefix']:
+ net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)}
+ tmp['subnets'].append(net)
+
+ if is_ipv6(prefix):
+ tmp['ipv6_enabled'] = True
+
+ write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2))
+
+ render(config_containers, 'container/containers.conf.j2', container)
+ render(config_registry, 'container/registries.conf.j2', container)
+ render(config_storage, 'container/storage.conf.j2', container)
+
+ if 'name' in container:
+ for name, container_config in container['name'].items():
+ if 'disable' in container_config:
+ continue
+
+ file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service')
+ run_args = generate_run_arguments(name, container_config)
+ render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, },
+ formater=lambda _: _.replace("&quot;", '"').replace("&apos;", "'"))
+
+ return None
+
+
+def apply(container):
+ # Delete old containers if needed. We can't delete running container
+ # Option "--force" allows to delete containers with any status
+ if 'container_remove' in container:
+ for name in container['container_remove']:
+ file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service')
+ call(f'systemctl stop vyos-container-{name}.service')
+ if os.path.exists(file_path):
+ os.unlink(file_path)
+
+ call('systemctl daemon-reload')
+
+ # Delete old networks if needed
+ if 'network_remove' in container:
+ for network in container['network_remove']:
+ call(f'podman network rm {network} >/dev/null 2>&1')
+
+ # Add container
+ disabled_new = False
+ if 'name' in container:
+ for name, container_config in container['name'].items():
+ image = container_config['image']
+
+ if run(f'podman image exists {image}') != 0:
+ # container image does not exist locally - user already got
+ # informed by a WARNING in verfiy() - bail out early
+ continue
+
+ if 'disable' in container_config:
+ # check if there is a container by that name running
+ tmp = _cmd('podman ps -a --format "{{.Names}}"')
+ if name in tmp:
+ file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service')
+ call(f'systemctl stop vyos-container-{name}.service')
+ if os.path.exists(file_path):
+ disabled_new = True
+ os.unlink(file_path)
+ continue
+
+ if 'container_restart' in container and name in container['container_restart']:
+ cmd(f'systemctl restart vyos-container-{name}.service')
+
+ if disabled_new:
+ call('systemctl daemon-reload')
+
+ # Start network and assign it to given VRF if requested. this can only be done
+ # after the containers got started as the podman network interface will
+ # only be enabled by the first container and yet I do not know how to enable
+ # the network interface in advance
+ if 'network' in container:
+ for network, network_config in container['network'].items():
+ network_name = f'pod-{network}'
+ # T5147: Networks are started only as soon as there is a consumer.
+ # If only a network is created in the first place, no need to assign
+ # it to a VRF as there's no consumer, yet.
+ if interface_exists(network_name):
+ tmp = Interface(network_name)
+ tmp.set_vrf(network_config.get('vrf', ''))
+ tmp.add_ipv6_eui64_address('fe80::/64')
+
+ return None
+
+
+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/firewall.py b/src/conf_mode/firewall.py
new file mode 100644
index 0000000..5638a96
--- /dev/null
+++ b/src/conf_mode/firewall.py
@@ -0,0 +1,597 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 re
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.configdiff import get_config_diff, Diff
+from vyos.configdep import set_dependents, call_dependents
+from vyos.configverify import verify_interface_exists
+from vyos.ethtool import Ethtool
+from vyos.firewall import fqdn_config_parse
+from vyos.firewall import geoip_update
+from vyos.template import render
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_search_recursive
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import rc_cmd
+from vyos import ConfigError
+from vyos import airbag
+from subprocess import run as subp_run
+
+airbag.enable()
+
+nftables_conf = '/run/nftables.conf'
+sysctl_file = r'/run/sysctl/10-vyos-firewall.conf'
+
+valid_groups = [
+ 'address_group',
+ 'domain_group',
+ 'network_group',
+ 'port_group',
+ 'interface_group',
+ ## Added for group ussage in bridge firewall
+ 'ipv4_address_group',
+ 'ipv6_address_group',
+ 'ipv4_network_group',
+ 'ipv6_network_group'
+]
+
+nested_group_types = [
+ 'address_group', 'network_group', 'mac_group',
+ 'port_group', 'ipv6_address_group', 'ipv6_network_group'
+]
+
+snmp_change_type = {
+ 'unknown': 0,
+ 'add': 1,
+ 'delete': 2,
+ 'change': 3
+}
+snmp_event_source = 1
+snmp_trap_mib = 'VYATTA-TRAP-MIB'
+snmp_trap_name = 'mgmtEventTrap'
+
+def geoip_updated(conf, firewall):
+ diff = get_config_diff(conf)
+ node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True)
+
+ out = {
+ 'name': [],
+ 'ipv6_name': [],
+ 'deleted_name': [],
+ 'deleted_ipv6_name': []
+ }
+ updated = False
+
+ for key, path in dict_search_recursive(firewall, 'geoip'):
+ set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}'
+ if (path[0] == 'ipv4'):
+ out['name'].append(set_name)
+ elif (path[0] == 'ipv6'):
+ set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}'
+ out['ipv6_name'].append(set_name)
+
+ updated = True
+
+ if 'delete' in node_diff:
+ for key, path in dict_search_recursive(node_diff['delete'], 'geoip'):
+ set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}'
+ if (path[0] == 'ipv4'):
+ out['deleted_name'].append(set_name)
+ elif (path[0] == 'ipv6'):
+ set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}'
+ out['deleted_ipv6_name'].append(set_name)
+ updated = True
+
+ if updated:
+ return out
+
+ return False
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['firewall']
+
+ firewall = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+
+ firewall['group_resync'] = bool('group' in firewall or is_node_changed(conf, base + ['group']))
+ if firewall['group_resync']:
+ # Update nat and policy-route as firewall groups were updated
+ set_dependents('group_resync', conf)
+
+ firewall['geoip_updated'] = geoip_updated(conf, firewall)
+
+ fqdn_config_parse(firewall)
+
+ set_dependents('conntrack', conf)
+
+ return firewall
+
+def verify_jump_target(firewall, hook, jump_target, family, recursive=False):
+ targets_seen = []
+ targets_pending = [jump_target]
+
+ while targets_pending:
+ target = targets_pending.pop()
+
+ if 'name' not in firewall[family]:
+ raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system')
+ elif target not in dict_search_args(firewall, family, 'name'):
+ raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system')
+
+ target_rules = dict_search_args(firewall, family, 'name', target, 'rule')
+ no_ipsec_in = hook in ('output', )
+
+ if target_rules:
+ for target_rule_conf in target_rules.values():
+ # Output hook types will not tolerate 'meta ipsec exists' matches even in jump targets:
+ if no_ipsec_in and (dict_search_args(target_rule_conf, 'ipsec', 'match_ipsec_in') is not None \
+ or dict_search_args(target_rule_conf, 'ipsec', 'match_none_in') is not None):
+ raise ConfigError(f'Invalid jump-target for {hook}. Firewall {family} name {target} rules contain incompatible ipsec inbound matches')
+ # Make sure we're not looping back on ourselves somewhere:
+ if recursive and 'jump_target' in target_rule_conf:
+ child_target = target_rule_conf['jump_target']
+ if child_target in targets_seen:
+ raise ConfigError(f'Loop detected in jump-targets, firewall {family} name {target} refers to previously traversed {family} name {child_target}')
+ targets_pending.append(child_target)
+ if len(targets_seen) == 7:
+ path_txt = ' -> '.join(targets_seen)
+ Warning(f'Deep nesting of jump targets has reached 8 levels deep, following the path {path_txt} -> {child_target}!')
+
+ targets_seen.append(target)
+
+def verify_rule(firewall, family, hook, priority, rule_id, rule_conf):
+ if 'action' not in rule_conf:
+ raise ConfigError('Rule action must be defined')
+
+ if 'jump' in rule_conf['action'] and 'jump_target' not in rule_conf:
+ raise ConfigError('Action set to jump, but no jump-target specified')
+
+ if 'jump_target' in rule_conf:
+ if 'jump' not in rule_conf['action']:
+ raise ConfigError('jump-target defined, but action jump needed and it is not defined')
+ target = rule_conf['jump_target']
+ if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code.
+ verify_jump_target(firewall, hook, target, family, recursive=True)
+ else:
+ verify_jump_target(firewall, hook, target, family, recursive=False)
+
+ if rule_conf['action'] == 'offload':
+ if 'offload_target' not in rule_conf:
+ raise ConfigError('Action set to offload, but no offload-target specified')
+
+ offload_target = rule_conf['offload_target']
+
+ if not dict_search_args(firewall, 'flowtable', offload_target):
+ raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system')
+
+ if rule_conf['action'] != 'synproxy' and 'synproxy' in rule_conf:
+ raise ConfigError('"synproxy" option allowed only for action synproxy')
+ if rule_conf['action'] == 'synproxy':
+ if 'state' in rule_conf:
+ raise ConfigError('For action "synproxy" state cannot be defined')
+ if not rule_conf.get('synproxy', {}).get('tcp'):
+ raise ConfigError('synproxy TCP MSS is not defined')
+ if rule_conf.get('protocol', {}) != 'tcp':
+ raise ConfigError('For action "synproxy" the protocol must be set to TCP')
+
+ if 'queue_options' in rule_conf:
+ if 'queue' not in rule_conf['action']:
+ raise ConfigError('queue-options defined, but action queue needed and it is not defined')
+ if 'fanout' in rule_conf['queue_options'] and ('queue' not in rule_conf or '-' not in rule_conf['queue']):
+ raise ConfigError('queue-options fanout defined, then queue needs to be defined as a range')
+
+ if 'queue' in rule_conf and 'queue' not in rule_conf['action']:
+ raise ConfigError('queue defined, but action queue needed and it is not defined')
+
+ if 'fragment' in rule_conf:
+ if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']):
+ raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"')
+
+ if 'limit' in rule_conf:
+ if 'rate' in rule_conf['limit']:
+ rate_int = re.sub(r'\D', '', rule_conf['limit']['rate'])
+ if int(rate_int) < 1:
+ raise ConfigError('Limit rate integer cannot be less than 1')
+
+ if 'ipsec' in rule_conf:
+ if {'match_ipsec_in', 'match_none_in'} <= set(rule_conf['ipsec']):
+ raise ConfigError('Cannot specify both "match-ipsec" and "match-none"')
+ if {'match_ipsec_out', 'match_none_out'} <= set(rule_conf['ipsec']):
+ raise ConfigError('Cannot specify both "match-ipsec" and "match-none"')
+
+ if 'recent' in rule_conf:
+ if not {'count', 'time'} <= set(rule_conf['recent']):
+ raise ConfigError('Recent "count" and "time" values must be defined')
+
+ if 'gre' in rule_conf:
+ if dict_search_args(rule_conf, 'protocol') != 'gre':
+ raise ConfigError('Protocol must be gre when matching GRE flags and fields')
+
+ if dict_search_args(rule_conf, 'gre', 'key'):
+ if dict_search_args(rule_conf, 'gre', 'version') == 'pptp':
+ raise ConfigError('GRE tunnel keys are not present in PPTP')
+
+ if dict_search_args(rule_conf, 'gre', 'flags', 'checksum') is None:
+ # There is no builtin match in nftables for the GRE key, so we need to do a raw lookup.
+ # The offset of the key within the packet shifts depending on the C-flag.
+ # 99% of the time, nobody will have checksums enabled - it's usually a manual config option.
+ # We can either assume it is unset unless otherwise directed
+ # (confusing, requires doco to explain why it doesn't work sometimes)
+ # or, demand an explicit selection to be made for this specific match rule.
+ # This check enforces the latter. The user is free to create rules for both cases.
+ raise ConfigError('Matching GRE tunnel key requires an explicit checksum flag match. For most cases, use "gre flags checksum unset"')
+
+ if dict_search_args(rule_conf, 'gre', 'flags', 'key', 'unset') is not None:
+ raise ConfigError('Matching GRE tunnel key implies "flags key", cannot specify "flags key unset"')
+
+ gre_inner_proto = dict_search_args(rule_conf, 'gre', 'inner_proto')
+ if gre_inner_proto is not None:
+ try:
+ gre_inner_value = int(gre_inner_proto, 0)
+ if gre_inner_value < 0 or gre_inner_value > 65535:
+ raise ConfigError('inner-proto outside valid ethertype range 0-65535')
+ except ValueError:
+ pass # Symbolic constant, pre-validated before reaching here.
+
+ tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
+ if tcp_flags:
+ if dict_search_args(rule_conf, 'protocol') != 'tcp':
+ raise ConfigError('Protocol must be tcp when specifying tcp flags')
+
+ not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not')
+ if not_flags:
+ duplicates = [flag for flag in tcp_flags if flag in not_flags]
+ if duplicates:
+ raise ConfigError(f'Cannot match a tcp flag as set and not set')
+
+ if 'protocol' in rule_conf:
+ if rule_conf['protocol'] == 'icmp' and family == 'ipv6':
+ raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp')
+ if rule_conf['protocol'] == 'ipv6-icmp' and family == 'ipv4':
+ raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp')
+
+ for side in ['destination', 'source']:
+ if side in rule_conf:
+ side_conf = rule_conf[side]
+
+ if len({'address', 'fqdn', 'geoip'} & set(side_conf)) > 1:
+ raise ConfigError('Only one of address, fqdn or geoip can be specified')
+
+ if 'group' in side_conf:
+ if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1:
+ raise ConfigError('Only one address-group, network-group or domain-group can be specified')
+
+ for group in valid_groups:
+ if group in side_conf['group']:
+ group_name = side_conf['group'][group]
+
+ if family == 'ipv6' and group in ['address_group', 'network_group']:
+ fw_group = f'ipv6_{group}'
+ elif family == 'bridge':
+ if group =='ipv4_address_group':
+ fw_group = 'address_group'
+ elif group == 'ipv4_network_group':
+ fw_group = 'network_group'
+ else:
+ fw_group = group
+ else:
+ fw_group = group
+
+ error_group = fw_group.replace("_", "-")
+
+ if group in ['address_group', 'network_group', 'domain_group']:
+ types = [t for t in ['address', 'fqdn', 'geoip'] if t in side_conf]
+ if types:
+ raise ConfigError(f'{error_group} and {types[0]} cannot both be defined')
+
+ if group_name and group_name[0] == '!':
+ group_name = group_name[1:]
+
+ group_obj = dict_search_args(firewall, 'group', fw_group, group_name)
+
+ if group_obj is None:
+ raise ConfigError(f'Invalid {error_group} "{group_name}" on firewall rule')
+
+ if not group_obj:
+ Warning(f'{error_group} "{group_name}" has no members!')
+
+ if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'):
+ if 'protocol' not in rule_conf:
+ raise ConfigError('Protocol must be defined if specifying a port or port-group')
+
+ if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+ raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group')
+
+ if 'port' in side_conf and dict_search_args(side_conf, 'group', 'port_group'):
+ raise ConfigError(f'{side} port-group and port cannot both be defined')
+
+ if 'add_address_to_group' in rule_conf:
+ for type in ['destination_address', 'source_address']:
+ if type in rule_conf['add_address_to_group']:
+ if 'address_group' not in rule_conf['add_address_to_group'][type]:
+ raise ConfigError(f'Dynamic address group must be defined.')
+ else:
+ target = rule_conf['add_address_to_group'][type]['address_group']
+ fwall_group = 'ipv6_address_group' if family == 'ipv6' else 'address_group'
+ group_obj = dict_search_args(firewall, 'group', 'dynamic_group', fwall_group, target)
+ if group_obj is None:
+ raise ConfigError(f'Invalid dynamic address group on firewall rule')
+
+ if 'log_options' in rule_conf:
+ if 'log' not in rule_conf:
+ raise ConfigError('log-options defined, but log is not enable')
+
+ if 'snapshot_length' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']:
+ raise ConfigError('log-options snapshot-length defined, but log group is not define')
+
+ if 'queue_threshold' in rule_conf['log_options'] and 'group' not in rule_conf['log_options']:
+ raise ConfigError('log-options queue-threshold defined, but log group is not define')
+
+ for direction in ['inbound_interface','outbound_interface']:
+ if direction in rule_conf:
+ if 'name' in rule_conf[direction] and 'group' in rule_conf[direction]:
+ raise ConfigError(f'Cannot specify both interface group and interface name for {direction}')
+ if 'group' in rule_conf[direction]:
+ group_name = rule_conf[direction]['group']
+ if group_name[0] == '!':
+ group_name = group_name[1:]
+ group_obj = dict_search_args(firewall, 'group', 'interface_group', group_name)
+ if group_obj is None:
+ raise ConfigError(f'Invalid interface group "{group_name}" on firewall rule')
+ if not group_obj:
+ Warning(f'interface-group "{group_name}" has no members!')
+
+def verify_nested_group(group_name, group, groups, seen):
+ if 'include' not in group:
+ return
+
+ seen.append(group_name)
+
+ for g in group['include']:
+ if g not in groups:
+ raise ConfigError(f'Nested group "{g}" does not exist')
+
+ if g in seen:
+ raise ConfigError(f'Group "{group_name}" has a circular reference')
+
+ if 'include' in groups[g]:
+ verify_nested_group(g, groups[g], groups, seen)
+
+def verify_hardware_offload(ifname):
+ ethtool = Ethtool(ifname)
+ enabled, fixed = ethtool.get_hw_tc_offload()
+
+ if not enabled and fixed:
+ raise ConfigError(f'Interface "{ifname}" does not support hardware offload')
+
+ if not enabled:
+ raise ConfigError(f'Interface "{ifname}" requires "offload hw-tc-offload"')
+
+def verify(firewall):
+ if 'flowtable' in firewall:
+ for flowtable, flowtable_conf in firewall['flowtable'].items():
+ if 'interface' not in flowtable_conf:
+ raise ConfigError(f'Flowtable "{flowtable}" requires at least one interface')
+
+ for ifname in flowtable_conf['interface']:
+ verify_interface_exists(firewall, ifname)
+
+ if dict_search_args(flowtable_conf, 'offload') == 'hardware':
+ interfaces = flowtable_conf['interface']
+
+ for ifname in interfaces:
+ verify_hardware_offload(ifname)
+
+ if 'group' in firewall:
+ for group_type in nested_group_types:
+ if group_type in firewall['group']:
+ groups = firewall['group'][group_type]
+ for group_name, group in groups.items():
+ verify_nested_group(group_name, group, groups, [])
+
+ for family in ['ipv4', 'ipv6', 'bridge']:
+ if family in firewall:
+ for chain in ['name','forward','input','output', 'prerouting']:
+ if chain in firewall[family]:
+ for priority, priority_conf in firewall[family][chain].items():
+ if 'jump' in priority_conf['default_action'] and 'default_jump_target' not in priority_conf:
+ raise ConfigError('default-action set to jump, but no default-jump-target specified')
+ if 'default_jump_target' in priority_conf:
+ target = priority_conf['default_jump_target']
+ if 'jump' not in priority_conf['default_action']:
+ raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined')
+ if priority_conf['default_jump_target'] == priority:
+ raise ConfigError(f'Loop detected on default-jump-target.')
+ if target not in dict_search_args(firewall[family], 'name'):
+ raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system')
+ if 'rule' in priority_conf:
+ for rule_id, rule_conf in priority_conf['rule'].items():
+ verify_rule(firewall, family, chain, priority, rule_id, rule_conf)
+
+ local_zone = False
+ zone_interfaces = []
+
+ if 'zone' in firewall:
+ for zone, zone_conf in firewall['zone'].items():
+ if 'local_zone' not in zone_conf and 'interface' not in zone_conf:
+ raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone')
+
+ if 'local_zone' in zone_conf:
+ if local_zone:
+ raise ConfigError('There cannot be multiple local zones')
+ if 'interface' in zone_conf:
+ raise ConfigError('Local zone cannot have interfaces assigned')
+ if 'intra_zone_filtering' in zone_conf:
+ raise ConfigError('Local zone cannot use intra-zone-filtering')
+ local_zone = True
+
+ if 'interface' in zone_conf:
+ found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces]
+
+ if found_duplicates:
+ raise ConfigError(f'Interfaces cannot be assigned to multiple zones')
+
+ zone_interfaces += zone_conf['interface']
+
+ if 'intra_zone_filtering' in zone_conf:
+ intra_zone = zone_conf['intra_zone_filtering']
+
+ if len(intra_zone) > 1:
+ raise ConfigError('Only one intra-zone-filtering action must be specified')
+
+ if 'firewall' in intra_zone:
+ v4_name = dict_search_args(intra_zone, 'firewall', 'name')
+ if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name):
+ raise ConfigError(f'Firewall name "{v4_name}" does not exist')
+
+ v6_name = dict_search_args(intra_zone, 'firewall', 'ipv6_name')
+ if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name):
+ raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist')
+
+ if not v4_name and not v6_name:
+ raise ConfigError('No firewall names specified for intra-zone-filtering')
+
+ if 'from' in zone_conf:
+ for from_zone, from_conf in zone_conf['from'].items():
+ if from_zone not in firewall['zone']:
+ raise ConfigError(f'Zone "{zone}" refers to a non-existent or deleted zone "{from_zone}"')
+
+ v4_name = dict_search_args(from_conf, 'firewall', 'name')
+ if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name):
+ raise ConfigError(f'Firewall name "{v4_name}" does not exist')
+
+ v6_name = dict_search_args(from_conf, 'firewall', 'ipv6_name')
+ if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name):
+ raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist')
+
+ return None
+
+def generate(firewall):
+ if not os.path.exists(nftables_conf):
+ firewall['first_install'] = True
+
+ if 'zone' in firewall:
+ for local_zone, local_zone_conf in firewall['zone'].items():
+ if 'local_zone' not in local_zone_conf:
+ continue
+
+ local_zone_conf['from_local'] = {}
+
+ for zone, zone_conf in firewall['zone'].items():
+ if zone == local_zone or 'from' not in zone_conf:
+ continue
+ if local_zone in zone_conf['from']:
+ local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone]
+
+ render(nftables_conf, 'firewall/nftables.j2', firewall)
+ render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall)
+ return None
+
+def parse_firewall_error(output):
+ # Define the regex patterns to extract the error message and the comment
+ error_pattern = re.compile(r'Error:\s*(.*?)\n')
+ comment_pattern = re.compile(r'comment\s+"([^"]+)"')
+ error_output = []
+
+ # Find all error messages in the output
+ error_matches = error_pattern.findall(output)
+ # Find all comment matches in the output
+ comment_matches = comment_pattern.findall(output)
+
+ if not error_matches or not comment_matches:
+ raise ConfigError(f'Unknown firewall error detected: {output}')
+
+ error_output.append('Fail to apply firewall')
+ # Loop over the matches and process them
+ for error_message, comment in zip(error_matches, comment_matches):
+ # Parse the comment
+ parsed_entries = comment.split('-')
+ family = 'bridge' if parsed_entries[0] == 'bri' else parsed_entries[0]
+ if parsed_entries[1] == 'NAM':
+ chain = 'name'
+ elif parsed_entries[1] == 'FWD':
+ chain = 'forward'
+ elif parsed_entries[1] == 'INP':
+ chain = 'input'
+ elif parsed_entries[1] == 'OUT':
+ chain = 'output'
+ elif parsed_entries[1] == 'PRE':
+ chain = 'prerouting'
+ error_output.append(f'Error found on: firewall {family} {chain} {parsed_entries[2]} rule {parsed_entries[3]}')
+ error_output.append(f'\tError message: {error_message.strip()}')
+
+ raise ConfigError('\n'.join(error_output))
+
+def apply(firewall):
+ # Use nft -c option to check current configuration file
+ completed_process = subp_run(['nft', '-c', '--file', nftables_conf], capture_output=True)
+ install_result = completed_process.returncode
+ if install_result == 1:
+ # We need to handle firewall error
+ output = completed_process.stderr
+ parse_firewall_error(output.decode())
+
+ # No error detected during check, we can apply the new configuration
+ install_result, output = rc_cmd(f'nft --file {nftables_conf}')
+ # Double check just in case
+ if install_result == 1:
+ raise ConfigError(f'Failed to apply firewall: {output}')
+
+ # Apply firewall global-options sysctl settings
+ cmd(f'sysctl -f {sysctl_file}')
+
+ call_dependents()
+
+ # T970 Enable a resolver (systemd daemon) that checks
+ # domain-group/fqdn addresses and update entries for domains by timeout
+ # If router loaded without internet connection or for synchronization
+ domain_action = 'stop'
+ if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'] or firewall['ip6_fqdn']:
+ domain_action = 'restart'
+ call(f'systemctl {domain_action} vyos-domain-resolver.service')
+
+ if firewall['geoip_updated']:
+ # Call helper script to Update set contents
+ if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']:
+ print('Updating GeoIP. Please wait...')
+ geoip_update(firewall)
+
+ return None
+
+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/high-availability.py b/src/conf_mode/high-availability.py
new file mode 100644
index 0000000..c726db8
--- /dev/null
+++ b/src/conf_mode/high-availability.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 os
+import time
+
+from sys import exit
+from ipaddress import ip_interface
+from ipaddress import IPv4Interface
+from ipaddress import IPv6Interface
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import leaf_node_changed
+from vyos.ifconfig.vrrp import VRRP
+from vyos.template import render
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+from vyos.utils.network import is_ipv6_tentative
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+systemd_override = r'/run/systemd/system/keepalived.service.d/10-override.conf'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['high-availability']
+ if not conf.exists(base):
+ return None
+
+ ha = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True, with_defaults=True)
+
+ ## Get the sync group used for conntrack-sync
+ conntrack_path = ['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group']
+ if conf.exists(conntrack_path):
+ ha['conntrack_sync_group'] = conf.return_value(conntrack_path)
+
+ if leaf_node_changed(conf, base + ['vrrp', 'snmp']):
+ ha.update({'restart_required': {}})
+
+ return ha
+
+def verify(ha):
+ if not ha or 'disable' in ha:
+ return None
+
+ used_vrid_if = []
+ if 'vrrp' in ha and 'group' in ha['vrrp']:
+ for group, group_config in ha['vrrp']['group'].items():
+ # Check required fields
+ if 'vrid' not in group_config:
+ raise ConfigError(f'VRID is required but not set in VRRP group "{group}"')
+
+ if 'interface' not in group_config:
+ raise ConfigError(f'Interface is required but not set in VRRP group "{group}"')
+
+ if 'address' not in group_config:
+ raise ConfigError(f'Virtual IP address is required but not set in VRRP group "{group}"')
+
+ if 'authentication' in group_config:
+ if not {'password', 'type'} <= set(group_config['authentication']):
+ raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"')
+
+ if 'health_check' in group_config:
+ _validate_health_check(group, group_config)
+
+ # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction
+ # We also need to make sure VRID is not used twice on the same interface with the
+ # same address family.
+
+ interface = group_config['interface']
+ vrid = group_config['vrid']
+
+ # XXX: filter on map object is destructive, so we force it to list.
+ # Additionally, filter objects always evaluate to True, empty or not,
+ # so we force them to lists as well.
+ vaddrs = list(map(lambda i: ip_interface(i), group_config['address']))
+ vaddrs4 = list(filter(lambda x: isinstance(x, IPv4Interface), vaddrs))
+ vaddrs6 = list(filter(lambda x: isinstance(x, IPv6Interface), vaddrs))
+
+ if vaddrs4 and vaddrs6:
+ raise ConfigError(f'VRRP group "{group}" mixes IPv4 and IPv6 virtual addresses, this is not allowed.\n' \
+ 'Create individual groups for IPv4 and IPv6!')
+ if vaddrs4:
+ tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv4'}
+ if tmp in used_vrid_if:
+ raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv4"!')
+ used_vrid_if.append(tmp)
+
+ if 'hello_source_address' in group_config:
+ if is_ipv6(group_config['hello_source_address']):
+ raise ConfigError(f'VRRP group "{group}" uses IPv4 but hello-source-address is IPv6!')
+
+ if 'peer_address' in group_config:
+ for peer_address in group_config['peer_address']:
+ if is_ipv6(peer_address):
+ raise ConfigError(f'VRRP group "{group}" uses IPv4 but peer-address is IPv6!')
+
+ if vaddrs6:
+ tmp = {'interface': interface, 'vrid': vrid, 'ipver': 'IPv6'}
+ if tmp in used_vrid_if:
+ raise ConfigError(f'VRID "{vrid}" can only be used once on interface "{interface} with address family IPv6"!')
+ used_vrid_if.append(tmp)
+
+ if 'hello_source_address' in group_config:
+ if is_ipv4(group_config['hello_source_address']):
+ raise ConfigError(f'VRRP group "{group}" uses IPv6 but hello-source-address is IPv4!')
+
+ if 'peer_address' in group_config:
+ for peer_address in group_config['peer_address']:
+ if is_ipv4(peer_address):
+ raise ConfigError(f'VRRP group "{group}" uses IPv6 but peer-address is IPv4!')
+ # Check sync groups
+ if 'vrrp' in ha and 'sync_group' in ha['vrrp']:
+ for sync_group, sync_config in ha['vrrp']['sync_group'].items():
+ if 'health_check' in sync_config:
+ _validate_health_check(sync_group, sync_config)
+
+ if 'member' in sync_config:
+ for member in sync_config['member']:
+ if member not in ha['vrrp']['group']:
+ raise ConfigError(f'VRRP sync-group "{sync_group}" refers to VRRP group "{member}", '\
+ 'but it does not exist!')
+ else:
+ ha['vrrp']['group'][member]['_is_sync_group_member'] = True
+ if ha['vrrp']['group'][member].get('health_check') is not None:
+ raise ConfigError(
+ f'Health check configuration for VRRP group "{member}" will remain unused '
+ f'while it has member of sync group "{sync_group}" '
+ f'Only sync group health check will be used'
+ )
+
+ # Virtual-server
+ if 'virtual_server' in ha:
+ for vs, vs_config in ha['virtual_server'].items():
+
+ if 'address' not in vs_config and 'fwmark' not in vs_config:
+ raise ConfigError('Either address or fwmark is required '
+ f'but not set for virtual-server "{vs}"')
+
+ if 'port' not in vs_config and 'fwmark' not in vs_config:
+ raise ConfigError(f'Port or fwmark is required but not set for virtual-server "{vs}"')
+ if 'port' in vs_config and 'fwmark' in vs_config:
+ raise ConfigError(f'Cannot set both port and fwmark for virtual-server "{vs}"')
+ if 'real_server' not in vs_config:
+ raise ConfigError(f'Real-server ip is required but not set for virtual-server "{vs}"')
+ # Real-server
+ for rs, rs_config in vs_config['real_server'].items():
+ if 'port' not in rs_config:
+ raise ConfigError(f'Port is required but not set for virtual-server "{vs}" real-server "{rs}"')
+
+
+def _validate_health_check(group, group_config):
+ health_check_types = ["script", "ping"]
+ from vyos.utils.dict import check_mutually_exclusive_options
+ try:
+ check_mutually_exclusive_options(group_config["health_check"],
+ health_check_types, required=True)
+ except ValueError:
+ Warning(
+ f'Health check configuration for VRRP group "{group}" will remain unused ' \
+ f'until it has one of the following options: {health_check_types}')
+ # XXX: health check has default options so we need to remove it
+ # to avoid generating useless config statements in keepalived.conf
+ del group_config["health_check"]
+
+
+def generate(ha):
+ if not ha or 'disable' in ha:
+ if os.path.isfile(systemd_override):
+ os.unlink(systemd_override)
+ return None
+
+ render(VRRP.location['config'], 'high-availability/keepalived.conf.j2', ha)
+ render(systemd_override, 'high-availability/10-override.conf.j2', ha)
+ return None
+
+def apply(ha):
+ service_name = 'keepalived.service'
+ call('systemctl daemon-reload')
+ if not ha or 'disable' in ha:
+ call(f'systemctl stop {service_name}')
+ return None
+
+ # Check if IPv6 address is tentative T5533
+ for group, group_config in ha.get('vrrp', {}).get('group', {}).items():
+ if 'hello_source_address' in group_config:
+ if is_ipv6(group_config['hello_source_address']):
+ ipv6_address = group_config['hello_source_address']
+ interface = group_config['interface']
+ checks = 20
+ interval = 0.1
+ for _ in range(checks):
+ if is_ipv6_tentative(interface, ipv6_address):
+ time.sleep(interval)
+
+ systemd_action = 'reload-or-restart'
+ if 'restart_required' in ha:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {service_name}')
+ return None
+
+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/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py
new file mode 100644
index 0000000..bbbfb03
--- /dev/null
+++ b/src/conf_mode/interfaces_bonding.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configdict import leaf_node_changed
+from vyos.configdict import is_member
+from vyos.configdict import is_source_interface
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_dhcpv6
+from vyos.configverify import verify_eapol
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_vlan_config
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import BondIf
+from vyos.ifconfig.ethernet import EthernetIf
+from vyos.ifconfig import Section
+from vyos.template import render_to_string
+from vyos.utils.assertion import assert_mac
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_to_paths_values
+from vyos.utils.network import interface_exists
+from vyos.configdict import has_address_configured
+from vyos.configdict import has_vrf_configured
+from vyos.configdep import set_dependents, call_dependents
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_bond_mode(mode):
+ if mode == 'round-robin':
+ return 'balance-rr'
+ elif mode == 'active-backup':
+ return 'active-backup'
+ elif mode == 'xor-hash':
+ return 'balance-xor'
+ elif mode == 'broadcast':
+ return 'broadcast'
+ elif mode == '802.3ad':
+ return '802.3ad'
+ elif mode == 'transmit-load-balance':
+ return 'balance-tlb'
+ elif mode == 'adaptive-load-balance':
+ return 'balance-alb'
+ else:
+ raise ConfigError(f'invalid bond mode "{mode}"')
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'bonding']
+ ifname, bond = get_interface_dict(conf, base, with_pki=True)
+
+ # To make our own life easier transfor the list of member interfaces
+ # into a dictionary - we will use this to add additional information
+ # later on for each member
+ if 'member' in bond and 'interface' in bond['member']:
+ # convert list of member interfaces to a dictionary
+ bond['member']['interface'] = {k: {} for k in bond['member']['interface']}
+
+ if 'mode' in bond:
+ bond['mode'] = get_bond_mode(bond['mode'])
+
+ tmp = is_node_changed(conf, base + [ifname, 'mode'])
+ if tmp: bond['shutdown_required'] = {}
+
+ tmp = is_node_changed(conf, base + [ifname, 'lacp-rate'])
+ if tmp: bond['shutdown_required'] = {}
+
+ # determine which members have been removed
+ interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface'])
+ # Reset config level to interfaces
+ old_level = conf.get_level()
+ conf.set_level(['interfaces'])
+
+ if interfaces_removed:
+ bond['shutdown_required'] = {}
+ if 'member' not in bond:
+ bond['member'] = {}
+
+ tmp = {}
+ for interface in interfaces_removed:
+ # if member is deleted from bond, add dependencies to call
+ # ethernet commit again in apply function
+ # to apply options under ethernet section
+ set_dependents('ethernet', conf, interface)
+ section = Section.section(interface) # this will be 'ethernet' for 'eth0'
+ if conf.exists([section, interface, 'disable']):
+ tmp[interface] = {'disable': ''}
+ else:
+ tmp[interface] = {}
+
+ # also present the interfaces to be removed from the bond as dictionary
+ bond['member']['interface_remove'] = tmp
+
+ # Restore existing config level
+ conf.set_level(old_level)
+
+ if dict_search('member.interface', bond):
+ for interface, interface_config in bond['member']['interface'].items():
+
+ interface_ethernet_config = conf.get_config_dict(
+ ['interfaces', 'ethernet', interface],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_defaults=False,
+ with_recursive_defaults=False)
+
+ interface_config['config_paths'] = dict_to_paths_values(interface_ethernet_config)
+
+ # Check if member interface is a new member
+ if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]):
+ bond['shutdown_required'] = {}
+ interface_config['new_added'] = {}
+
+ # Check if member interface is disabled
+ conf.set_level(['interfaces'])
+
+ section = Section.section(interface) # this will be 'ethernet' for 'eth0'
+ if conf.exists([section, interface, 'disable']):
+ interface_config['disable'] = ''
+
+ conf.set_level(old_level)
+
+ # Check if member interface is already member of another bridge
+ tmp = is_member(conf, interface, 'bridge')
+ if tmp: interface_config['is_bridge_member'] = tmp
+
+ # Check if member interface is already member of a bond
+ tmp = is_member(conf, interface, 'bonding')
+ for tmp in is_member(conf, interface, 'bonding'):
+ if bond['ifname'] == tmp:
+ continue
+ interface_config['is_bond_member'] = tmp
+
+ # Check if member interface is used as source-interface on another interface
+ tmp = is_source_interface(conf, interface)
+ if tmp: interface_config['is_source_interface'] = tmp
+
+ # bond members must not have an assigned address
+ tmp = has_address_configured(conf, interface)
+ if tmp: interface_config['has_address'] = {}
+
+ # bond members must not have a VRF attached
+ tmp = has_vrf_configured(conf, interface)
+ if tmp: interface_config['has_vrf'] = {}
+ return bond
+
+
+def verify(bond):
+ if 'deleted' in bond:
+ verify_bridge_delete(bond)
+ return None
+
+ if 'arp_monitor' in bond:
+ if 'target' in bond['arp_monitor'] and len(bond['arp_monitor']['target']) > 16:
+ raise ConfigError('The maximum number of arp-monitor targets is 16')
+
+ if 'interval' in bond['arp_monitor'] and int(bond['arp_monitor']['interval']) > 0:
+ if bond['mode'] in ['802.3ad', 'balance-tlb', 'balance-alb']:
+ raise ConfigError('ARP link monitoring does not work for mode 802.3ad, ' \
+ 'transmit-load-balance or adaptive-load-balance')
+
+ if 'primary' in bond:
+ if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']:
+ raise ConfigError('Option primary - mode dependency failed, not'
+ 'supported in mode {mode}!'.format(**bond))
+
+ verify_mtu_ipv6(bond)
+ verify_address(bond)
+ verify_dhcpv6(bond)
+ verify_vrf(bond)
+ verify_mirror_redirect(bond)
+ verify_eapol(bond)
+
+ # use common function to verify VLAN configuration
+ verify_vlan_config(bond)
+
+ bond_name = bond['ifname']
+ if dict_search('member.interface', bond):
+ for interface, interface_config in bond['member']['interface'].items():
+ error_msg = f'Can not add interface "{interface}" to bond, '
+
+ if interface == 'lo':
+ raise ConfigError('Loopback interface "lo" can not be added to a bond')
+
+ if not interface_exists(interface):
+ raise ConfigError(error_msg + 'it does not exist!')
+
+ if 'is_bridge_member' in interface_config:
+ tmp = next(iter(interface_config['is_bridge_member']))
+ raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!')
+
+ if 'is_bond_member' in interface_config:
+ tmp = next(iter(interface_config['is_bond_member']))
+ raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!')
+
+ if 'is_source_interface' in interface_config:
+ tmp = interface_config['is_source_interface']
+ raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!')
+
+ if 'has_address' in interface_config:
+ raise ConfigError(error_msg + 'it has an address assigned!')
+
+ if 'has_vrf' in interface_config:
+ raise ConfigError(error_msg + 'it has a VRF assigned!')
+
+ if 'new_added' in interface_config and 'config_paths' in interface_config:
+ for option_path, option_value in interface_config['config_paths'].items():
+ if option_path in EthernetIf.get_bond_member_allowed_options() :
+ continue
+ if option_path in BondIf.get_inherit_bond_options():
+ continue
+ raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!')
+
+ if 'primary' in bond:
+ if bond['primary'] not in bond['member']['interface']:
+ raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface')
+
+ if bond['mode'] not in ['active-backup', 'balance-tlb', 'balance-alb']:
+ raise ConfigError('primary interface only works for mode active-backup, ' \
+ 'transmit-load-balance or adaptive-load-balance')
+
+ if 'system_mac' in bond:
+ if bond['mode'] != '802.3ad':
+ raise ConfigError('Actor MAC address only available in 802.3ad mode!')
+
+ system_mac = bond['system_mac']
+ try:
+ assert_mac(system_mac, test_all_zero=False)
+ except:
+ raise ConfigError(f'Cannot use a multicast MAC address "{system_mac}" as system-mac!')
+
+ return None
+
+def generate(bond):
+ bond['frr_zebra_config'] = ''
+ if 'deleted' not in bond:
+ bond['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', bond)
+ return None
+
+def apply(bond):
+ ifname = bond['ifname']
+ b = BondIf(ifname)
+ if 'deleted' in bond:
+ # delete interface
+ b.remove()
+ else:
+ b.update(bond)
+
+ if dict_search('member.interface_remove', bond):
+ try:
+ call_dependents()
+ except ConfigError:
+ raise ConfigError('Error in updating ethernet interface '
+ 'after deleting it from bond')
+
+ zebra_daemon = 'zebra'
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True)
+ if 'frr_zebra_config' in bond:
+ frr_cfg.add_before(frr.default_add_before, bond['frr_zebra_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ return None
+
+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/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py
new file mode 100644
index 0000000..637db44
--- /dev/null
+++ b/src/conf_mode/interfaces_bridge.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import node_changed
+from vyos.configdict import is_member
+from vyos.configdict import is_source_interface
+from vyos.configdict import has_vlan_subinterface_configured
+from vyos.configverify import verify_dhcpv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import BridgeIf
+from vyos.configdict import has_address_configured
+from vyos.configdict import has_vrf_configured
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.utils.dict import dict_search
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'bridge']
+ ifname, bridge = get_interface_dict(conf, base)
+
+ # determine which members have been removed
+ tmp = node_changed(conf, base + [ifname, 'member', 'interface'])
+ if tmp:
+ if 'member' in bridge:
+ bridge['member'].update({'interface_remove': {t: {} for t in tmp}})
+ else:
+ bridge.update({'member': {'interface_remove': {t: {} for t in tmp}}})
+ for interface in tmp:
+ # When using VXLAN member interfaces that are configured for Single
+ # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to
+ # re-create VLAN to VNI mappings if required, but only if the interface
+ # is already live on the system - this must not be done on first commit
+ if interface.startswith('vxlan') and interface_exists(interface):
+ set_dependents('vxlan', conf, interface)
+ _, vxlan = get_interface_dict(conf, ['interfaces', 'vxlan'], ifname=interface)
+ bridge['member']['interface_remove'].update({interface: vxlan})
+ # When using Wireless member interfaces we need to inform hostapd
+ # to properly set-up the bridge
+ elif interface.startswith('wlan') and interface_exists(interface):
+ set_dependents('wlan', conf, interface)
+
+ if dict_search('member.interface', bridge) is not None:
+ for interface in list(bridge['member']['interface']):
+ # Check if member interface is already member of another bridge
+ tmp = is_member(conf, interface, 'bridge')
+ if tmp and bridge['ifname'] not in tmp:
+ bridge['member']['interface'][interface].update({'is_bridge_member' : tmp})
+
+ # Check if member interface is already member of a bond
+ tmp = is_member(conf, interface, 'bonding')
+ if tmp: bridge['member']['interface'][interface].update({'is_bond_member' : tmp})
+
+ # Check if member interface is used as source-interface on another interface
+ tmp = is_source_interface(conf, interface)
+ if tmp: bridge['member']['interface'][interface].update({'is_source_interface' : tmp})
+
+ # Bridge members must not have an assigned address
+ tmp = has_address_configured(conf, interface)
+ if tmp: bridge['member']['interface'][interface].update({'has_address' : ''})
+
+ # Bridge members must not have a VRF attached
+ tmp = has_vrf_configured(conf, interface)
+ if tmp: bridge['member']['interface'][interface].update({'has_vrf' : ''})
+
+ # VLAN-aware bridge members must not have VLAN interface configuration
+ tmp = has_vlan_subinterface_configured(conf,interface)
+ if 'enable_vlan' in bridge and tmp:
+ bridge['member']['interface'][interface].update({'has_vlan' : ''})
+
+ # When using VXLAN member interfaces that are configured for Single
+ # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to
+ # re-create VLAN to VNI mappings if required, but only if the interface
+ # is already live on the system - this must not be done on first commit
+ if interface.startswith('vxlan') and interface_exists(interface):
+ set_dependents('vxlan', conf, interface)
+ # When using Wireless member interfaces we need to inform hostapd
+ # to properly set-up the bridge
+ elif interface.startswith('wlan') and interface_exists(interface):
+ set_dependents('wlan', conf, interface)
+
+ # delete empty dictionary keys - no need to run code paths if nothing is there to do
+ if 'member' in bridge:
+ if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0:
+ del bridge['member']['interface']
+
+ if len(bridge['member']) == 0:
+ del bridge['member']
+
+ return bridge
+
+def verify(bridge):
+ # to delete interface or remove a member interface VXLAN first need to check if
+ # VXLAN does not require to be a member of a bridge interface
+ if dict_search('member.interface_remove', bridge):
+ for iface, iface_config in bridge['member']['interface_remove'].items():
+ if iface.startswith('vxlan') and dict_search('parameters.neighbor_suppress', iface_config) != None:
+ raise ConfigError(
+ f'To detach interface {iface} from bridge you must first '
+ f'disable "neighbor-suppress" parameter in the VXLAN interface {iface}'
+ )
+
+ if 'deleted' in bridge:
+ return None
+
+ verify_dhcpv6(bridge)
+ verify_vrf(bridge)
+ verify_mirror_redirect(bridge)
+
+ ifname = bridge['ifname']
+
+ if dict_search('member.interface', bridge):
+ for interface, interface_config in bridge['member']['interface'].items():
+ error_msg = f'Can not add interface "{interface}" to bridge, '
+
+ if interface == 'lo':
+ raise ConfigError('Loopback interface "lo" can not be added to a bridge')
+
+ if 'is_bridge_member' in interface_config:
+ tmp = next(iter(interface_config['is_bridge_member']))
+ raise ConfigError(error_msg + f'it is already a member of bridge "{tmp}"!')
+
+ if 'is_bond_member' in interface_config:
+ tmp = next(iter(interface_config['is_bond_member']))
+ raise ConfigError(error_msg + f'it is already a member of bond "{tmp}"!')
+
+ if 'is_source_interface' in interface_config:
+ tmp = interface_config['is_source_interface']
+ raise ConfigError(error_msg + f'it is the source-interface of "{tmp}"!')
+
+ if 'has_address' in interface_config:
+ raise ConfigError(error_msg + 'it has an address assigned!')
+
+ if 'has_vrf' in interface_config:
+ raise ConfigError(error_msg + 'it has a VRF assigned!')
+
+ if 'enable_vlan' in bridge:
+ if 'has_vlan' in interface_config:
+ raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!')
+ else:
+ for option in ['allowed_vlan', 'native_vlan']:
+ if option in interface_config:
+ raise ConfigError('Can not use VLAN options on non VLAN aware bridge')
+
+ if 'enable_vlan' in bridge:
+ if dict_search('vif.1', bridge):
+ raise ConfigError(f'VLAN 1 sub interface cannot be set for VLAN aware bridge {ifname}, and VLAN 1 is always the parent interface')
+ else:
+ if dict_search('vif', bridge):
+ raise ConfigError(f'You must first activate "enable-vlan" of {ifname} bridge to use "vif"')
+
+ return None
+
+def generate(bridge):
+ return None
+
+def apply(bridge):
+ br = BridgeIf(bridge['ifname'])
+ if 'deleted' in bridge:
+ # delete interface
+ br.remove()
+ else:
+ br.update(bridge)
+
+ tmp = []
+ if 'member' in bridge:
+ if 'interface_remove' in bridge['member']:
+ tmp.extend(bridge['member']['interface_remove'])
+ if 'interface' in bridge['member']:
+ tmp.extend(bridge['member']['interface'])
+
+ for interface in tmp:
+ if interface.startswith(tuple(['vxlan', 'wlan'])) and interface_exists(interface):
+ try:
+ call_dependents()
+ except ConfigError:
+ raise ConfigError(f'Error updating member interface {interface} configuration after changing bridge!')
+
+ return None
+
+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/interfaces_dummy.py b/src/conf_mode/interfaces_dummy.py
new file mode 100644
index 0000000..db768b9
--- /dev/null
+++ b/src/conf_mode/interfaces_dummy.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mirror_redirect
+from vyos.ifconfig import DummyIf
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'dummy']
+ _, dummy = get_interface_dict(conf, base)
+ return dummy
+
+def verify(dummy):
+ if 'deleted' in dummy:
+ verify_bridge_delete(dummy)
+ return None
+
+ verify_vrf(dummy)
+ verify_address(dummy)
+ verify_mirror_redirect(dummy)
+
+ return None
+
+def generate(dummy):
+ return None
+
+def apply(dummy):
+ d = DummyIf(**dummy)
+
+ # Remove dummy interface
+ if 'deleted' in dummy:
+ d.remove()
+ else:
+ d.update(dummy)
+
+ return None
+
+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/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py
new file mode 100644
index 0000000..34ce7bc
--- /dev/null
+++ b/src/conf_mode/interfaces_ethernet.py
@@ -0,0 +1,360 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_address
+from vyos.configverify import verify_dhcpv6
+from vyos.configverify import verify_interface_exists
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_mtu
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_vlan_config
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_bond_bridge_member
+from vyos.configverify import verify_eapol
+from vyos.ethtool import Ethtool
+from vyos.ifconfig import EthernetIf
+from vyos.ifconfig import BondIf
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_to_paths_values
+from vyos.utils.dict import dict_set
+from vyos.utils.dict import dict_delete
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def update_bond_options(conf: Config, eth_conf: dict) -> list:
+ """
+ Return list of blocked options if interface is a bond member
+ :param conf: Config object
+ :type conf: Config
+ :param eth_conf: Ethernet config dictionary
+ :type eth_conf: dict
+ :return: List of blocked options
+ :rtype: list
+ """
+ blocked_list = []
+ bond_name = list(eth_conf['is_bond_member'].keys())[0]
+ config_without_defaults = conf.get_config_dict(
+ ['interfaces', 'ethernet', eth_conf['ifname']],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_defaults=False,
+ with_recursive_defaults=False)
+ config_with_defaults = conf.get_config_dict(
+ ['interfaces', 'ethernet', eth_conf['ifname']],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_defaults=True,
+ with_recursive_defaults=True)
+ bond_config_with_defaults = conf.get_config_dict(
+ ['interfaces', 'bonding', bond_name],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_defaults=True,
+ with_recursive_defaults=True)
+ eth_dict_paths = dict_to_paths_values(config_without_defaults)
+ eth_path_base = ['interfaces', 'ethernet', eth_conf['ifname']]
+
+ #if option is configured under ethernet section
+ for option_path, option_value in eth_dict_paths.items():
+ bond_option_value = dict_search(option_path, bond_config_with_defaults)
+
+ #If option is allowed for changing then continue
+ if option_path in EthernetIf.get_bond_member_allowed_options():
+ continue
+ # if option is inherited from bond then set valued from bond interface
+ if option_path in BondIf.get_inherit_bond_options():
+ # If option equals to bond option then do nothing
+ if option_value == bond_option_value:
+ continue
+ else:
+ # if ethernet has option and bond interface has
+ # then copy it from bond
+ if bond_option_value is not None:
+ if is_node_changed(conf, eth_path_base + option_path.split('.')):
+ Warning(
+ f'Cannot apply "{option_path.replace(".", " ")}" to "{option_value}".' \
+ f' Interface "{eth_conf["ifname"]}" is a bond member.' \
+ f' Option is inherited from bond "{bond_name}"')
+ dict_set(option_path, bond_option_value, eth_conf)
+ continue
+ # if ethernet has option and bond interface does not have
+ # then delete it form dict and do not apply it
+ else:
+ if is_node_changed(conf, eth_path_base + option_path.split('.')):
+ Warning(
+ f'Cannot apply "{option_path.replace(".", " ")}".' \
+ f' Interface "{eth_conf["ifname"]}" is a bond member.' \
+ f' Option is inherited from bond "{bond_name}"')
+ dict_delete(option_path, eth_conf)
+ blocked_list.append(option_path)
+
+ # if inherited option is not configured under ethernet section but configured under bond section
+ for option_path in BondIf.get_inherit_bond_options():
+ bond_option_value = dict_search(option_path, bond_config_with_defaults)
+ if bond_option_value is not None:
+ if option_path not in eth_dict_paths:
+ if is_node_changed(conf, eth_path_base + option_path.split('.')):
+ Warning(
+ f'Cannot apply "{option_path.replace(".", " ")}" to "{dict_search(option_path, config_with_defaults)}".' \
+ f' Interface "{eth_conf["ifname"]}" is a bond member. ' \
+ f'Option is inherited from bond "{bond_name}"')
+ dict_set(option_path, bond_option_value, eth_conf)
+ eth_conf['bond_blocked_changes'] = blocked_list
+ return None
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['interfaces', 'ethernet']
+ ifname, ethernet = get_interface_dict(conf, base, with_pki=True)
+
+ # T5862 - default MTU is not acceptable in some environments
+ # There are cloud environments available where the maximum supported
+ # ethernet MTU is e.g. 1450 bytes, thus we clamp this to the adapters
+ # maximum MTU value or 1500 bytes - whatever is lower
+ if 'mtu' not in ethernet:
+ try:
+ ethernet['mtu'] = '1500'
+ max_mtu = EthernetIf(ifname).get_max_mtu()
+ if max_mtu < int(ethernet['mtu']):
+ ethernet['mtu'] = str(max_mtu)
+ except:
+ pass
+
+ if 'is_bond_member' in ethernet:
+ update_bond_options(conf, ethernet)
+
+ tmp = is_node_changed(conf, base + [ifname, 'speed'])
+ if tmp: ethernet.update({'speed_duplex_changed': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'duplex'])
+ if tmp: ethernet.update({'speed_duplex_changed': {}})
+
+ return ethernet
+
+def verify_speed_duplex(ethernet: dict, ethtool: Ethtool):
+ """
+ Verify speed and duplex
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ :param ethtool: Ethernet object
+ :type ethtool: Ethtool
+ """
+ if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or
+ (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')):
+ raise ConfigError(
+ 'Speed/Duplex missmatch. Must be both auto or manually configured')
+
+ if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto':
+ # We need to verify if the requested speed and duplex setting is
+ # supported by the underlaying NIC.
+ speed = ethernet['speed']
+ duplex = ethernet['duplex']
+ if not ethtool.check_speed_duplex(speed, duplex):
+ raise ConfigError(
+ f'Adapter does not support changing speed ' \
+ f'and duplex settings to: {speed}/{duplex}!')
+
+
+def verify_flow_control(ethernet: dict, ethtool: Ethtool):
+ """
+ Verify flow control
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ :param ethtool: Ethernet object
+ :type ethtool: Ethtool
+ """
+ if 'disable_flow_control' in ethernet:
+ if not ethtool.check_flow_control():
+ raise ConfigError(
+ 'Adapter does not support changing flow-control settings!')
+
+
+def verify_ring_buffer(ethernet: dict, ethtool: Ethtool):
+ """
+ Verify ring buffer
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ :param ethtool: Ethernet object
+ :type ethtool: Ethtool
+ """
+ if 'ring_buffer' in ethernet:
+ max_rx = ethtool.get_ring_buffer_max('rx')
+ if not max_rx:
+ raise ConfigError(
+ 'Driver does not support RX ring-buffer configuration!')
+
+ max_tx = ethtool.get_ring_buffer_max('tx')
+ if not max_tx:
+ raise ConfigError(
+ 'Driver does not support TX ring-buffer configuration!')
+
+ rx = dict_search('ring_buffer.rx', ethernet)
+ if rx and int(rx) > int(max_rx):
+ raise ConfigError(f'Driver only supports a maximum RX ring-buffer ' \
+ f'size of "{max_rx}" bytes!')
+
+ tx = dict_search('ring_buffer.tx', ethernet)
+ if tx and int(tx) > int(max_tx):
+ raise ConfigError(f'Driver only supports a maximum TX ring-buffer ' \
+ f'size of "{max_tx}" bytes!')
+
+
+def verify_offload(ethernet: dict, ethtool: Ethtool):
+ """
+ Verify offloading capabilities
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ :param ethtool: Ethernet object
+ :type ethtool: Ethtool
+ """
+ if dict_search('offload.rps', ethernet) != None:
+ if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'):
+ raise ConfigError('Interface does not suport RPS!')
+ driver = ethtool.get_driver_name()
+ # T3342 - Xen driver requires special treatment
+ if driver == 'vif':
+ if int(ethernet['mtu']) > 1500 and dict_search('offload.sg', ethernet) == None:
+ raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\
+ 'for MTU size larger then 1500 bytes')
+
+
+def verify_allowedbond_changes(ethernet: dict):
+ """
+ Verify changed options if interface is in bonding
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ """
+ if 'bond_blocked_changes' in ethernet:
+ for option in ethernet['bond_blocked_changes']:
+ raise ConfigError(f'Cannot configure "{option.replace(".", " ")}"' \
+ f' on interface "{ethernet["ifname"]}".' \
+ f' Interface is a bond member')
+
+def verify(ethernet):
+ if 'deleted' in ethernet:
+ return None
+ if 'is_bond_member' in ethernet:
+ verify_bond_member(ethernet)
+ else:
+ verify_ethernet(ethernet)
+
+
+def verify_bond_member(ethernet):
+ """
+ Verification function for ethernet interface which is in bonding
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ """
+ ifname = ethernet['ifname']
+ verify_interface_exists(ethernet, ifname)
+ verify_eapol(ethernet)
+ verify_mirror_redirect(ethernet)
+ ethtool = Ethtool(ifname)
+ verify_speed_duplex(ethernet, ethtool)
+ verify_flow_control(ethernet, ethtool)
+ verify_ring_buffer(ethernet, ethtool)
+ verify_offload(ethernet, ethtool)
+ verify_allowedbond_changes(ethernet)
+
+def verify_ethernet(ethernet):
+ """
+ Verification function for simple ethernet interface
+ :param ethernet: dictionary which is received from get_interface_dict
+ :type ethernet: dict
+ """
+ ifname = ethernet['ifname']
+ verify_interface_exists(ethernet, ifname)
+ verify_mtu(ethernet)
+ verify_mtu_ipv6(ethernet)
+ verify_dhcpv6(ethernet)
+ verify_address(ethernet)
+ verify_vrf(ethernet)
+ verify_bond_bridge_member(ethernet)
+ verify_eapol(ethernet)
+ verify_mirror_redirect(ethernet)
+ ethtool = Ethtool(ifname)
+ # No need to check speed and duplex keys as both have default values.
+ verify_speed_duplex(ethernet, ethtool)
+ verify_flow_control(ethernet, ethtool)
+ verify_ring_buffer(ethernet, ethtool)
+ verify_offload(ethernet, ethtool)
+ # use common function to verify VLAN configuration
+ verify_vlan_config(ethernet)
+ return None
+
+def generate(ethernet):
+ if 'deleted' in ethernet:
+ return None
+
+ ethernet['frr_zebra_config'] = ''
+ if 'deleted' not in ethernet:
+ ethernet['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', ethernet)
+
+ return None
+
+def apply(ethernet):
+ ifname = ethernet['ifname']
+
+ e = EthernetIf(ifname)
+ if 'deleted' in ethernet:
+ # delete interface
+ e.remove()
+ else:
+ e.update(ethernet)
+
+ zebra_daemon = 'zebra'
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True)
+ if 'frr_zebra_config' in ethernet:
+ frr_cfg.add_before(frr.default_add_before, ethernet['frr_zebra_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+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/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py
new file mode 100644
index 0000000..007708d
--- /dev/null
+++ b/src/conf_mode/interfaces_geneve.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_address
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_bond_bridge_member
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import GeneveIf
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'geneve']
+ ifname, geneve = get_interface_dict(conf, base)
+
+ # GENEVE interfaces are picky and require recreation if certain parameters
+ # change. But a GENEVE interface should - of course - not be re-created if
+ # it's description or IP address is adjusted. Feels somehow logic doesn't it?
+ for cli_option in ['remote', 'vni', 'parameters']:
+ if is_node_changed(conf, base + [ifname, cli_option]):
+ geneve.update({'rebuild_required': {}})
+
+ return geneve
+
+def verify(geneve):
+ if 'deleted' in geneve:
+ verify_bridge_delete(geneve)
+ return None
+
+ verify_mtu_ipv6(geneve)
+ verify_address(geneve)
+ verify_vrf(geneve)
+ verify_bond_bridge_member(geneve)
+ verify_mirror_redirect(geneve)
+
+ if 'remote' not in geneve:
+ raise ConfigError('Remote side must be configured')
+
+ if 'vni' not in geneve:
+ raise ConfigError('VNI must be configured')
+
+ return None
+
+
+def generate(geneve):
+ return None
+
+def apply(geneve):
+ # Check if GENEVE interface already exists
+ if 'rebuild_required' in geneve or 'delete' in geneve:
+ if interface_exists(geneve['ifname']):
+ g = GeneveIf(**geneve)
+ # GENEVE is super picky and the tunnel always needs to be recreated,
+ # thus we can simply always delete it first.
+ g.remove()
+
+ if 'deleted' not in geneve:
+ # Finally create the new interface
+ g = GeneveIf(**geneve)
+ g.update(geneve)
+
+ return None
+
+
+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/interfaces_input.py b/src/conf_mode/interfaces_input.py
new file mode 100644
index 0000000..ad24884
--- /dev/null
+++ b/src/conf_mode/interfaces_input.py
@@ -0,0 +1,70 @@
+#!/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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configverify import verify_mirror_redirect
+from vyos.ifconfig import InputIf
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at
+ least the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'input']
+ _, ifb = get_interface_dict(conf, base)
+
+ return ifb
+
+def verify(ifb):
+ if 'deleted' in ifb:
+ return None
+
+ verify_mirror_redirect(ifb)
+ return None
+
+def generate(ifb):
+ return None
+
+def apply(ifb):
+ d = InputIf(ifb['ifname'])
+
+ # Remove input interface
+ if 'deleted' in ifb:
+ d.remove()
+ else:
+ d.update(ifb)
+
+ return None
+
+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/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py
new file mode 100644
index 0000000..f0a7043
--- /dev/null
+++ b/src/conf_mode/interfaces_l2tpv3.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import leaf_node_changed
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_bond_bridge_member
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import L2TPv3If
+from vyos.utils.kernel import check_kmod
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6']
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'l2tpv3']
+ ifname, l2tpv3 = get_interface_dict(conf, base)
+
+ # To delete an l2tpv3 interface we need the current tunnel and session-id
+ if 'deleted' in l2tpv3:
+ tmp = leaf_node_changed(conf, base + [ifname, 'tunnel-id'])
+ # leaf_node_changed() returns a list
+ l2tpv3.update({'tunnel_id': tmp[0]})
+
+ tmp = leaf_node_changed(conf, base + [ifname, 'session-id'])
+ l2tpv3.update({'session_id': tmp[0]})
+
+ return l2tpv3
+
+def verify(l2tpv3):
+ if 'deleted' in l2tpv3:
+ verify_bridge_delete(l2tpv3)
+ return None
+
+ interface = l2tpv3['ifname']
+
+ for key in ['source_address', 'remote', 'tunnel_id', 'peer_tunnel_id',
+ 'session_id', 'peer_session_id']:
+ if key not in l2tpv3:
+ tmp = key.replace('_', '-')
+ raise ConfigError(f'Missing mandatory L2TPv3 option: "{tmp}"!')
+
+ if not is_addr_assigned(l2tpv3['source_address']):
+ raise ConfigError('L2TPv3 source-address address "{source_address}" '
+ 'not configured on any interface!'.format(**l2tpv3))
+
+ verify_mtu_ipv6(l2tpv3)
+ verify_address(l2tpv3)
+ verify_vrf(l2tpv3)
+ verify_bond_bridge_member(l2tpv3)
+ verify_mirror_redirect(l2tpv3)
+ return None
+
+def generate(l2tpv3):
+ return None
+
+def apply(l2tpv3):
+ check_kmod(k_mod)
+
+ # Check if L2TPv3 interface already exists
+ if interface_exists(l2tpv3['ifname']):
+ # L2TPv3 is picky when changing tunnels/sessions, thus we can simply
+ # always delete it first.
+ l = L2TPv3If(**l2tpv3)
+ l.remove()
+
+ if 'deleted' not in l2tpv3:
+ # Finally create the new interface
+ l = L2TPv3If(**l2tpv3)
+ l.update(l2tpv3)
+
+ return None
+
+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/interfaces_loopback.py b/src/conf_mode/interfaces_loopback.py
new file mode 100644
index 0000000..a784e9e
--- /dev/null
+++ b/src/conf_mode/interfaces_loopback.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configverify import verify_mirror_redirect
+from vyos.ifconfig import LoopbackIf
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'loopback']
+ _, loopback = get_interface_dict(conf, base)
+ return loopback
+
+def verify(loopback):
+ verify_mirror_redirect(loopback)
+ return None
+
+def generate(loopback):
+ return None
+
+def apply(loopback):
+ l = LoopbackIf(**loopback)
+ if 'deleted' in loopback:
+ l.remove()
+ else:
+ l.update(loopback)
+
+ return None
+
+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/interfaces_macsec.py b/src/conf_mode/interfaces_macsec.py
new file mode 100644
index 0000000..3ede437
--- /dev/null
+++ b/src/conf_mode/interfaces_macsec.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configdict import is_source_interface
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_bond_bridge_member
+from vyos.ifconfig import MACsecIf
+from vyos.ifconfig import Interface
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.utils.network import interface_exists
+from vyos.utils.process import is_systemd_service_running
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+# XXX: wpa_supplicant works on the source interface
+wpa_suppl_conf = '/run/wpa_supplicant/{source_interface}.conf'
+
+# Constants
+## gcm-aes-128 requires a 128bit long key - 32 characters (string) = 16byte = 128bit
+GCM_AES_128_LEN: int = 32
+GCM_128_KEY_ERROR = 'gcm-aes-128 requires a 128bit long key!'
+## gcm-aes-256 requires a 256bit long key - 64 characters (string) = 32byte = 256bit
+GCM_AES_256_LEN: int = 64
+GCM_256_KEY_ERROR = 'gcm-aes-256 requires a 256bit long key!'
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'macsec']
+ ifname, macsec = get_interface_dict(conf, base)
+
+ # Check if interface has been removed
+ if 'deleted' in macsec:
+ source_interface = conf.return_effective_value(base + [ifname, 'source-interface'])
+ macsec.update({'source_interface': source_interface})
+
+ if is_node_changed(conf, base + [ifname, 'security']):
+ macsec.update({'shutdown_required': {}})
+
+ if is_node_changed(conf, base + [ifname, 'source_interface']):
+ macsec.update({'shutdown_required': {}})
+
+ if 'source_interface' in macsec:
+ tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet'])
+ if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp})
+
+ return macsec
+
+
+def verify(macsec):
+ if 'deleted' in macsec:
+ verify_bridge_delete(macsec)
+ return None
+
+ verify_source_interface(macsec)
+ verify_vrf(macsec)
+ verify_mtu_ipv6(macsec)
+ verify_address(macsec)
+ verify_bond_bridge_member(macsec)
+ verify_mirror_redirect(macsec)
+
+ if dict_search('security.cipher', macsec) == None:
+ raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec))
+
+ if dict_search('security.encrypt', macsec) != None:
+ # Check that only static or MKA config is present
+ if dict_search('security.static', macsec) != None and (dict_search('security.mka.cak', macsec) != None or dict_search('security.mka.ckn', macsec) != None):
+ raise ConfigError('Only static or MKA can be used!')
+
+ # Logic to check static configuration
+ if dict_search('security.static', macsec) != None:
+ # key must be defined
+ if dict_search('security.static.key', macsec) == None:
+ raise ConfigError('Static MACsec key must be defined.')
+
+ tx_len = len(dict_search('security.static.key', macsec))
+
+ if dict_search('security.cipher', macsec) == 'gcm-aes-128' and tx_len != GCM_AES_128_LEN:
+ raise ConfigError(GCM_128_KEY_ERROR)
+
+ if dict_search('security.cipher', macsec) == 'gcm-aes-256' and tx_len != GCM_AES_256_LEN:
+ raise ConfigError(GCM_256_KEY_ERROR)
+
+ # Make sure at least one peer is defined
+ if 'peer' not in macsec['security']['static']:
+ raise ConfigError('Must have at least one peer defined for static MACsec')
+
+ # For every enabled peer, make sure a MAC and key is defined
+ for peer, peer_config in macsec['security']['static']['peer'].items():
+ if 'disable' not in peer_config and ('mac' not in peer_config or 'key' not in peer_config):
+ raise ConfigError('Every enabled MACsec static peer must have a MAC address and key defined!')
+
+ # check key length against cipher suite
+ rx_len = len(peer_config['key'])
+
+ if dict_search('security.cipher', macsec) == 'gcm-aes-128' and rx_len != GCM_AES_128_LEN:
+ raise ConfigError(GCM_128_KEY_ERROR)
+
+ if dict_search('security.cipher', macsec) == 'gcm-aes-256' and rx_len != GCM_AES_256_LEN:
+ raise ConfigError(GCM_256_KEY_ERROR)
+
+ # Logic to check MKA configuration
+ else:
+ if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None:
+ raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!')
+
+ cak_len = len(dict_search('security.mka.cak', macsec))
+
+ if dict_search('security.cipher', macsec) == 'gcm-aes-128' and cak_len != GCM_AES_128_LEN:
+ raise ConfigError(GCM_128_KEY_ERROR)
+
+ elif dict_search('security.cipher', macsec) == 'gcm-aes-256' and cak_len != GCM_AES_256_LEN:
+ raise ConfigError(GCM_256_KEY_ERROR)
+
+ if 'source_interface' in macsec:
+ # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad
+ # and 802.1q) - we need to check the underlaying MTU if our configured
+ # MTU is at least 40 bytes less then the MTU of our physical interface.
+ lower_mtu = Interface(macsec['source_interface']).get_mtu()
+ if lower_mtu < (int(macsec['mtu']) + 40):
+ raise ConfigError('MACsec overhead does not fit into underlaying device MTU,\n' \
+ f'{lower_mtu} bytes is too small!')
+
+ return None
+
+
+def generate(macsec):
+ # Only generate wpa_supplicant config if using MKA
+ if dict_search('security.mka.cak', macsec):
+ render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec)
+ return None
+
+
+def apply(macsec):
+ systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec)
+
+ # Remove macsec interface on deletion or mandatory parameter change
+ if 'deleted' in macsec or 'shutdown_required' in macsec:
+ call(f'systemctl stop {systemd_service}')
+
+ if interface_exists(macsec['ifname']):
+ tmp = MACsecIf(**macsec)
+ tmp.remove()
+
+ if 'deleted' in macsec:
+ # delete configuration on interface removal
+ if os.path.isfile(wpa_suppl_conf.format(**macsec)):
+ os.unlink(wpa_suppl_conf.format(**macsec))
+
+ return None
+
+ # It is safe to "re-create" the interface always, there is a sanity
+ # check that the interface will only be create if its non existent
+ i = MACsecIf(**macsec)
+ i.update(macsec)
+
+ # Only reload/restart if using MKA
+ if dict_search('security.mka.cak', macsec):
+ if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec:
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ return None
+
+
+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/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py
new file mode 100644
index 0000000..8c1213e
--- /dev/null
+++ b/src/conf_mode/interfaces_openvpn.py
@@ -0,0 +1,808 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 re
+
+from cryptography.hazmat.primitives.asymmetric import ec
+from glob import glob
+from sys import exit
+from ipaddress import IPv4Address
+from ipaddress import IPv4Network
+from ipaddress import IPv6Address
+from ipaddress import IPv6Network
+from ipaddress import summarize_address_range
+from secrets import SystemRandom
+from shutil import rmtree
+
+from vyos.base import DeprecationWarning
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_bond_bridge_member
+from vyos.ifconfig import VTunIf
+from vyos.pki import load_dh_parameters
+from vyos.pki import load_private_key
+from vyos.pki import sort_ca_chain
+from vyos.pki import verify_ca_chain
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_crl
+from vyos.pki import wrap_dh_parameters
+from vyos.pki import wrap_openvpn_key
+from vyos.pki import wrap_private_key
+from vyos.template import render
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.list import is_list_equal
+from vyos.utils.file import makedir
+from vyos.utils.file import read_file
+from vyos.utils.file import write_file
+from vyos.utils.kernel import check_kmod
+from vyos.utils.kernel import unload_kmod
+from vyos.utils.process import call
+from vyos.utils.permission import chown
+from vyos.utils.process import cmd
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.network import interface_exists
+
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+user = 'openvpn'
+group = 'openvpn'
+
+cfg_dir = '/run/openvpn'
+cfg_file = '/run/openvpn/{ifname}.conf'
+otp_path = '/config/auth/openvpn'
+otp_file = '/config/auth/openvpn/{ifname}-otp-secrets'
+secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')
+service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf'
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'openvpn']
+
+ ifname, openvpn = get_interface_dict(conf, base, with_pki=True)
+ openvpn['auth_user_pass_file'] = '/run/openvpn/{ifname}.pw'.format(**openvpn)
+
+ if 'deleted' in openvpn:
+ return openvpn
+
+ if is_node_changed(conf, base + [ifname, 'openvpn-option']):
+ openvpn.update({'restart_required': {}})
+ if is_node_changed(conf, base + [ifname, 'enable-dco']):
+ openvpn.update({'restart_required': {}})
+
+ # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict'
+ # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there.
+ tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True)
+
+ # We have to cleanup the config dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: server mfa totp
+ # originate comes with defaults, which will enable the
+ # totp plugin, even when not set via CLI so we
+ # need to check this first and drop those keys
+ if dict_search('server.mfa.totp', tmp) == None:
+ del openvpn['server']['mfa']
+
+ # OpenVPN Data-Channel-Offload (DCO) is a Kernel module. If loaded it applies to all
+ # OpenVPN interfaces. Check if DCO is used by any other interface instance.
+ tmp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+ for interface, interface_config in tmp.items():
+ # If one interface has DCO configured, enable it. No need to further check
+ # all other OpenVPN interfaces. We must use a dedicated key to indicate
+ # the Kernel module must be loaded or not. The per interface "offload.dco"
+ # key is required per OpenVPN interface instance.
+ if dict_search('offload.dco', interface_config) != None:
+ openvpn['module_load_dco'] = {}
+ break
+
+ # Calculate the protocol modifier. This is concatenated to the protocol string to direct
+ # OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which
+ # type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces
+ # binding the socket in IPv6 mode, which can also receive IPv4 traffic (when using the
+ # default "ipv6" mode, we specify "bind ipv6only" to disable kernel dual-stack behaviors).
+ if openvpn['ip_version'] == 'ipv4':
+ openvpn['protocol_modifier'] = '4'
+ elif openvpn['ip_version'] in ['ipv6', 'dual-stack']:
+ openvpn['protocol_modifier'] = '6'
+ else:
+ openvpn['protocol_modifier'] = ''
+
+ return openvpn
+
+def is_ec_private_key(pki, cert_name):
+ if not pki or 'certificate' not in pki:
+ return False
+ if cert_name not in pki['certificate']:
+ return False
+
+ pki_cert = pki['certificate'][cert_name]
+ if 'private' not in pki_cert or 'key' not in pki_cert['private']:
+ return False
+
+ key = load_private_key(pki_cert['private']['key'])
+ return isinstance(key, ec.EllipticCurvePrivateKey)
+
+def verify_pki(openvpn):
+ pki = openvpn['pki']
+ interface = openvpn['ifname']
+ mode = openvpn['mode']
+ shared_secret_key = dict_search_args(openvpn, 'shared_secret_key')
+ tls = dict_search_args(openvpn, 'tls')
+
+ if not bool(shared_secret_key) ^ bool(tls): # xor check if only one is set
+ raise ConfigError('Must specify only one of "shared-secret-key" and "tls"')
+
+ if mode in ['server', 'client'] and not tls:
+ raise ConfigError('Must specify "tls" for server and client modes')
+
+ if not pki:
+ raise ConfigError('PKI is not configured')
+
+ if shared_secret_key:
+ if not dict_search_args(pki, 'openvpn', 'shared_secret'):
+ raise ConfigError('There are no openvpn shared-secrets in PKI configuration')
+
+ if shared_secret_key not in pki['openvpn']['shared_secret']:
+ raise ConfigError(f'Invalid shared-secret on openvpn interface {interface}')
+
+ # If PSK settings are correct, warn about its deprecation
+ DeprecationWarning('OpenVPN shared-secret support will be removed in future '\
+ 'VyOS versions. Please migrate your site-to-site tunnels to '\
+ 'TLS. You can use self-signed certificates with peer fingerprint '\
+ 'verification, consult the documentation for details.')
+
+ if tls:
+ if mode == 'site-to-site':
+ # XXX: site-to-site with PSKs is the only mode that can work without TLS,
+ # so 'tls role' is not mandatory for it,
+ # but we need to check that if it uses peer certificate fingerprints rather than PSKs,
+ # then the TLS role is set
+ if ('shared_secret_key' not in tls) and ('role' not in tls):
+ raise ConfigError('"tls role" is required for site-to-site OpenVPN with TLS')
+
+ if (mode in ['server', 'client']) and ('ca_certificate' not in tls):
+ raise ConfigError(f'Must specify "tls ca-certificate" on openvpn interface {interface},\
+ it is required in server and client modes')
+ else:
+ if ('ca_certificate' not in tls) and ('peer_fingerprint' not in tls):
+ raise ConfigError('Either "tls ca-certificate" or "tls peer-fingerprint" is required\
+ on openvpn interface {interface} in site-to-site mode')
+
+ if 'ca_certificate' in tls:
+ for ca_name in tls['ca_certificate']:
+ if ca_name not in pki['ca']:
+ raise ConfigError(f'Invalid CA certificate on openvpn interface {interface}')
+
+ if len(tls['ca_certificate']) > 1:
+ sorted_chain = sort_ca_chain(tls['ca_certificate'], pki['ca'])
+ if not verify_ca_chain(sorted_chain, pki['ca']):
+ raise ConfigError(f'CA certificates are not a valid chain')
+
+ if mode != 'client' and 'auth_key' not in tls:
+ if 'certificate' not in tls:
+ raise ConfigError(f'Missing "tls certificate" on openvpn interface {interface}')
+
+ if 'certificate' in tls:
+ if tls['certificate'] not in pki['certificate']:
+ raise ConfigError(f'Invalid certificate on openvpn interface {interface}')
+
+ if dict_search_args(pki, 'certificate', tls['certificate'], 'private', 'password_protected') is not None:
+ raise ConfigError(f'Cannot use encrypted private key on openvpn interface {interface}')
+
+ if 'dh_params' in tls:
+ if 'dh' not in pki:
+ raise ConfigError(f'pki dh is not configured')
+ proposed_dh = tls['dh_params']
+ if proposed_dh not in pki['dh'].keys():
+ raise ConfigError(f"pki dh '{proposed_dh}' is not configured")
+
+ pki_dh = pki['dh'][tls['dh_params']]
+ dh_params = load_dh_parameters(pki_dh['parameters'])
+ dh_numbers = dh_params.parameter_numbers()
+ dh_bits = dh_numbers.p.bit_length()
+
+ if dh_bits < 2048:
+ raise ConfigError(f'Minimum DH key-size is 2048 bits')
+
+
+ if 'auth_key' in tls or 'crypt_key' in tls:
+ if not dict_search_args(pki, 'openvpn', 'shared_secret'):
+ raise ConfigError('There are no openvpn shared-secrets in PKI configuration')
+
+ if 'auth_key' in tls:
+ if tls['auth_key'] not in pki['openvpn']['shared_secret']:
+ raise ConfigError(f'Invalid auth-key on openvpn interface {interface}')
+
+ if 'crypt_key' in tls:
+ if tls['crypt_key'] not in pki['openvpn']['shared_secret']:
+ raise ConfigError(f'Invalid crypt-key on openvpn interface {interface}')
+
+def verify(openvpn):
+ if 'deleted' in openvpn:
+ verify_bridge_delete(openvpn)
+ return None
+
+ if 'mode' not in openvpn:
+ raise ConfigError('Must specify OpenVPN operation mode!')
+
+ #
+ # OpenVPN client mode - VERIFY
+ #
+ if openvpn['mode'] == 'client':
+ if 'local_port' in openvpn:
+ raise ConfigError('Cannot specify "local-port" in client mode')
+
+ if 'local_host' in openvpn:
+ raise ConfigError('Cannot specify "local-host" in client mode')
+
+ if 'remote_host' not in openvpn:
+ raise ConfigError('Must specify "remote-host" in client mode')
+
+ if openvpn['protocol'] == 'tcp-passive':
+ raise ConfigError('Protocol "tcp-passive" is not valid in client mode')
+
+ if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack':
+ raise ConfigError('"ip-version dual-stack" is not supported in client mode')
+
+ if dict_search('tls.dh_params', openvpn):
+ raise ConfigError('Cannot specify "tls dh-params" in client mode')
+
+ #
+ # OpenVPN site-to-site - VERIFY
+ #
+ elif openvpn['mode'] == 'site-to-site':
+ if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack':
+ raise ConfigError('"ip-version dual-stack" is not supported in site-to-site mode')
+
+ if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn:
+ raise ConfigError('Must specify "local-address" or add interface to bridge')
+
+ if 'local_address' in openvpn:
+ if len([addr for addr in openvpn['local_address'] if is_ipv4(addr)]) > 1:
+ raise ConfigError('Only one IPv4 local-address can be specified')
+
+ if len([addr for addr in openvpn['local_address'] if is_ipv6(addr)]) > 1:
+ raise ConfigError('Only one IPv6 local-address can be specified')
+
+ if openvpn['device_type'] == 'tun':
+ if 'remote_address' not in openvpn:
+ raise ConfigError('Must specify "remote-address"')
+
+ if 'remote_address' in openvpn:
+ if len([addr for addr in openvpn['remote_address'] if is_ipv4(addr)]) > 1:
+ raise ConfigError('Only one IPv4 remote-address can be specified')
+
+ if len([addr for addr in openvpn['remote_address'] if is_ipv6(addr)]) > 1:
+ raise ConfigError('Only one IPv6 remote-address can be specified')
+
+ if not 'local_address' in openvpn:
+ raise ConfigError('"remote-address" requires "local-address"')
+
+ v4loAddr = [addr for addr in openvpn['local_address'] if is_ipv4(addr)]
+ v4remAddr = [addr for addr in openvpn['remote_address'] if is_ipv4(addr)]
+ if v4loAddr and not v4remAddr:
+ raise ConfigError('IPv4 "local-address" requires IPv4 "remote-address"')
+ elif v4remAddr and not v4loAddr:
+ raise ConfigError('IPv4 "remote-address" requires IPv4 "local-address"')
+
+ v6remAddr = [addr for addr in openvpn['remote_address'] if is_ipv6(addr)]
+ v6loAddr = [addr for addr in openvpn['local_address'] if is_ipv6(addr)]
+ if v6loAddr and not v6remAddr:
+ raise ConfigError('IPv6 "local-address" requires IPv6 "remote-address"')
+ elif v6remAddr and not v6loAddr:
+ raise ConfigError('IPv6 "remote-address" requires IPv6 "local-address"')
+
+ if is_list_equal(v4loAddr, v4remAddr) or is_list_equal(v6loAddr, v6remAddr):
+ raise ConfigError('"local-address" and "remote-address" cannot be the same')
+
+ if dict_search('local_host', openvpn) in dict_search('local_address', openvpn):
+ raise ConfigError('"local-address" cannot be the same as "local-host"')
+
+ if dict_search('remote_host', openvpn) in dict_search('remote_address', openvpn):
+ raise ConfigError('"remote-address" and "remote-host" can not be the same')
+
+ if openvpn['device_type'] == 'tap' and 'local_address' in openvpn:
+ # we can only have one local_address, this is ensured above
+ v4addr = None
+ for laddr in openvpn['local_address']:
+ if is_ipv4(laddr):
+ v4addr = laddr
+ break
+
+ if v4addr in openvpn['local_address'] and 'subnet_mask' not in openvpn['local_address'][v4addr]:
+ raise ConfigError('Must specify IPv4 "subnet-mask" for local-address')
+
+ if dict_search('encryption.data_ciphers', openvpn):
+ raise ConfigError('Cipher negotiation can only be used in client or server mode')
+
+ else:
+ # checks for client-server or site-to-site bridged
+ if 'local_address' in openvpn or 'remote_address' in openvpn:
+ raise ConfigError('Cannot specify "local-address" or "remote-address" ' \
+ 'in client/server or bridge mode')
+
+ #
+ # OpenVPN server mode - VERIFY
+ #
+ if openvpn['mode'] == 'server':
+ if openvpn['protocol'] == 'tcp-active':
+ raise ConfigError('Protocol "tcp-active" is not valid in server mode')
+
+ if dict_search('authentication.username', openvpn) or dict_search('authentication.password', openvpn):
+ raise ConfigError('Cannot specify "authentication" in server mode')
+
+ if 'remote_port' in openvpn:
+ raise ConfigError('Cannot specify "remote-port" in server mode')
+
+ if 'remote_host' in openvpn:
+ raise ConfigError('Cannot specify "remote-host" in server mode')
+
+ tmp = dict_search('server.subnet', openvpn)
+ if tmp:
+ v4_subnets = len([subnet for subnet in tmp if is_ipv4(subnet)])
+ v6_subnets = len([subnet for subnet in tmp if is_ipv6(subnet)])
+ if v4_subnets > 1:
+ raise ConfigError('Cannot specify more than 1 IPv4 server subnet')
+ if v6_subnets > 1:
+ raise ConfigError('Cannot specify more than 1 IPv6 server subnet')
+
+ for subnet in tmp:
+ if is_ipv4(subnet):
+ subnet = IPv4Network(subnet)
+
+ if openvpn['device_type'] == 'tun' and subnet.prefixlen > 29:
+ raise ConfigError('Server subnets smaller than /29 with device type "tun" are not supported')
+ elif openvpn['device_type'] == 'tap' and subnet.prefixlen > 30:
+ raise ConfigError('Server subnets smaller than /30 with device type "tap" are not supported')
+
+ for client in (dict_search('client', openvpn) or []):
+ if client['ip'] and not IPv4Address(client['ip'][0]) in subnet:
+ raise ConfigError(f'Client "{client["name"]}" IP {client["ip"][0]} not in server subnet {subnet}')
+
+ else:
+ if 'is_bridge_member' not in openvpn:
+ raise ConfigError('Must specify "server subnet" or add interface to bridge in server mode')
+
+ if hasattr(dict_search('server.client', openvpn), '__iter__'):
+ for client_k, client_v in dict_search('server.client', openvpn).items():
+ if (client_v.get('ip') and len(client_v['ip']) > 1) or (client_v.get('ipv6_ip') and len(client_v['ipv6_ip']) > 1):
+ raise ConfigError(f'Server client "{client_k}": cannot specify more than 1 IPv4 and 1 IPv6 IP')
+
+ if dict_search('server.bridge', openvpn):
+ # check if server bridge is a tap interfaces
+ if not openvpn['device_type'] == 'tap' and dict_search('server.bridge', openvpn):
+ raise ConfigError('Must specify "device-type tap" with server bridge mode')
+ elif not (dict_search('server.bridge.start', openvpn) and dict_search('server.bridge.stop', openvpn)):
+ raise ConfigError('Server bridge requires both start and stop addresses')
+ else:
+ v4PoolStart = IPv4Address(dict_search('server.bridge.start', openvpn))
+ v4PoolStop = IPv4Address(dict_search('server.bridge.stop', openvpn))
+ if v4PoolStart > v4PoolStop:
+ raise ConfigError(f'Server bridge start address {v4PoolStart} is larger than stop address {v4PoolStop}')
+
+ v4PoolSize = int(v4PoolStop) - int(v4PoolStart)
+ if v4PoolSize >= 65536:
+ raise ConfigError(f'Server bridge is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.')
+
+ if dict_search('server.client_ip_pool', openvpn):
+ if not (dict_search('server.client_ip_pool.start', openvpn) and dict_search('server.client_ip_pool.stop', openvpn)):
+ raise ConfigError('Server client-ip-pool requires both start and stop addresses')
+ else:
+ v4PoolStart = IPv4Address(dict_search('server.client_ip_pool.start', openvpn))
+ v4PoolStop = IPv4Address(dict_search('server.client_ip_pool.stop', openvpn))
+ if v4PoolStart > v4PoolStop:
+ raise ConfigError(f'Server client-ip-pool start address {v4PoolStart} is larger than stop address {v4PoolStop}')
+
+ v4PoolSize = int(v4PoolStop) - int(v4PoolStart)
+ if v4PoolSize >= 65536:
+ raise ConfigError(f'Server client-ip-pool is too large [{v4PoolStart} -> {v4PoolStop} = {v4PoolSize}], maximum is 65536 addresses.')
+
+ v4PoolNets = list(summarize_address_range(v4PoolStart, v4PoolStop))
+ for client in (dict_search('client', openvpn) or []):
+ if client['ip']:
+ for v4PoolNet in v4PoolNets:
+ if IPv4Address(client['ip'][0]) in v4PoolNet:
+ print(f'Warning: Client "{client["name"]}" IP {client["ip"][0]} is in server IP pool, it is not reserved for this client.')
+ # configuring a client_ip_pool will set 'server ... nopool' which is currently incompatible with 'server-ipv6' (probably to be fixed upstream)
+ for subnet in (dict_search('server.subnet', openvpn) or []):
+ if is_ipv6(subnet):
+ raise ConfigError(f'Setting client-ip-pool is incompatible having an IPv6 server subnet.')
+
+ for subnet in (dict_search('server.subnet', openvpn) or []):
+ if is_ipv6(subnet):
+ tmp = dict_search('client_ipv6_pool.base', openvpn)
+ if tmp:
+ if not dict_search('server.client_ip_pool', openvpn):
+ raise ConfigError('IPv6 server pool requires an IPv4 server pool')
+
+ if int(tmp.split('/')[1]) >= 112:
+ raise ConfigError('IPv6 server pool must be larger than /112')
+
+ #
+ # todo - weird logic
+ #
+ v6PoolStart = IPv6Address(tmp)
+ v6PoolStop = IPv6Network((v6PoolStart, openvpn['server_ipv6_pool_prefixlen']), strict=False)[-1] # don't remove the parentheses, it's a 2-tuple
+ v6PoolSize = int(v6PoolStop) - int(v6PoolStart) if int(openvpn['server_ipv6_pool_prefixlen']) > 96 else 65536
+ if v6PoolSize < v4PoolSize:
+ raise ConfigError(f'IPv6 server pool must be at least as large as the IPv4 pool (current sizes: IPv6={v6PoolSize} IPv4={v4PoolSize})')
+
+ v6PoolNets = list(summarize_address_range(v6PoolStart, v6PoolStop))
+ for client in (dict_search('client', openvpn) or []):
+ if client['ipv6_ip']:
+ for v6PoolNet in v6PoolNets:
+ if IPv6Address(client['ipv6_ip'][0]) in v6PoolNet:
+ print(f'Warning: Client "{client["name"]}" IP {client["ipv6_ip"][0]} is in server IP pool, it is not reserved for this client.')
+
+ if 'topology' in openvpn['server']:
+ if openvpn['server']['topology'] == 'net30':
+ DeprecationWarning('Topology net30 is deprecated '\
+ 'and will be removed in future VyOS versions. '\
+ 'Switch to "subnet" or "p2p"'
+ )
+
+ # add mfa users to the file the mfa plugin uses
+ if dict_search('server.mfa.totp', openvpn):
+ user_data = ''
+ if not os.path.isfile(otp_file.format(**openvpn)):
+ write_file(otp_file.format(**openvpn), user_data,
+ user=user, group=group, mode=0o644)
+
+ ovpn_users = read_file(otp_file.format(**openvpn))
+ for client in (dict_search('server.client', openvpn) or []):
+ exists = None
+ for ovpn_user in ovpn_users.split('\n'):
+ if re.search('^' + client + ' ', ovpn_user):
+ user_data += f'{ovpn_user}\n'
+ exists = 'true'
+
+ if not exists:
+ random = SystemRandom()
+ totp_secret = ''.join(random.choice(secret_chars) for _ in range(16))
+ user_data += f'{client} otp totp:sha1:base32:{totp_secret}::xxx *\n'
+
+ write_file(otp_file.format(**openvpn), user_data,
+ user=user, group=group, mode=0o644)
+
+ else:
+ # checks for both client and site-to-site go here
+ if dict_search('server.reject_unconfigured_clients', openvpn):
+ raise ConfigError('Option reject-unconfigured-clients only supported in server mode')
+
+ if 'replace_default_route' in openvpn and 'remote_host' not in openvpn:
+ raise ConfigError('Cannot set "replace-default-route" without "remote-host"')
+
+ #
+ # OpenVPN common verification section
+ # not depending on any operation mode
+ #
+
+ # verify that local_host/remote_host match with any ip_version override
+ # specified (if a dns name is specified for remote_host, no attempt is made
+ # to verify that record resolves to an address of the configured family)
+ if 'local_host' in openvpn:
+ if openvpn['ip_version'] == 'ipv4' and is_ipv6(openvpn['local_host']):
+ raise ConfigError('Cannot use an IPv6 "local-host" with "ip-version ipv4"')
+ elif openvpn['ip_version'] == 'ipv6' and is_ipv4(openvpn['local_host']):
+ raise ConfigError('Cannot use an IPv4 "local-host" with "ip-version ipv6"')
+ elif openvpn['ip_version'] == 'dual-stack':
+ raise ConfigError('Cannot use "local-host" with "ip-version dual-stack". "dual-stack" is only supported when OpenVPN binds to all available interfaces.')
+
+ if 'remote_host' in openvpn:
+ remote_hosts = dict_search('remote_host', openvpn)
+ for remote_host in remote_hosts:
+ if openvpn['ip_version'] == 'ipv4' and is_ipv6(remote_host):
+ raise ConfigError('Cannot use an IPv6 "remote-host" with "ip-version ipv4"')
+ elif openvpn['ip_version'] == 'ipv6' and is_ipv4(remote_host):
+ raise ConfigError('Cannot use an IPv4 "remote-host" with "ip-version ipv6"')
+
+ # verify specified IP address is present on any interface on this system
+ if 'local_host' in openvpn:
+ if not is_addr_assigned(openvpn['local_host']):
+ print('local-host IP address "{local_host}" not assigned' \
+ ' to any interface'.format(**openvpn))
+
+ # TCP active
+ if openvpn['protocol'] == 'tcp-active':
+ if 'local_port' in openvpn:
+ raise ConfigError('Cannot specify "local-port" with "tcp-active"')
+
+ if 'remote_host' not in openvpn:
+ raise ConfigError('Must specify "remote-host" with "tcp-active"')
+
+ #
+ # TLS/encryption
+ #
+ if 'shared_secret_key' in openvpn:
+ if dict_search('encryption.cipher', openvpn) in ['aes128gcm', 'aes192gcm', 'aes256gcm']:
+ raise ConfigError('GCM encryption with shared-secret-key not supported')
+
+ if 'tls' in openvpn:
+ if {'auth_key', 'crypt_key'} <= set(openvpn['tls']):
+ raise ConfigError('TLS auth and crypt keys are mutually exclusive')
+
+ tmp = dict_search('tls.role', openvpn)
+ if tmp:
+ if openvpn['mode'] in ['client', 'server']:
+ if not dict_search('tls.auth_key', openvpn):
+ raise ConfigError('Cannot specify "tls role" in client-server mode')
+
+ if tmp == 'active':
+ if openvpn['protocol'] == 'tcp-passive':
+ raise ConfigError('Cannot specify "tcp-passive" when "tls role" is "active"')
+
+ if dict_search('tls.dh_params', openvpn):
+ raise ConfigError('Cannot specify "tls dh-params" when "tls role" is "active"')
+
+ elif tmp == 'passive':
+ if openvpn['protocol'] == 'tcp-active':
+ raise ConfigError('Cannot specify "tcp-active" when "tls role" is "passive"')
+
+ if 'certificate' in openvpn['tls'] and is_ec_private_key(openvpn['pki'], openvpn['tls']['certificate']):
+ if 'dh_params' in openvpn['tls']:
+ print('Warning: using dh-params and EC keys simultaneously will ' \
+ 'lead to DH ciphers being used instead of ECDH')
+
+ if dict_search('encryption.cipher', openvpn):
+ raise ConfigError('"encryption cipher" option is deprecated for TLS mode. '
+ 'Use "encryption data-ciphers" instead')
+
+ if dict_search('encryption.cipher', openvpn) == 'none':
+ print('Warning: "encryption none" was specified!')
+ print('No encryption will be performed and data is transmitted in ' \
+ 'plain text over the network!')
+
+ verify_pki(openvpn)
+
+ #
+ # Auth user/pass
+ #
+ if (dict_search('authentication.username', openvpn) and not
+ dict_search('authentication.password', openvpn)):
+ raise ConfigError('Password for authentication is missing')
+
+ if (dict_search('authentication.password', openvpn) and not
+ dict_search('authentication.username', openvpn)):
+ raise ConfigError('Username for authentication is missing')
+
+ verify_vrf(openvpn)
+ verify_bond_bridge_member(openvpn)
+ verify_mirror_redirect(openvpn)
+
+ return None
+
+def generate_pki_files(openvpn):
+ pki = openvpn['pki']
+ if not pki:
+ return None
+
+ interface = openvpn['ifname']
+ shared_secret_key = dict_search_args(openvpn, 'shared_secret_key')
+ tls = dict_search_args(openvpn, 'tls')
+
+ if shared_secret_key:
+ pki_key = pki['openvpn']['shared_secret'][shared_secret_key]
+ key_path = os.path.join(cfg_dir, f'{interface}_shared.key')
+ write_file(key_path, wrap_openvpn_key(pki_key['key']),
+ user=user, group=group)
+
+ if tls:
+ if 'ca_certificate' in tls:
+ cert_path = os.path.join(cfg_dir, f'{interface}_ca.pem')
+ crl_path = os.path.join(cfg_dir, f'{interface}_crl.pem')
+
+ if os.path.exists(cert_path):
+ os.unlink(cert_path)
+
+ if os.path.exists(crl_path):
+ os.unlink(crl_path)
+
+ for cert_name in sort_ca_chain(tls['ca_certificate'], pki['ca']):
+ pki_ca = pki['ca'][cert_name]
+
+ if 'certificate' in pki_ca:
+ write_file(cert_path, wrap_certificate(pki_ca['certificate']) + "\n",
+ user=user, group=group, mode=0o600, append=True)
+
+ if 'crl' in pki_ca:
+ for crl in pki_ca['crl']:
+ write_file(crl_path, wrap_crl(crl) + "\n", user=user, group=group,
+ mode=0o600, append=True)
+
+ openvpn['tls']['crl'] = True
+
+ if 'certificate' in tls:
+ cert_name = tls['certificate']
+ pki_cert = pki['certificate'][cert_name]
+
+ if 'certificate' in pki_cert:
+ cert_path = os.path.join(cfg_dir, f'{interface}_cert.pem')
+ write_file(cert_path, wrap_certificate(pki_cert['certificate']),
+ user=user, group=group, mode=0o600)
+
+ if 'private' in pki_cert and 'key' in pki_cert['private']:
+ key_path = os.path.join(cfg_dir, f'{interface}_cert.key')
+ write_file(key_path, wrap_private_key(pki_cert['private']['key']),
+ user=user, group=group, mode=0o600)
+
+ openvpn['tls']['private_key'] = True
+
+ if 'dh_params' in tls:
+ dh_name = tls['dh_params']
+ pki_dh = pki['dh'][dh_name]
+
+ if 'parameters' in pki_dh:
+ dh_path = os.path.join(cfg_dir, f'{interface}_dh.pem')
+ write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']),
+ user=user, group=group, mode=0o600)
+
+ if 'auth_key' in tls:
+ key_name = tls['auth_key']
+ pki_key = pki['openvpn']['shared_secret'][key_name]
+
+ if 'key' in pki_key:
+ key_path = os.path.join(cfg_dir, f'{interface}_auth.key')
+ write_file(key_path, wrap_openvpn_key(pki_key['key']),
+ user=user, group=group, mode=0o600)
+
+ if 'crypt_key' in tls:
+ key_name = tls['crypt_key']
+ pki_key = pki['openvpn']['shared_secret'][key_name]
+
+ if 'key' in pki_key:
+ key_path = os.path.join(cfg_dir, f'{interface}_crypt.key')
+ write_file(key_path, wrap_openvpn_key(pki_key['key']),
+ user=user, group=group, mode=0o600)
+
+
+def generate(openvpn):
+ if 'deleted' in openvpn:
+ # remove totp secrets file if totp is not configured
+ if os.path.isfile(otp_file.format(**openvpn)):
+ os.remove(otp_file.format(**openvpn))
+ return None
+
+ if 'disable' in openvpn:
+ return None
+
+ interface = openvpn['ifname']
+ directory = os.path.dirname(cfg_file.format(**openvpn))
+ openvpn['plugin_dir'] = '/usr/lib/openvpn'
+
+ # create base config directory on demand
+ makedir(directory, user, group)
+ # enforce proper permissions on /run/openvpn
+ chown(directory, user, group)
+
+ # we can't know in advance which clients have been removed,
+ # thus all client configs will be removed and re-added on demand
+ ccd_dir = os.path.join(directory, 'ccd', interface)
+ if os.path.isdir(ccd_dir):
+ rmtree(ccd_dir, ignore_errors=True)
+
+ # Remove systemd directories with overrides
+ service_dir = os.path.dirname(service_file.format(**openvpn))
+ if os.path.isdir(service_dir):
+ rmtree(service_dir, ignore_errors=True)
+
+ # create client config directory on demand
+ makedir(ccd_dir, user, group)
+
+ # Fix file permissons for keys
+ generate_pki_files(openvpn)
+
+ # Generate User/Password authentication file
+ if 'authentication' in openvpn:
+ render(openvpn['auth_user_pass_file'], 'openvpn/auth.pw.j2', openvpn,
+ user=user, group=group, permission=0o600)
+ else:
+ # delete old auth file if present
+ if os.path.isfile(openvpn['auth_user_pass_file']):
+ os.remove(openvpn['auth_user_pass_file'])
+
+ # Generate client specific configuration
+ server_client = dict_search_args(openvpn, 'server', 'client')
+ if server_client:
+ for client, client_config in server_client.items():
+ client_file = os.path.join(ccd_dir, client)
+
+ # Our client need's to know its subnet mask ...
+ client_config['server_subnet'] = dict_search('server.subnet', openvpn)
+
+ render(client_file, 'openvpn/client.conf.j2', client_config,
+ user=user, group=group)
+
+ # we need to support quoting of raw parameters from OpenVPN CLI
+ # see https://vyos.dev/T1632
+ render(cfg_file.format(**openvpn), 'openvpn/server.conf.j2', openvpn,
+ formater=lambda _: _.replace("&quot;", '"'), user=user, group=group)
+
+ # Render 20-override.conf for OpenVPN service
+ render(service_file.format(**openvpn), 'openvpn/service-override.conf.j2', openvpn,
+ formater=lambda _: _.replace("&quot;", '"'), user=user, group=group)
+ # Reload systemd services config to apply an override
+ call(f'systemctl daemon-reload')
+
+ return None
+
+def apply(openvpn):
+ interface = openvpn['ifname']
+
+ # Do some cleanup when OpenVPN is disabled/deleted
+ if 'deleted' in openvpn or 'disable' in openvpn:
+ call(f'systemctl stop openvpn@{interface}.service')
+ for cleanup_file in glob(f'/run/openvpn/{interface}.*'):
+ if os.path.isfile(cleanup_file):
+ os.unlink(cleanup_file)
+
+ if interface_exists(interface):
+ VTunIf(interface).remove()
+
+ # dynamically load/unload DCO Kernel extension if requested
+ dco_module = 'ovpn_dco_v2'
+ if 'module_load_dco' in openvpn:
+ check_kmod(dco_module)
+ else:
+ unload_kmod(dco_module)
+
+ # Now bail out early if interface is disabled or got deleted
+ if 'deleted' in openvpn or 'disable' in openvpn:
+ return None
+
+ # verify specified IP address is present on any interface on this system
+ # Allow to bind service to nonlocal address, if it virtaual-vrrp address
+ # or if address will be assign later
+ if 'local_host' in openvpn:
+ if not is_addr_assigned(openvpn['local_host']):
+ cmd('sysctl -w net.ipv4.ip_nonlocal_bind=1')
+
+ # No matching OpenVPN process running - maybe it got killed or none
+ # existed - nevertheless, spawn new OpenVPN process
+ action = 'reload-or-restart'
+ if 'restart_required' in openvpn:
+ action = 'restart'
+ call(f'systemctl {action} openvpn@{interface}.service')
+
+ o = VTunIf(**openvpn)
+ o.update(openvpn)
+
+ return None
+
+
+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/interfaces_pppoe.py b/src/conf_mode/interfaces_pppoe.py
new file mode 100644
index 0000000..412676c
--- /dev/null
+++ b/src/conf_mode/interfaces_pppoe.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_authentication
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.ifconfig import PPPoEIf
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.process import is_systemd_service_running
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'pppoe']
+ ifname, pppoe = get_interface_dict(conf, base)
+
+ # We should only terminate the PPPoE session if critical parameters change.
+ # All parameters that can be changed on-the-fly (like interface description)
+ # should not lead to a reconnect!
+ for options in ['access-concentrator', 'connect-on-demand', 'service-name',
+ 'source-interface', 'vrf', 'no-default-route',
+ 'authentication', 'host_uniq']:
+ if is_node_changed(conf, base + [ifname, options]):
+ pppoe.update({'shutdown_required': {}})
+ # bail out early - no need to further process other nodes
+ break
+
+ if 'deleted' not in pppoe:
+ # We always set the MRU value to the MTU size. This code path only re-creates
+ # the old behavior if MRU is not set on the CLI.
+ if 'mru' not in pppoe:
+ pppoe['mru'] = pppoe['mtu']
+
+ return pppoe
+
+def verify(pppoe):
+ if 'deleted' in pppoe:
+ # bail out early
+ return None
+
+ verify_source_interface(pppoe)
+ verify_authentication(pppoe)
+ verify_vrf(pppoe)
+ verify_mtu_ipv6(pppoe)
+ verify_mirror_redirect(pppoe)
+
+ if {'connect_on_demand', 'vrf'} <= set(pppoe):
+ raise ConfigError('On-demand dialing and VRF can not be used at the same time')
+
+ # both MTU and MRU have default values, thus we do not need to check
+ # if the key exists
+ if int(pppoe['mru']) > int(pppoe['mtu']):
+ raise ConfigError('PPPoE MRU needs to be lower then MTU!')
+
+ return None
+
+def generate(pppoe):
+ # set up configuration file path variables where our templates will be
+ # rendered into
+ ifname = pppoe['ifname']
+ config_pppoe = f'/etc/ppp/peers/{ifname}'
+
+ if 'deleted' in pppoe or 'disable' in pppoe:
+ if os.path.exists(config_pppoe):
+ os.unlink(config_pppoe)
+
+ return None
+
+ # Create PPP configuration files
+ render(config_pppoe, 'pppoe/peer.j2', pppoe, permission=0o640)
+
+ return None
+
+def apply(pppoe):
+ ifname = pppoe['ifname']
+ if 'deleted' in pppoe or 'disable' in pppoe:
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = PPPoEIf(ifname)
+ p.remove()
+ call(f'systemctl stop ppp@{ifname}.service')
+ return None
+
+ # reconnect should only be necessary when certain config options change,
+ # like ACS name, authentication ... (see get_config() for details)
+ if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or
+ 'shutdown_required' in pppoe):
+
+ # cleanup system (e.g. FRR routes first)
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = PPPoEIf(ifname)
+ p.remove()
+
+ call(f'systemctl restart ppp@{ifname}.service')
+ # When interface comes "live" a hook is called:
+ # /etc/ppp/ip-up.d/99-vyos-pppoe-callback
+ # which triggers PPPoEIf.update()
+ else:
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = PPPoEIf(ifname)
+ p.update(pppoe)
+
+ return None
+
+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/interfaces_pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py
new file mode 100644
index 0000000..446beff
--- /dev/null
+++ b/src/conf_mode/interfaces_pseudo-ethernet.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configdict import is_source_interface
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_vlan_config
+from vyos.configverify import verify_mtu_parent
+from vyos.configverify import verify_mirror_redirect
+from vyos.ifconfig import MACVLANIf
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at
+ least the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'pseudo-ethernet']
+ ifname, peth = get_interface_dict(conf, base)
+
+ mode = is_node_changed(conf, ['mode'])
+ if mode: peth.update({'shutdown_required' : {}})
+
+ if is_node_changed(conf, base + [ifname, 'mode']):
+ peth.update({'rebuild_required': {}})
+
+ if 'source_interface' in peth:
+ _, peth['parent'] = get_interface_dict(conf, ['interfaces', 'ethernet'],
+ peth['source_interface'])
+ # test if source-interface is maybe already used by another interface
+ tmp = is_source_interface(conf, peth['source_interface'], ['macsec'])
+ if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp})
+
+ return peth
+
+def verify(peth):
+ if 'deleted' in peth:
+ verify_bridge_delete(peth)
+ return None
+
+ verify_source_interface(peth)
+ verify_vrf(peth)
+ verify_address(peth)
+ verify_mtu_parent(peth, peth['parent'])
+ verify_mirror_redirect(peth)
+ # use common function to verify VLAN configuration
+ verify_vlan_config(peth)
+
+ return None
+
+def generate(peth):
+ return None
+
+def apply(peth):
+ # Check if the MACVLAN interface already exists
+ if 'rebuild_required' in peth or 'deleted' in peth:
+ if interface_exists(peth['ifname']):
+ p = MACVLANIf(**peth)
+ # MACVLAN is always needs to be recreated,
+ # thus we can simply always delete it first.
+ p.remove()
+
+ if 'deleted' not in peth:
+ p = MACVLANIf(**peth)
+ p.update(peth)
+
+ return None
+
+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/interfaces_sstpc.py b/src/conf_mode/interfaces_sstpc.py
new file mode 100644
index 0000000..b9d7a74
--- /dev/null
+++ b/src/conf_mode/interfaces_sstpc.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_authentication
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import SSTPCIf
+from vyos.pki import encode_certificate
+from vyos.pki import find_chain
+from vyos.pki import load_certificate
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'sstpc']
+ ifname, sstpc = get_interface_dict(conf, base, with_pki=True)
+
+ # We should only terminate the SSTP client session if critical parameters
+ # change. All parameters that can be changed on-the-fly (like interface
+ # description) should not lead to a reconnect!
+ for options in ['authentication', 'no_peer_dns', 'no_default_route',
+ 'server', 'ssl']:
+ if is_node_changed(conf, base + [ifname, options]):
+ sstpc.update({'shutdown_required': {}})
+ # bail out early - no need to further process other nodes
+ break
+
+ return sstpc
+
+def verify(sstpc):
+ if 'deleted' in sstpc:
+ return None
+
+ verify_authentication(sstpc)
+ verify_vrf(sstpc)
+
+ if not dict_search('server', sstpc):
+ raise ConfigError('Remote SSTP server must be specified!')
+
+ if not dict_search('ssl.ca_certificate', sstpc):
+ raise ConfigError('Missing mandatory CA certificate!')
+
+ return None
+
+def generate(sstpc):
+ ifname = sstpc['ifname']
+ config_sstpc = f'/etc/ppp/peers/{ifname}'
+
+ sstpc['ca_file_path'] = f'/run/sstpc/{ifname}_ca-cert.pem'
+
+ if 'deleted' in sstpc:
+ for file in [sstpc['ca_file_path'], config_sstpc]:
+ if os.path.exists(file):
+ os.unlink(file)
+ return None
+
+ ca_name = sstpc['ssl']['ca_certificate']
+ pki_ca_cert = sstpc['pki']['ca'][ca_name]
+
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in sstpc['pki']['ca'].values()} if 'ca' in sstpc['pki'] else {}
+
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+
+ write_file(sstpc['ca_file_path'], '\n'.join(encode_certificate(c) for c in ca_full_chain))
+ render(config_sstpc, 'sstp-client/peer.j2', sstpc, permission=0o640)
+
+ return None
+
+def apply(sstpc):
+ ifname = sstpc['ifname']
+ if 'deleted' in sstpc or 'disable' in sstpc:
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = SSTPCIf(ifname)
+ p.remove()
+ call(f'systemctl stop ppp@{ifname}.service')
+ return None
+
+ # reconnect should only be necessary when specific options change,
+ # like server, authentication ... (see get_config() for details)
+ if ((not is_systemd_service_running(f'ppp@{ifname}.service')) or
+ 'shutdown_required' in sstpc):
+
+ # cleanup system (e.g. FRR routes first)
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = SSTPCIf(ifname)
+ p.remove()
+
+ call(f'systemctl restart ppp@{ifname}.service')
+ # When interface comes "live" a hook is called:
+ # /etc/ppp/ip-up.d/96-vyos-sstpc-callback
+ # which triggers SSTPCIf.update()
+ else:
+ if os.path.isdir(f'/sys/class/net/{ifname}'):
+ p = SSTPCIf(ifname)
+ p.update(sstpc)
+
+ return None
+
+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/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py
new file mode 100644
index 0000000..98ef98d
--- /dev/null
+++ b/src/conf_mode/interfaces_tunnel.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2024 yOS 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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_tunnel
+from vyos.configverify import verify_bond_bridge_member
+from vyos.ifconfig import Interface
+from vyos.ifconfig import TunnelIf
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least
+ the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'tunnel']
+ ifname, tunnel = get_interface_dict(conf, base)
+
+ if 'deleted' not in tunnel:
+ tmp = is_node_changed(conf, base + [ifname, 'encapsulation'])
+ if tmp: tunnel.update({'encapsulation_changed': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'parameters', 'ip', 'key'])
+ if tmp: tunnel.update({'key_changed': {}})
+
+ # We also need to inspect other configured tunnels as there are Kernel
+ # restrictions where we need to comply. E.g. GRE tunnel key can't be used
+ # twice, or with multiple GRE tunnels to the same location we must specify
+ # a GRE key
+ conf.set_level(base)
+ tunnel['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ # delete our own instance from this dict
+ ifname = tunnel['ifname']
+ del tunnel['other_tunnels'][ifname]
+ # if only one tunnel is present on the system, no need to keep this key
+ if len(tunnel['other_tunnels']) == 0:
+ del tunnel['other_tunnels']
+
+ # We must check if our interface is configured to be a DMVPN member
+ nhrp_base = ['protocols', 'nhrp', 'tunnel']
+ conf.set_level(nhrp_base)
+ nhrp = conf.get_config_dict([], key_mangling=('-', '_'), get_first_key=True)
+ if nhrp: tunnel.update({'nhrp' : list(nhrp.keys())})
+
+ if 'encapsulation' in tunnel and tunnel['encapsulation'] not in ['erspan', 'ip6erspan']:
+ del tunnel['parameters']['erspan']
+
+ return tunnel
+
+def verify(tunnel):
+ if 'deleted' in tunnel:
+ verify_bridge_delete(tunnel)
+
+ if 'nhrp' in tunnel and tunnel['ifname'] in tunnel['nhrp']:
+ raise ConfigError('Tunnel used for NHRP, it can not be deleted!')
+
+ return None
+
+ verify_tunnel(tunnel)
+
+ if tunnel['encapsulation'] in ['erspan', 'ip6erspan']:
+ if dict_search('parameters.ip.key', tunnel) == None:
+ raise ConfigError('ERSPAN requires ip key parameter!')
+
+ # this is a default field
+ ver = int(tunnel['parameters']['erspan']['version'])
+ if ver == 1:
+ if 'hw_id' in tunnel['parameters']['erspan']:
+ raise ConfigError('ERSPAN version 1 does not support hw-id!')
+ if 'direction' in tunnel['parameters']['erspan']:
+ raise ConfigError('ERSPAN version 1 does not support direction!')
+ elif ver == 2:
+ if 'idx' in tunnel['parameters']['erspan']:
+ raise ConfigError('ERSPAN version 2 does not index parameter!')
+ if 'direction' not in tunnel['parameters']['erspan']:
+ raise ConfigError('ERSPAN version 2 requires direction to be set!')
+
+ # If tunnel source is any and gre key is not set
+ interface = tunnel['ifname']
+ if tunnel['encapsulation'] in ['gre'] and \
+ dict_search('source_address', tunnel) == '0.0.0.0' and \
+ dict_search('parameters.ip.key', tunnel) == None:
+ raise ConfigError(f'"parameters ip key" must be set for {interface} when '\
+ 'encapsulation is GRE!')
+
+ gre_encapsulations = ['gre', 'gretap']
+ if tunnel['encapsulation'] in gre_encapsulations and 'other_tunnels' in tunnel:
+ # Check pairs tunnel source-address/encapsulation/key with exists tunnels.
+ # Prevent the same key for 2 tunnels with same source-address/encap. T2920
+ for o_tunnel, o_tunnel_conf in tunnel['other_tunnels'].items():
+ # no match on encapsulation - bail out
+ our_encapsulation = tunnel['encapsulation']
+ their_encapsulation = o_tunnel_conf['encapsulation']
+ if our_encapsulation in gre_encapsulations and their_encapsulation \
+ not in gre_encapsulations:
+ continue
+
+ our_address = dict_search('source_address', tunnel)
+ our_key = dict_search('parameters.ip.key', tunnel)
+ their_address = dict_search('source_address', o_tunnel_conf)
+ their_key = dict_search('parameters.ip.key', o_tunnel_conf)
+ if our_key != None:
+ if their_address == our_address and their_key == our_key:
+ raise ConfigError(f'Key "{our_key}" for source-address "{our_address}" ' \
+ f'is already used for tunnel "{o_tunnel}"!')
+ else:
+ our_source_if = dict_search('source_interface', tunnel)
+ their_source_if = dict_search('source_interface', o_tunnel_conf)
+ our_remote = dict_search('remote', tunnel)
+ their_remote = dict_search('remote', o_tunnel_conf)
+ # If no IP GRE key is defined we can not have more then one GRE tunnel
+ # bound to any one interface/IP address and the same remote. This will
+ # result in a OS PermissionError: add tunnel "gre0" failed: File exists
+ if our_remote == their_remote:
+ if our_address is not None and their_address == our_address:
+ # If set to the same values, this is always a fail
+ raise ConfigError(f'Missing required "ip key" parameter when '\
+ 'running more then one GRE based tunnel on the '\
+ 'same source-address')
+
+ if their_source_if == our_source_if and their_address == our_address:
+ # Note that lack of None check on these is deliberate.
+ # source-if and source-ip matching while unset (all None) is a fail
+ # source-ifs set and matching with unset source-ips is a fail
+ raise ConfigError(f'Missing required "ip key" parameter when '\
+ 'running more then one GRE based tunnel on the '\
+ 'same source-interface')
+
+ # Keys are not allowed with ipip and sit tunnels
+ if tunnel['encapsulation'] in ['ipip', 'sit']:
+ if dict_search('parameters.ip.key', tunnel) != None:
+ raise ConfigError('Keys are not allowed with ipip and sit tunnels!')
+
+ verify_mtu_ipv6(tunnel)
+ verify_address(tunnel)
+ verify_vrf(tunnel)
+ verify_bond_bridge_member(tunnel)
+ verify_mirror_redirect(tunnel)
+
+ if 'source_interface' in tunnel:
+ verify_source_interface(tunnel)
+
+ # TTL != 0 and nopmtudisc are incompatible, parameters and ip use default
+ # values, thus the keys are always present.
+ if dict_search('parameters.ip.no_pmtu_discovery', tunnel) != None:
+ if dict_search('parameters.ip.ttl', tunnel) != '0':
+ raise ConfigError('Disabled PMTU requires TTL set to "0"!')
+ if tunnel['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']:
+ raise ConfigError('Can not disable PMTU discovery for given encapsulation')
+
+ if dict_search('parameters.ip.ignore_df', tunnel) != None:
+ if tunnel['encapsulation'] not in ['gretap']:
+ raise ConfigError('Option ignore-df can only be used on GRETAP tunnels!')
+
+ if dict_search('parameters.ip.no_pmtu_discovery', tunnel) == None:
+ raise ConfigError('Option ignore-df requires path MTU discovery to be disabled!')
+
+
+def generate(tunnel):
+ return None
+
+def apply(tunnel):
+ interface = tunnel['ifname']
+ # If a gretap tunnel is already existing we can not "simply" change local or
+ # remote addresses. This returns "Operation not supported" by the Kernel.
+ # There is no other solution to destroy and recreate the tunnel.
+ encap = ''
+ remote = ''
+ tmp = get_interface_config(interface)
+ if tmp:
+ encap = dict_search('linkinfo.info_kind', tmp)
+ remote = dict_search('linkinfo.info_data.remote', tmp)
+
+ if ('deleted' in tunnel or 'encapsulation_changed' in tunnel or encap in
+ ['gretap', 'ip6gretap', 'erspan', 'ip6erspan'] or remote in ['any'] or
+ 'key_changed' in tunnel):
+ if interface_exists(interface):
+ tmp = Interface(interface)
+ tmp.remove()
+ if 'deleted' in tunnel:
+ return None
+
+ tun = TunnelIf(**tunnel)
+ tun.update(tunnel)
+
+ return None
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ generate(c)
+ verify(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/interfaces_virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py
new file mode 100644
index 0000000..cb6104f
--- /dev/null
+++ b/src/conf_mode/interfaces_virtual-ethernet.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+
+from sys import exit
+
+from vyos import ConfigError
+from vyos import airbag
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import VethIf
+from vyos.utils.network import interface_exists
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at
+ least the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'virtual-ethernet']
+ ifname, veth = get_interface_dict(conf, base)
+
+ # We need to know all other veth related interfaces as veth requires a 1:1
+ # mapping for the peer-names. The Linux kernel automatically creates both
+ # interfaces, the local one and the peer-name, but VyOS also needs a peer
+ # interfaces configrued on the CLI so we can assign proper IP addresses etc.
+ veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ return veth
+
+
+def verify(veth):
+ if 'deleted' in veth:
+ verify_bridge_delete(veth)
+ # Prevent to delete veth interface which used for another "vethX peer-name"
+ for iface, iface_config in veth['other_interfaces'].items():
+ if veth['ifname'] in iface_config['peer_name']:
+ ifname = veth['ifname']
+ raise ConfigError(
+ f'Cannot delete "{ifname}" used for "interface {iface} peer-name"'
+ )
+ return None
+
+ verify_vrf(veth)
+ verify_address(veth)
+
+ if 'peer_name' not in veth:
+ raise ConfigError(f'Remote peer name must be set for "{veth["ifname"]}"!')
+
+ peer_name = veth['peer_name']
+ ifname = veth['ifname']
+
+ if veth['peer_name'] not in veth['other_interfaces']:
+ raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \
+ 'is not configured!')
+
+ if veth['other_interfaces'][peer_name]['peer_name'] != ifname:
+ raise ConfigError(
+ f'Configuration mismatch between "{ifname}" and "{peer_name}"!')
+
+ if peer_name == ifname:
+ raise ConfigError(
+ f'Peer-name "{peer_name}" cannot be the same as interface "{ifname}"!')
+
+ return None
+
+
+def generate(peth):
+ return None
+
+def apply(veth):
+ # Check if the Veth interface already exists
+ if 'rebuild_required' in veth or 'deleted' in veth:
+ if interface_exists(veth['ifname']):
+ p = VethIf(**veth)
+ p.remove()
+
+ if 'deleted' not in veth:
+ p = VethIf(**veth)
+ p.update(veth)
+
+ return None
+
+
+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/interfaces_vti.py b/src/conf_mode/interfaces_vti.py
new file mode 100644
index 0000000..20629c6
--- /dev/null
+++ b/src/conf_mode/interfaces_vti.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import VTIIf
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'vti']
+ _, vti = get_interface_dict(conf, base)
+ return vti
+
+def verify(vti):
+ verify_vrf(vti)
+ verify_mirror_redirect(vti)
+ return None
+
+def generate(vti):
+ return None
+
+def apply(vti):
+ # Remove macsec interface
+ if 'deleted' in vti:
+ VTIIf(**vti).remove()
+ return None
+
+ tmp = VTIIf(**vti)
+ tmp.update(vti)
+
+ return None
+
+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/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py
new file mode 100644
index 0000000..68646e8
--- /dev/null
+++ b/src/conf_mode/interfaces_vxlan.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import leaf_node_changed
+from vyos.configdict import is_node_changed
+from vyos.configdict import node_changed
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_bond_bridge_member
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import Interface
+from vyos.ifconfig import VXLANIf
+from vyos.template import is_ipv6
+from vyos.utils.dict import dict_search
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least
+ the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'vxlan']
+ ifname, vxlan = get_interface_dict(conf, base)
+
+ # VXLAN interfaces are picky and require recreation if certain parameters
+ # change. But a VXLAN interface should - of course - not be re-created if
+ # it's description or IP address is adjusted. Feels somehow logic doesn't it?
+ for cli_option in ['parameters', 'gpe', 'group', 'port', 'remote',
+ 'source-address', 'source-interface', 'vni']:
+ if is_node_changed(conf, base + [ifname, cli_option]):
+ vxlan.update({'rebuild_required': {}})
+ break
+
+ # When dealing with VNI filtering we need to know what VNI was actually removed,
+ # so build up a dict matching the vlan_to_vni structure but with removed values.
+ tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True)
+ if tmp:
+ vxlan.update({'vlan_to_vni_removed': {}})
+ for vlan in tmp:
+ vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni'])
+ vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}})
+
+ # We need to verify that no other VXLAN tunnel is configured when external
+ # mode is in use - Linux Kernel limitation
+ conf.set_level(base)
+ vxlan['other_tunnels'] = conf.get_config_dict([], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # This if-clause is just to be sure - it will always evaluate to true
+ ifname = vxlan['ifname']
+ if ifname in vxlan['other_tunnels']:
+ del vxlan['other_tunnels'][ifname]
+ if len(vxlan['other_tunnels']) == 0:
+ del vxlan['other_tunnels']
+
+ return vxlan
+
+def verify(vxlan):
+ if 'deleted' in vxlan:
+ verify_bridge_delete(vxlan)
+ return None
+
+ if int(vxlan['mtu']) < 1500:
+ Warning('RFC7348 recommends VXLAN tunnels preserve a 1500 byte MTU')
+
+ if 'group' in vxlan:
+ if 'source_interface' not in vxlan:
+ raise ConfigError('Multicast VXLAN requires an underlaying interface')
+ verify_source_interface(vxlan)
+
+ if not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan):
+ raise ConfigError('Group, remote, source-address or source-interface must be configured')
+
+ if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None:
+ raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!')
+
+ if dict_search('parameters.external', vxlan) != None:
+ if 'vni' in vxlan:
+ raise ConfigError('Can not specify both "external" and "VNI"!')
+
+ if 'other_tunnels' in vxlan:
+ # When multiple VXLAN interfaces are defined and "external" is used,
+ # all VXLAN interfaces need to have vni-filter enabled!
+ # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9
+ other_vni_filter = False
+ for tunnel, tunnel_config in vxlan['other_tunnels'].items():
+ if dict_search('parameters.vni_filter', tunnel_config) != None:
+ other_vni_filter = True
+ break
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False
+ # If either one is enabled, so must be the other. Both can be off and both can be on
+ if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter):
+ raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\
+ 'requires all VXLAN interfaces to have "vni-filter" configured!')
+
+ if not vni_filter and not other_vni_filter:
+ other_tunnels = ', '.join(vxlan['other_tunnels'])
+ raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\
+ f'CLI option is used and "vni-filter" is unset. '\
+ f'Additional tunnels: {other_tunnels}')
+
+ if 'gpe' in vxlan and 'external' not in vxlan:
+ raise ConfigError(f'VXLAN-GPE is only supported when "external" '\
+ f'CLI option is used.')
+
+ if 'source_interface' in vxlan:
+ # VXLAN adds at least an overhead of 50 byte - we need to check the
+ # underlaying device if our VXLAN package is not going to be fragmented!
+ vxlan_overhead = 50
+ if 'source_address' in vxlan and is_ipv6(vxlan['source_address']):
+ # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20
+ # bytes larger than the IPv4 header - assuming no extra options are
+ # in use.
+ vxlan_overhead += 20
+
+ # If source_address is not used - check IPv6 'remote' list
+ elif 'remote' in vxlan:
+ if any(is_ipv6(a) for a in vxlan['remote']):
+ vxlan_overhead += 20
+
+ lower_mtu = Interface(vxlan['source_interface']).get_mtu()
+ if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead):
+ raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\
+ f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)')
+
+ # Check for mixed IPv4 and IPv6 addresses
+ protocol = None
+ if 'source_address' in vxlan:
+ if is_ipv6(vxlan['source_address']):
+ protocol = 'ipv6'
+ else:
+ protocol = 'ipv4'
+
+ if 'remote' in vxlan:
+ error_msg = 'Can not mix both IPv4 and IPv6 for VXLAN underlay'
+ for remote in vxlan['remote']:
+ if is_ipv6(remote):
+ if protocol == 'ipv4':
+ raise ConfigError(error_msg)
+ protocol = 'ipv6'
+ else:
+ if protocol == 'ipv6':
+ raise ConfigError(error_msg)
+ protocol = 'ipv4'
+
+ if 'vlan_to_vni' in vxlan:
+ if 'is_bridge_member' not in vxlan:
+ raise ConfigError('VLAN to VNI mapping requires that VXLAN interface '\
+ 'is member of a bridge interface!')
+
+ vnis_used = []
+ vlans_used = []
+ for vif, vif_config in vxlan['vlan_to_vni'].items():
+ if 'vni' not in vif_config:
+ raise ConfigError(f'Must define VNI for VLAN "{vif}"!')
+ vni = vif_config['vni']
+
+ err_msg = f'VLAN range "{vif}" does not match VNI range "{vni}"!'
+ vif_range, vni_range = list(map(int, vif.split('-'))), list(map(int, vni.split('-')))
+
+ if len(vif_range) != len(vni_range):
+ raise ConfigError(err_msg)
+
+ if len(vif_range) > 1:
+ if vni_range[0] > vni_range[-1] or vif_range[0] > vif_range[-1]:
+ raise ConfigError('The upper bound of the range must be greater than the lower bound!')
+ vni_range = range(vni_range[0], vni_range[1] + 1)
+ vif_range = range(vif_range[0], vif_range[1] + 1)
+
+ if len(vif_range) != len(vni_range):
+ raise ConfigError(err_msg)
+
+ for vni_id in vni_range:
+ if vni_id in vnis_used:
+ raise ConfigError(f'VNI "{vni_id}" is already assigned to a different VLAN!')
+ vnis_used.append(vni_id)
+
+ for vif_id in vif_range:
+ if vif_id in vlans_used:
+ raise ConfigError(f'VLAN "{vif_id}" is already in use!')
+ vlans_used.append(vif_id)
+
+ if dict_search('parameters.neighbor_suppress', vxlan) != None:
+ if 'is_bridge_member' not in vxlan:
+ raise ConfigError('Neighbor suppression requires that VXLAN interface '\
+ 'is member of a bridge interface!')
+
+ verify_mtu_ipv6(vxlan)
+ verify_address(vxlan)
+ verify_vrf(vxlan)
+ verify_bond_bridge_member(vxlan)
+ verify_mirror_redirect(vxlan)
+
+ # We use a defaultValue for port, thus it's always safe to use
+ if vxlan['port'] == '8472':
+ Warning('Starting from VyOS 1.4, the default port for VXLAN '\
+ 'has been changed to 4789. This matches the IANA assigned '\
+ 'standard port number!')
+
+ return None
+
+def generate(vxlan):
+ return None
+
+def apply(vxlan):
+ # Check if the VXLAN interface already exists
+ if 'rebuild_required' in vxlan or 'delete' in vxlan:
+ if interface_exists(vxlan['ifname']):
+ v = VXLANIf(**vxlan)
+ # VXLAN is super picky and the tunnel always needs to be recreated,
+ # thus we can simply always delete it first.
+ v.remove()
+
+ if 'deleted' not in vxlan:
+ # Finally create the new interface
+ v = VXLANIf(**vxlan)
+ v.update(vxlan)
+
+ return None
+
+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/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py
new file mode 100644
index 0000000..7abdfdb
--- /dev/null
+++ b/src/conf_mode/interfaces_wireguard.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mtu_ipv6
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_bond_bridge_member
+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 import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'wireguard']
+ ifname, wireguard = get_interface_dict(conf, base)
+
+ # Check if a port was changed
+ tmp = is_node_changed(conf, base + [ifname, 'port'])
+ if tmp: wireguard['port_changed'] = {}
+
+ # T4702: If anything on a peer changes we remove the peer first and re-add it
+ if is_node_changed(conf, base + [ifname, 'peer']):
+ wireguard.update({'rebuild_required': {}})
+
+ return wireguard
+
+def verify(wireguard):
+ if 'deleted' in wireguard:
+ verify_bridge_delete(wireguard)
+ return None
+
+ verify_mtu_ipv6(wireguard)
+ verify_address(wireguard)
+ verify_vrf(wireguard)
+ verify_bond_bridge_member(wireguard)
+ verify_mirror_redirect(wireguard)
+
+ if 'private_key' not in wireguard:
+ raise ConfigError('Wireguard private-key not defined')
+
+ if 'peer' not in wireguard:
+ raise ConfigError('At least one Wireguard peer is required!')
+
+ if 'port' in wireguard and 'port_changed' in wireguard:
+ listen_port = int(wireguard['port'])
+ if check_port_availability('0.0.0.0', listen_port, 'udp') is not True:
+ raise ConfigError(f'UDP port {listen_port} is busy or unavailable and '
+ 'cannot be used for the interface!')
+
+ # run checks on individual configured WireGuard peer
+ public_keys = []
+ for tmp in wireguard['peer']:
+ peer = wireguard['peer'][tmp]
+
+ if 'allowed_ips' not in peer:
+ raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!')
+
+ 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!')
+
+ if peer['public_key'] in public_keys:
+ raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"')
+
+ 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"]}"')
+
+ public_keys.append(peer['public_key'])
+
+def generate(wireguard):
+ return None
+
+def apply(wireguard):
+ check_kmod('wireguard')
+
+ if 'rebuild_required' in wireguard or 'deleted' in wireguard:
+ wg = WireGuardIf(**wireguard)
+ # WireGuard only supports peer removal based on the configured public-key,
+ # by deleting the entire interface this is the shortcut instead of parsing
+ # out all peers and removing them one by one.
+ #
+ # Peer reconfiguration will always come with a short downtime while the
+ # WireGuard interface is recreated (see below)
+ wg.remove()
+
+ # Create the new interface if required
+ if 'deleted' not in wireguard:
+ wg = WireGuardIf(**wireguard)
+ wg.update(wireguard)
+
+ return None
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py
new file mode 100644
index 0000000..d24675e
--- /dev/null
+++ b/src/conf_mode/interfaces_wireless.py
@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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
+
+from sys import exit
+from re import findall
+from netaddr import EUI, mac_unix_expanded
+from time import sleep
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_address
+from vyos.configverify import verify_bridge_delete
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_vlan_config
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_bond_bridge_member
+from vyos.ifconfig import WiFiIf
+from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.kernel import check_kmod
+from vyos.utils.process import call
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+# XXX: wpa_supplicant works on the source interface
+wpa_suppl_conf = '/run/wpa_supplicant/{ifname}.conf'
+hostapd_conf = '/run/hostapd/{ifname}.conf'
+hostapd_accept_station_conf = '/run/hostapd/{ifname}_station_accept.conf'
+hostapd_deny_station_conf = '/run/hostapd/{ifname}_station_deny.conf'
+
+country_code_path = ['system', 'wireless', 'country-code']
+
+def find_other_stations(conf, base, ifname):
+ """
+ Only one wireless interface per phy can be in station mode -
+ find all interfaces attached to a phy which run in station mode
+ """
+ old_level = conf.get_level()
+ conf.set_level(base)
+ dict = {}
+ for phy in os.listdir('/sys/class/ieee80211'):
+ list = []
+ for interface in conf.list_nodes([]):
+ if interface == ifname:
+ continue
+ # the following node is mandatory
+ if conf.exists([interface, 'physical-device', phy]):
+ tmp = conf.return_value([interface, 'type'])
+ if tmp == 'station':
+ list.append(interface)
+ if list:
+ dict.update({phy: list})
+ conf.set_level(old_level)
+ return dict
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'wireless']
+
+ _, wifi = get_interface_dict(conf, base)
+
+ # retrieve global Wireless regulatory domain setting
+ if conf.exists(country_code_path):
+ wifi['country_code'] = conf.return_value(country_code_path)
+
+ if 'deleted' not in wifi:
+ # then get_interface_dict provides default keys
+ if wifi.from_defaults(['security', 'wep']): # if not set by user
+ del wifi['security']['wep']
+ if wifi.from_defaults(['security', 'wpa']): # if not set by user
+ del wifi['security']['wpa']
+
+ # XXX: Jinja2 can not operate on a dictionary key when it starts of with a number
+ if '40mhz_incapable' in (dict_search('capabilities.ht', wifi) or []):
+ wifi['capabilities']['ht']['fourtymhz_incapable'] = wifi['capabilities']['ht']['40mhz_incapable']
+ del wifi['capabilities']['ht']['40mhz_incapable']
+
+ if dict_search('security.wpa', wifi) != None:
+ wpa_cipher = wifi['security']['wpa'].get('cipher')
+ wpa_mode = wifi['security']['wpa'].get('mode')
+ if not wpa_cipher:
+ tmp = None
+ if wpa_mode == 'wpa':
+ tmp = {'security': {'wpa': {'cipher' : ['TKIP', 'CCMP']}}}
+ elif wpa_mode == 'wpa2':
+ tmp = {'security': {'wpa': {'cipher' : ['CCMP']}}}
+ elif wpa_mode == 'both':
+ tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'TKIP']}}}
+ elif wpa_mode == 'wpa3':
+ # According to WiFi specs (https://www.wi-fi.org/file/wpa3-specification)
+ # section 3.5: WPA3-Enterprise 192-bit mode
+ # WiFi NICs which would be able to connect to WPA3-Enterprise managed
+ # networks MUST support GCMP-256.
+ # Reasoning: Provided that chipsets would most likely _not_ be
+ # "private user only", they all would come with built-in support
+ # for GCMP-256.
+ tmp = {'security': {'wpa': {'cipher' : ['CCMP', 'CCMP-256', 'GCMP', 'GCMP-256']}}}
+
+ if tmp: wifi = dict_merge(tmp, wifi)
+
+ # Only one wireless interface per phy can be in station mode
+ tmp = find_other_stations(conf, base, wifi['ifname'])
+ if tmp: wifi['station_interfaces'] = tmp
+
+ # used in hostapd.conf.j2
+ wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi)
+ wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi)
+
+ return wifi
+
+def verify(wifi):
+ if 'deleted' in wifi:
+ verify_bridge_delete(wifi)
+ return None
+
+ if 'physical_device' not in wifi:
+ raise ConfigError('You must specify a physical-device "phy"')
+
+ physical_device = wifi['physical_device']
+ if not os.path.exists(f'/sys/class/ieee80211/{physical_device}'):
+ raise ConfigError(f'Wirelss interface PHY "{physical_device}" does not exist!')
+
+ if 'type' not in wifi:
+ raise ConfigError('You must specify a WiFi mode')
+
+ if 'ssid' not in wifi and wifi['type'] != 'monitor':
+ raise ConfigError('SSID must be configured unless type is set to "monitor"!')
+
+ if wifi['type'] == 'access-point':
+ if 'country_code' not in wifi:
+ raise ConfigError(f'Wireless country-code is mandatory, use: '\
+ f'"set {" ".join(country_code_path)}"!')
+
+ if 'channel' not in wifi:
+ raise ConfigError('Wireless channel must be configured!')
+
+ if 'capabilities' in wifi and 'he' in wifi['capabilities']:
+ if 'channel_set_width' not in wifi['capabilities']['he']:
+ raise ConfigError('Channel width must be configured!')
+
+ # op_modes drawn from:
+ # https://w1.fi/cgit/hostap/tree/src/common/ieee802_11_common.c?id=195cc3d919503fb0d699d9a56a58a72602b25f51#n1525
+ # 802.11ax (WiFi-6e - HE) can use up to 160MHz bandwidth channels
+ six_ghz_op_modes_he = ['131', '132', '133', '134', '135']
+ # 802.11be (WiFi-7 - EHT) can use up to 320MHz bandwidth channels
+ six_ghz_op_modes_eht = six_ghz_op_modes_he.append('137')
+ if 'security' in wifi and 'wpa' in wifi['security'] and 'mode' in wifi['security']['wpa']:
+ if wifi['security']['wpa']['mode'] == 'wpa3':
+ if 'he' in wifi['capabilities']:
+ if wifi['capabilities']['he']['channel_set_width'] in six_ghz_op_modes_he:
+ if 'mgmt_frame_protection' not in wifi or wifi['mgmt_frame_protection'] != 'required':
+ raise ConfigError('Management Frame Protection (MFP) is required with WPA3 at 6GHz! Consider also enabling Beacon Frame Protection (BFP) if your device supports it.')
+
+ if 'security' in wifi:
+ if {'wep', 'wpa'} <= set(wifi.get('security', {})):
+ raise ConfigError('Must either use WEP or WPA security!')
+
+ if 'wep' in wifi['security']:
+ if 'key' in wifi['security']['wep'] and len(wifi['security']['wep']) > 4:
+ raise ConfigError('No more then 4 WEP keys configurable')
+ elif 'key' not in wifi['security']['wep']:
+ raise ConfigError('Security WEP configured - missing WEP keys!')
+
+ elif 'wpa' in wifi['security']:
+ wpa = wifi['security']['wpa']
+ if not any(i in ['passphrase', 'radius'] for i in wpa):
+ raise ConfigError('Misssing WPA key or RADIUS server')
+
+ if 'username' in wpa:
+ if 'passphrase' not in wpa:
+ raise ConfigError('WPA-Enterprise configured - missing passphrase!')
+ elif 'passphrase' in wpa:
+ # check if passphrase meets the regex .{8,63}
+ if len(wpa['passphrase']) < 8 or len(wpa['passphrase']) > 63:
+ raise ConfigError('WPA passphrase must be between 8 and 63 characters long')
+ if 'radius' in wpa:
+ if 'server' in wpa['radius']:
+ for server in wpa['radius']['server']:
+ if 'key' not in wpa['radius']['server'][server]:
+ raise ConfigError(f'Missing RADIUS shared secret key for server: {server}')
+
+ if 'capabilities' in wifi:
+ capabilities = wifi['capabilities']
+ if 'vht' in capabilities:
+ if 'ht' not in capabilities:
+ raise ConfigError('Specify HT flags if you want to use VHT!')
+
+ if {'beamform', 'antenna_count'} <= set(capabilities.get('vht', {})):
+ if capabilities['vht']['antenna_count'] == '1':
+ raise ConfigError('Cannot use beam forming with just one antenna!')
+
+ if capabilities['vht']['beamform'] == 'single-user-beamformer':
+ if int(capabilities['vht']['antenna_count']) < 3:
+ # Nasty Gotcha: see lines 708-721 in:
+ # https://w1.fi/cgit/hostap/tree/hostapd/hostapd.conf?h=hostap_2_10&id=cff80b4f7d3c0a47c052e8187d671710f48939e4#n708
+ raise ConfigError('Single-user beam former requires at least 3 antennas!')
+
+ if 'station_interfaces' in wifi and wifi['type'] == 'station':
+ phy = wifi['physical_device']
+ if phy in wifi['station_interfaces']:
+ if len(wifi['station_interfaces'][phy]) > 0:
+ raise ConfigError('Only one station per wireless physical interface possible!')
+
+ verify_address(wifi)
+ verify_vrf(wifi)
+ verify_bond_bridge_member(wifi)
+ verify_mirror_redirect(wifi)
+
+ # use common function to verify VLAN configuration
+ verify_vlan_config(wifi)
+
+ return None
+
+def generate(wifi):
+ check_kmod('mac80211')
+
+ interface = wifi['ifname']
+
+ # Delete config files if interface is removed
+ if 'deleted' in wifi:
+ if os.path.isfile(hostapd_conf.format(**wifi)):
+ os.unlink(hostapd_conf.format(**wifi))
+ if os.path.isfile(hostapd_accept_station_conf.format(**wifi)):
+ os.unlink(hostapd_accept_station_conf.format(**wifi))
+ if os.path.isfile(hostapd_deny_station_conf.format(**wifi)):
+ os.unlink(hostapd_deny_station_conf.format(**wifi))
+ if os.path.isfile(wpa_suppl_conf.format(**wifi)):
+ os.unlink(wpa_suppl_conf.format(**wifi))
+
+ return None
+
+ if 'mac' not in wifi:
+ # http://wiki.stocksy.co.uk/wiki/Multiple_SSIDs_with_hostapd
+ # generate locally administered MAC address from used phy interface
+ with open('/sys/class/ieee80211/{physical_device}/addresses'.format(**wifi), 'r') as f:
+ # some PHYs tend to have multiple interfaces and thus supply multiple MAC
+ # addresses - we only need the first one for our calculation
+ tmp = f.readline().rstrip()
+ tmp = EUI(tmp).value
+ # mask last nibble from the MAC address
+ tmp &= 0xfffffffffff0
+ # set locally administered bit in MAC address
+ tmp |= 0x020000000000
+ # we now need to add an offset to our MAC address indicating this
+ # subinterfaces index
+ tmp += int(findall(r'\d+', interface)[0])
+
+ # convert integer to "real" MAC address representation
+ mac = EUI(hex(tmp).split('x')[-1])
+ # change dialect to use : as delimiter instead of -
+ mac.dialect = mac_unix_expanded
+ wifi['mac'] = str(mac)
+
+ # render appropriate new config files depending on access-point or station mode
+ if wifi['type'] == 'access-point':
+ render(hostapd_conf.format(**wifi), 'wifi/hostapd.conf.j2', wifi)
+ render(hostapd_accept_station_conf.format(**wifi), 'wifi/hostapd_accept_station.conf.j2', wifi)
+ render(hostapd_deny_station_conf.format(**wifi), 'wifi/hostapd_deny_station.conf.j2', wifi)
+
+ elif wifi['type'] == 'station':
+ render(wpa_suppl_conf.format(**wifi), 'wifi/wpa_supplicant.conf.j2', wifi)
+
+ return None
+
+def apply(wifi):
+ interface = wifi['ifname']
+ # From systemd source code:
+ # If there's a stop job queued before we enter the DEAD state, we shouldn't act on Restart=,
+ # in order to not undo what has already been enqueued. */
+ #
+ # It was found that calling restart on hostapd will (4 out of 10 cases) deactivate
+ # the service instead of restarting it, when it was not yet properly stopped
+ # systemd[1]: hostapd@wlan1.service: Deactivated successfully.
+ # Thus kill all WIFI service and start them again after it's ensured nothing lives
+ call(f'systemctl stop hostapd@{interface}.service')
+ call(f'systemctl stop wpa_supplicant@{interface}.service')
+
+ if 'deleted' in wifi:
+ WiFiIf(**wifi).remove()
+ return None
+
+ while (is_systemd_service_running(f'hostapd@{interface}.service') or \
+ is_systemd_service_active(f'hostapd@{interface}.service')):
+ sleep(0.250) # wait 250ms
+
+ # Finally create the new interface
+ w = WiFiIf(**wifi)
+ w.update(wifi)
+
+ # Enable/Disable interface - interface is always placed in
+ # administrative down state in WiFiIf class
+ if 'disable' not in wifi:
+ # Wait until interface was properly added to the Kernel
+ ii = 0
+ while not (interface_exists(interface) and ii < 20):
+ sleep(0.250) # wait 250ms
+ ii += 1
+
+ # Physical interface is now configured. Proceed by starting hostapd or
+ # wpa_supplicant daemon. When type is monitor we can just skip this.
+ if wifi['type'] == 'access-point':
+ call(f'systemctl start hostapd@{interface}.service')
+
+ elif wifi['type'] == 'station':
+ call(f'systemctl start wpa_supplicant@{interface}.service')
+
+ return None
+
+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/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py
new file mode 100644
index 0000000..230eb14
--- /dev/null
+++ b/src/conf_mode/interfaces_wwan.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-2022 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
+
+from sys import exit
+from time import sleep
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_authentication
+from vyos.configverify import verify_interface_exists
+from vyos.configverify import verify_mirror_redirect
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import WWANIf
+from vyos.utils.dict import dict_search
+from vyos.utils.process import cmd
+from vyos.utils.process import call
+from vyos.utils.process import DEVNULL
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+service_name = 'ModemManager.service'
+cron_script = '/etc/cron.d/vyos-wwan'
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the
+ interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'wwan']
+ ifname, wwan = get_interface_dict(conf, base)
+
+ # We should only terminate the WWAN session if critical parameters change.
+ # All parameters that can be changed on-the-fly (like interface description)
+ # should not lead to a reconnect!
+ tmp = is_node_changed(conf, base + [ifname, 'address'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'apn'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'disable'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'vrf'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'authentication'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'ipv6', 'address', 'autoconf'])
+ if tmp: wwan.update({'shutdown_required': {}})
+
+ # We need to know the amount of other WWAN interfaces as ModemManager needs
+ # to be started or stopped.
+ wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # This if-clause is just to be sure - it will always evaluate to true
+ if ifname in wwan['other_interfaces']:
+ del wwan['other_interfaces'][ifname]
+ if len(wwan['other_interfaces']) == 0:
+ del wwan['other_interfaces']
+
+ return wwan
+
+def verify(wwan):
+ if 'deleted' in wwan:
+ return None
+
+ ifname = wwan['ifname']
+ if not 'apn' in wwan:
+ raise ConfigError(f'No APN configured for "{ifname}"!')
+
+ verify_interface_exists(wwan, ifname)
+ verify_authentication(wwan)
+ verify_vrf(wwan)
+ verify_mirror_redirect(wwan)
+
+ return None
+
+def generate(wwan):
+ if 'deleted' in wwan:
+ # We are the last WWAN interface - there are no other ones remaining
+ # thus the cronjob needs to go away, too
+ if 'other_interfaces' not in wwan:
+ if os.path.exists(cron_script):
+ os.unlink(cron_script)
+ return None
+
+ # Install cron triggered helper script to re-dial WWAN interfaces on
+ # disconnect - e.g. happens during RF signal loss. The script watches every
+ # WWAN interface - so there is only one instance.
+ if not os.path.exists(cron_script):
+ write_file(cron_script, '*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py\n')
+
+ return None
+
+def apply(wwan):
+ # ModemManager is required to dial WWAN connections - one instance is
+ # required to serve all modems. Activate ModemManager on first invocation
+ # of any WWAN interface.
+ if not is_systemd_service_active(service_name):
+ cmd(f'systemctl start {service_name}')
+
+ counter = 100
+ # Wait until a modem is detected and then we can continue
+ while counter > 0:
+ counter -= 1
+ tmp = cmd('mmcli -L')
+ if tmp != 'No modems were found':
+ break
+ sleep(0.250)
+
+ if 'shutdown_required' in wwan:
+ # we only need the modem number. wwan0 -> 0, wwan1 -> 1
+ modem = wwan['ifname'].lstrip('wwan')
+ base_cmd = f'mmcli --modem {modem}'
+ # Number of bearers is limited - always disconnect first
+ cmd(f'{base_cmd} --simple-disconnect')
+
+ w = WWANIf(wwan['ifname'])
+ if 'deleted' in wwan or 'disable' in wwan:
+ w.remove()
+
+ # We are the last WWAN interface - there are no other WWAN interfaces
+ # remaining, thus we can stop ModemManager and free resources.
+ if 'other_interfaces' not in wwan:
+ cmd(f'systemctl stop {service_name}')
+ # Clean CRON helper script which is used for to re-connect when
+ # RF signal is lost
+ if os.path.exists(cron_script):
+ os.unlink(cron_script)
+
+ return None
+
+ if 'shutdown_required' in wwan:
+ ip_type = 'ipv4'
+ slaac = dict_search('ipv6.address.autoconf', wwan) != None
+ if 'address' in wwan:
+ if 'dhcp' in wwan['address'] and ('dhcpv6' in wwan['address'] or slaac):
+ ip_type = 'ipv4v6'
+ elif 'dhcpv6' in wwan['address'] or slaac:
+ ip_type = 'ipv6'
+ elif 'dhcp' in wwan['address']:
+ ip_type = 'ipv4'
+
+ options = f'ip-type={ip_type},apn=' + wwan['apn']
+ if 'authentication' in wwan:
+ options += ',user={username},password={password}'.format(**wwan['authentication'])
+
+ command = f'{base_cmd} --simple-connect="{options}"'
+ call(command, stdout=DEVNULL)
+
+ w.update(wwan)
+ return None
+
+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/load-balancing_reverse-proxy.py b/src/conf_mode/load-balancing_reverse-proxy.py
new file mode 100644
index 0000000..17226ef
--- /dev/null
+++ b/src/conf_mode/load-balancing_reverse-proxy.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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
+
+from sys import exit
+from shutil import rmtree
+
+from vyos.config import Config
+from vyos.configverify import verify_pki_certificate
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.utils.dict import dict_search
+from vyos.utils.process import call
+from vyos.utils.network import check_port_availability
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.pki import find_chain
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.template import render
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+load_balancing_dir = '/run/haproxy'
+load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg'
+systemd_service = 'haproxy.service'
+systemd_override = '/run/systemd/system/haproxy.service.d/10-override.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['load-balancing', 'reverse-proxy']
+ if not conf.exists(base):
+ return None
+ lb = conf.get_config_dict(base,
+ get_first_key=True,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True,
+ with_pki=True)
+
+ return lb
+
+def verify(lb):
+ if not lb:
+ return None
+
+ if 'backend' not in lb or 'service' not in lb:
+ raise ConfigError(f'"service" and "backend" must be configured!')
+
+ for front, front_config in lb['service'].items():
+ if 'port' not in front_config:
+ raise ConfigError(f'"{front} service port" must be configured!')
+
+ # Check if bind address:port are used by another service
+ tmp_address = front_config.get('address', '0.0.0.0')
+ tmp_port = front_config['port']
+ if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \
+ not is_listen_port_bind_service(int(tmp_port), 'haproxy'):
+ raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service')
+
+ for back, back_config in lb['backend'].items():
+ if 'http_check' in back_config:
+ http_check = back_config['http_check']
+ if 'expect' in http_check and 'status' in http_check['expect'] and 'string' in http_check['expect']:
+ raise ConfigError(f'"expect status" and "expect string" can not be configured together!')
+
+ if 'health_check' in back_config:
+ if back_config['mode'] != 'tcp':
+ raise ConfigError(f'backend "{back}" can only be configured with {back_config["health_check"]} ' +
+ f'health-check whilst in TCP mode!')
+ if 'http_check' in back_config:
+ raise ConfigError(f'backend "{back}" cannot be configured with both http-check and health-check!')
+
+ if 'server' not in back_config:
+ raise ConfigError(f'"{back} server" must be configured!')
+
+ for bk_server, bk_server_conf in back_config['server'].items():
+ if 'address' not in bk_server_conf or 'port' not in bk_server_conf:
+ raise ConfigError(f'"backend {back} server {bk_server} address and port" must be configured!')
+
+ if {'send_proxy', 'send_proxy_v2'} <= set(bk_server_conf):
+ raise ConfigError(f'Cannot use both "send-proxy" and "send-proxy-v2" for server "{bk_server}"')
+
+ if 'ssl' in back_config:
+ if {'no_verify', 'ca_certificate'} <= set(back_config['ssl']):
+ raise ConfigError(f'backend {back} cannot have both ssl options no-verify and ca-certificate set!')
+
+ # Check if http-response-headers are configured in any frontend/backend where mode != http
+ for group in ['service', 'backend']:
+ for config_name, config in lb[group].items():
+ if 'http_response_headers' in config and config['mode'] != 'http':
+ raise ConfigError(f'{group} {config_name} must be set to http mode to use http_response_headers!')
+
+ for front, front_config in lb['service'].items():
+ for cert in dict_search('ssl.certificate', front_config) or []:
+ verify_pki_certificate(lb, cert)
+
+ for back, back_config in lb['backend'].items():
+ tmp = dict_search('ssl.ca_certificate', back_config)
+ if tmp: verify_pki_ca_certificate(lb, tmp)
+
+
+def generate(lb):
+ if not lb:
+ # Delete /run/haproxy/haproxy.cfg
+ config_files = [load_balancing_conf_file, systemd_override]
+ for file in config_files:
+ if os.path.isfile(file):
+ os.unlink(file)
+ # Delete old directories
+ if os.path.isdir(load_balancing_dir):
+ rmtree(load_balancing_dir, ignore_errors=True)
+
+ return None
+
+ # Create load-balance dir
+ if not os.path.isdir(load_balancing_dir):
+ os.mkdir(load_balancing_dir)
+
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in lb['pki']['ca'].values()} if 'ca' in lb['pki'] else {}
+
+ # SSL Certificates for frontend
+ for front, front_config in lb['service'].items():
+ if 'ssl' not in front_config:
+ continue
+
+ if 'certificate' in front_config['ssl']:
+ cert_names = front_config['ssl']['certificate']
+
+ for cert_name in cert_names:
+ pki_cert = lb['pki']['certificate'][cert_name]
+ cert_file_path = os.path.join(load_balancing_dir, f'{cert_name}.pem')
+ cert_key_path = os.path.join(load_balancing_dir, f'{cert_name}.pem.key')
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+
+ write_file(cert_file_path,
+ '\n'.join(encode_certificate(c) for c in cert_full_chain))
+
+ if 'private' in pki_cert and 'key' in pki_cert['private']:
+ loaded_key = load_private_key(pki_cert['private']['key'], passphrase=None, wrap_tags=True)
+ key_pem = encode_private_key(loaded_key, passphrase=None)
+ write_file(cert_key_path, key_pem)
+
+ # SSL Certificates for backend
+ for back, back_config in lb['backend'].items():
+ if 'ssl' not in back_config:
+ continue
+
+ if 'ca_certificate' in back_config['ssl']:
+ ca_name = back_config['ssl']['ca_certificate']
+ ca_cert_file_path = os.path.join(load_balancing_dir, f'{ca_name}.pem')
+ ca_chains = []
+
+ pki_ca_cert = lb['pki']['ca'][ca_name]
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ ca_chains.append('\n'.join(encode_certificate(c) for c in ca_full_chain))
+ write_file(ca_cert_file_path, '\n'.join(ca_chains))
+
+ render(load_balancing_conf_file, 'load-balancing/haproxy.cfg.j2', lb)
+ render(systemd_override, 'load-balancing/override_haproxy.conf.j2', lb)
+
+ return None
+
+def apply(lb):
+ call('systemctl daemon-reload')
+ if not lb:
+ call(f'systemctl stop {systemd_service}')
+ else:
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ return None
+
+
+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/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py
new file mode 100644
index 0000000..5da0b90
--- /dev/null
+++ b/src/conf_mode/load-balancing_wan.py
@@ -0,0 +1,151 @@
+#!/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 os
+
+from sys import exit
+from shutil import rmtree
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdep import set_dependents, call_dependents
+from vyos.utils.process import cmd
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+load_balancing_dir = '/run/load-balance'
+load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf'
+systemd_service = 'vyos-wan-load-balance.service'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['load-balancing', 'wan']
+ lb = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ # prune limit key if not set by user
+ for rule in lb.get('rule', []):
+ if lb.from_defaults(['rule', rule, 'limit']):
+ del lb['rule'][rule]['limit']
+
+ set_dependents('conntrack', conf)
+
+ return lb
+
+
+def verify(lb):
+ if not lb:
+ return None
+
+ if 'interface_health' not in lb:
+ raise ConfigError(
+ 'A valid WAN load-balance configuration requires an interface with a nexthop!'
+ )
+
+ for interface, interface_config in lb['interface_health'].items():
+ if 'nexthop' not in interface_config:
+ raise ConfigError(
+ f'interface-health {interface} nexthop must be specified!')
+
+ if 'test' in interface_config:
+ for test_rule, test_config in interface_config['test'].items():
+ if 'type' in test_config:
+ if test_config['type'] == 'user-defined' and 'test_script' not in test_config:
+ raise ConfigError(
+ f'test {test_rule} script must be defined for test-script!'
+ )
+
+ if 'rule' not in lb:
+ Warning(
+ 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!'
+ )
+ else:
+ for rule, rule_config in lb['rule'].items():
+ if 'inbound_interface' not in rule_config:
+ raise ConfigError(f'rule {rule} inbound-interface must be specified!')
+ if {'failover', 'exclude'} <= set(rule_config):
+ raise ConfigError(f'rule {rule} failover cannot be configured with exclude!')
+ if {'limit', 'exclude'} <= set(rule_config):
+ raise ConfigError(f'rule {rule} limit cannot be used with exclude!')
+ if 'interface' not in rule_config:
+ if 'exclude' not in rule_config:
+ Warning(
+ f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule'
+ )
+ for direction in {'source', 'destination'}:
+ if direction in rule_config:
+ if 'protocol' in rule_config and 'port' in rule_config[
+ direction]:
+ if rule_config['protocol'] not in {'tcp', 'udp'}:
+ raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"')
+
+
+def generate(lb):
+ if not lb:
+ # Delete /run/load-balance/wlb.conf
+ if os.path.isfile(load_balancing_conf_file):
+ os.unlink(load_balancing_conf_file)
+ # Delete old directories
+ if os.path.isdir(load_balancing_dir):
+ rmtree(load_balancing_dir, ignore_errors=True)
+ if os.path.exists('/var/run/load-balance/wlb.out'):
+ os.unlink('/var/run/load-balance/wlb.out')
+
+ return None
+
+ # Create load-balance dir
+ if not os.path.isdir(load_balancing_dir):
+ os.mkdir(load_balancing_dir)
+
+ render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb)
+
+ return None
+
+
+def apply(lb):
+ if not lb:
+ try:
+ cmd(f'systemctl stop {systemd_service}')
+ except Exception as e:
+ print(f"Error message: {e}")
+
+ else:
+ cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1')
+ cmd(f'systemctl restart {systemd_service}')
+
+ call_dependents()
+
+ return None
+
+
+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/nat.py b/src/conf_mode/nat.py
new file mode 100644
index 0000000..39803fa
--- /dev/null
+++ b/src/conf_mode/nat.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdep import set_dependents, call_dependents
+from vyos.template import render
+from vyos.template import is_ip_network
+from vyos.utils.kernel import check_kmod
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+k_mod = ['nft_nat', 'nft_chain_nat']
+
+nftables_nat_config = '/run/nftables_nat.conf'
+nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft'
+
+valid_groups = [
+ 'address_group',
+ 'domain_group',
+ 'network_group',
+ 'port_group'
+]
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['nat']
+ nat = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ set_dependents('conntrack', conf)
+
+ if not conf.exists(base):
+ nat['deleted'] = ''
+ return nat
+
+ nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Remove dynamic firewall groups if present:
+ if 'dynamic_group' in nat['firewall_group']:
+ del nat['firewall_group']['dynamic_group']
+
+ return nat
+
+def verify_rule(config, err_msg, groups_dict):
+ """ Common verify steps used for both source and destination NAT """
+
+ if (dict_search('translation.port', config) != None or
+ dict_search('translation.redirect.port', config) != None or
+ dict_search('destination.port', config) != None or
+ dict_search('source.port', config)):
+
+ if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+ raise ConfigError(f'{err_msg} ports can only be specified when '\
+ 'protocol is either tcp, udp or tcp_udp!')
+
+ for side in ['destination', 'source']:
+ if side in config:
+ side_conf = config[side]
+
+ if len({'address', 'fqdn'} & set(side_conf)) > 1:
+ raise ConfigError('Only one of address, fqdn or geoip can be specified')
+
+ if 'group' in side_conf:
+ if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1:
+ raise ConfigError('Only one address-group, network-group or domain-group can be specified')
+
+ for group in valid_groups:
+ if group in side_conf['group']:
+ group_name = side_conf['group'][group]
+ error_group = group.replace("_", "-")
+
+ if group in ['address_group', 'network_group', 'domain_group']:
+ types = [t for t in ['address', 'fqdn'] if t in side_conf]
+ if types:
+ raise ConfigError(f'{error_group} and {types[0]} cannot both be defined')
+
+ if group_name and group_name[0] == '!':
+ group_name = group_name[1:]
+
+ group_obj = dict_search_args(groups_dict, group, group_name)
+
+ if group_obj is None:
+ raise ConfigError(f'Invalid {error_group} "{group_name}" on nat rule')
+
+ if not group_obj:
+ Warning(f'{error_group} "{group_name}" has no members!')
+
+ if dict_search_args(side_conf, 'group', 'port_group'):
+ if 'protocol' not in config:
+ raise ConfigError('Protocol must be defined if specifying a port-group')
+
+ if config['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+ raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group')
+
+ if 'load_balance' in config:
+ for item in ['source-port', 'destination-port']:
+ if item in config['load_balance']['hash'] and config['protocol'] not in ['tcp', 'udp']:
+ raise ConfigError('Protocol must be tcp or udp when specifying hash ports')
+ count = 0
+ if 'backend' in config['load_balance']:
+ for member in config['load_balance']['backend']:
+ weight = config['load_balance']['backend'][member]['weight']
+ count = count + int(weight)
+ if count != 100:
+ Warning(f'Sum of weight for nat load balance rule is not 100. You may get unexpected behaviour')
+
+def verify(nat):
+ if not nat or 'deleted' in nat:
+ # no need to verify the CLI as NAT is going to be deactivated
+ return None
+
+ if dict_search('source.rule', nat):
+ for rule, config in dict_search('source.rule', nat).items():
+ err_msg = f'Source NAT configuration error in rule {rule}:'
+
+ if 'outbound_interface' in config:
+ if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']:
+ raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"')
+ elif 'name' in config['outbound_interface']:
+ interface_name = config['outbound_interface']['name']
+ if interface_name not in 'any':
+ if interface_name.startswith('!'):
+ interface_name = interface_name[1:]
+ if not interface_exists(interface_name):
+ Warning(f'Interface "{interface_name}" for source NAT rule "{rule}" does not exist!')
+ else:
+ group_name = config['outbound_interface']['group']
+ if group_name[0] == '!':
+ group_name = group_name[1:]
+ group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name)
+ if group_obj is None:
+ raise ConfigError(f'Invalid interface group "{group_name}" on source nat rule')
+ if not group_obj:
+ Warning(f'interface-group "{group_name}" has no members!')
+
+ if not dict_search('translation.address', config) and not dict_search('translation.port', config):
+ if 'exclude' not in config and 'backend' not in config['load_balance']:
+ raise ConfigError(f'{err_msg} translation requires address and/or port')
+
+ addr = dict_search('translation.address', config)
+ if addr != None and addr != 'masquerade' and not is_ip_network(addr):
+ for ip in addr.split('-'):
+ if not is_addr_assigned(ip):
+ Warning(f'IP address {ip} does not exist on the system!')
+
+ # common rule verification
+ verify_rule(config, err_msg, nat['firewall_group'])
+
+ if dict_search('destination.rule', nat):
+ for rule, config in dict_search('destination.rule', nat).items():
+ err_msg = f'Destination NAT configuration error in rule {rule}:'
+
+ if 'inbound_interface' in config:
+ if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']:
+ raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"')
+ elif 'name' in config['inbound_interface']:
+ interface_name = config['inbound_interface']['name']
+ if interface_name not in 'any':
+ if interface_name.startswith('!'):
+ interface_name = interface_name[1:]
+ if not interface_exists(interface_name):
+ Warning(f'Interface "{interface_name}" for destination NAT rule "{rule}" does not exist!')
+ else:
+ group_name = config['inbound_interface']['group']
+ if group_name[0] == '!':
+ group_name = group_name[1:]
+ group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name)
+ if group_obj is None:
+ raise ConfigError(f'Invalid interface group "{group_name}" on destination nat rule')
+ if not group_obj:
+ Warning(f'interface-group "{group_name}" has no members!')
+
+ if not dict_search('translation.address', config) and not dict_search('translation.port', config) and 'redirect' not in config['translation']:
+ if 'exclude' not in config and 'backend' not in config['load_balance']:
+ raise ConfigError(f'{err_msg} translation requires address and/or port')
+
+ # common rule verification
+ verify_rule(config, err_msg, nat['firewall_group'])
+
+ if dict_search('static.rule', nat):
+ for rule, config in dict_search('static.rule', nat).items():
+ err_msg = f'Static NAT configuration error in rule {rule}:'
+
+ if 'inbound_interface' not in config:
+ raise ConfigError(f'{err_msg} inbound-interface not specified')
+
+ # common rule verification
+ verify_rule(config, err_msg, nat['firewall_group'])
+
+ return None
+
+def generate(nat):
+ if not os.path.exists(nftables_nat_config):
+ nat['first_install'] = True
+
+ render(nftables_nat_config, 'firewall/nftables-nat.j2', nat)
+ render(nftables_static_nat_conf, 'firewall/nftables-static-nat.j2', nat)
+
+ # dry-run newly generated configuration
+ tmp = run(f'nft --check --file {nftables_nat_config}')
+ if tmp > 0:
+ raise ConfigError('Configuration file errors encountered!')
+
+ tmp = run(f'nft --check --file {nftables_static_nat_conf}')
+ if tmp > 0:
+ raise ConfigError('Configuration file errors encountered!')
+
+ return None
+
+def apply(nat):
+ check_kmod(k_mod)
+
+ cmd(f'nft --file {nftables_nat_config}')
+ cmd(f'nft --file {nftables_static_nat_conf}')
+
+ if not nat or 'deleted' in nat:
+ os.unlink(nftables_nat_config)
+ os.unlink(nftables_static_nat_conf)
+
+ call_dependents()
+
+ return None
+
+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/nat64.py b/src/conf_mode/nat64.py
new file mode 100644
index 0000000..df501ce
--- /dev/null
+++ b/src/conf_mode/nat64.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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/>.
+
+# pylint: disable=empty-docstring,missing-module-docstring
+
+import csv
+import os
+import re
+
+from ipaddress import IPv6Network, IPv6Address
+from json import dumps as json_write
+
+from vyos import ConfigError
+from vyos import airbag
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.kernel import check_kmod
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+
+airbag.enable()
+
+INSTANCE_REGEX = re.compile(r"instance-(\d+)")
+JOOL_CONFIG_DIR = "/run/jool"
+
+
+def get_config(config: Config | None = None) -> None:
+ if config is None:
+ config = Config()
+
+ base = ["nat64"]
+ nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True)
+
+ return nat64
+
+
+def verify(nat64) -> None:
+ check_kmod(["jool"])
+ base_src = ["nat64", "source", "rule"]
+
+ # Load in existing instances so we can destroy any unknown
+ lines = cmd("jool instance display --csv").splitlines()
+ for _, instance, _ in csv.reader(lines):
+ match = INSTANCE_REGEX.fullmatch(instance)
+ if not match:
+ # FIXME: Instances that don't match should be ignored but WARN'ed to the user
+ continue
+ num = match.group(1)
+
+ rules = nat64.setdefault("source", {}).setdefault("rule", {})
+ # Mark it for deletion
+ if num not in rules:
+ rules[num] = {"deleted": True}
+ continue
+
+ # If the user changes the mode, recreate the instance else Jool fails with:
+ # Jool error: Sorry; you can't change an instance's framework for now.
+ if is_node_changed(config, base_src + [f"instance-{num}", "mode"]):
+ rules[num]["recreate"] = True
+
+ # If the user changes the pool6, recreate the instance else Jool fails with:
+ # Jool error: Sorry; you can't change a NAT64 instance's pool6 for now.
+ if dict_search("source.prefix", rules[num]) and is_node_changed(
+ config,
+ base_src + [num, "source", "prefix"],
+ ):
+ rules[num]["recreate"] = True
+
+ if not nat64:
+ # nothing left to do
+ return
+
+ if dict_search("source.rule", nat64):
+ # Ensure only 1 netfilter instance per namespace
+ nf_rules = filter(
+ lambda i: "deleted" not in i and i.get('mode') == "netfilter",
+ nat64["source"]["rule"].values(),
+ )
+ next(nf_rules, None) # Discard the first element
+ if next(nf_rules, None) is not None:
+ raise ConfigError(
+ "Jool permits only 1 NAT64 netfilter instance (per network namespace)"
+ )
+
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ continue
+
+ # Verify that source.prefix is set and is a /96
+ if not dict_search("source.prefix", instance):
+ raise ConfigError(f"Source NAT64 rule {rule} missing source prefix")
+ src_prefix = IPv6Network(instance["source"]["prefix"])
+ if src_prefix.prefixlen != 96:
+ raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96")
+ if (int(src_prefix[0]) & int(IPv6Address('0:0:0:0:ff00::'))) != 0:
+ raise ConfigError(
+ f'Source NAT64 rule {rule} source prefix is not RFC6052-compliant: '
+ 'bits 64 to 71 (9th octet) must be zeroed'
+ )
+
+ pools = dict_search("translation.pool", instance)
+ if pools:
+ for num, pool in pools.items():
+ if "address" not in pool:
+ raise ConfigError(
+ f"Source NAT64 rule {rule} translation pool "
+ f"{num} missing address/prefix"
+ )
+ if "port" not in pool:
+ raise ConfigError(
+ f"Source NAT64 rule {rule} translation pool "
+ f"{num} missing port(-range)"
+ )
+
+
+def generate(nat64) -> None:
+ if not nat64:
+ return
+
+ os.makedirs(JOOL_CONFIG_DIR, exist_ok=True)
+
+ if dict_search("source.rule", nat64):
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ # Delete the unused instance file
+ os.unlink(os.path.join(JOOL_CONFIG_DIR, f"instance-{rule}.json"))
+ continue
+
+ name = f"instance-{rule}"
+ config = {
+ "instance": name,
+ "framework": "netfilter",
+ "global": {
+ "pool6": instance["source"]["prefix"],
+ "manually-enabled": "disable" not in instance,
+ },
+ # "bib": [],
+ }
+
+ if "description" in instance:
+ config["comment"] = instance["description"]
+
+ if dict_search("translation.pool", instance):
+ pool4 = []
+ # mark
+ mark = ''
+ if dict_search("match.mark", instance):
+ mark = instance["match"]["mark"]
+
+ for pool in instance["translation"]["pool"].values():
+ if "disable" in pool:
+ continue
+
+ protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp")
+ for proto in protos:
+ obj = {
+ "protocol": proto.upper(),
+ "prefix": pool["address"],
+ "port range": pool["port"],
+ }
+ if mark:
+ obj["mark"] = int(mark)
+ if "description" in pool:
+ obj["comment"] = pool["description"]
+
+ pool4.append(obj)
+
+ if pool4:
+ config["pool4"] = pool4
+
+ write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2))
+
+
+def apply(nat64) -> None:
+ if not nat64:
+ unload_kmod(['jool'])
+ return
+
+ if dict_search("source.rule", nat64):
+ # Deletions first to avoid conflicts
+ for rule, instance in nat64["source"]["rule"].items():
+ if not any(k in instance for k in ("deleted", "recreate")):
+ continue
+
+ ret = run(f"jool instance remove instance-{rule}")
+ if ret != 0:
+ raise ConfigError(
+ f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})"
+ )
+
+ # Now creations
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ continue
+
+ name = f"instance-{rule}"
+ ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json")
+ if ret != 0:
+ raise ConfigError(f"Failed to set jool instance {name}")
+
+
+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/nat66.py b/src/conf_mode/nat66.py
new file mode 100644
index 0000000..95dfae3
--- /dev/null
+++ b/src/conf_mode/nat66.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdep import set_dependents, call_dependents
+from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.kernel import check_kmod
+from vyos.utils.network import interface_exists
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.template import is_ipv6
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+k_mod = ['nft_nat', 'nft_chain_nat']
+
+nftables_nat66_config = '/run/nftables_nat66.nft'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['nat66']
+ nat = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ set_dependents('conntrack', conf)
+
+ if not conf.exists(base):
+ nat['deleted'] = ''
+ return nat
+
+ nat['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Remove dynamic firewall groups if present:
+ if 'dynamic_group' in nat['firewall_group']:
+ del nat['firewall_group']['dynamic_group']
+
+ return nat
+
+def verify(nat):
+ if not nat or 'deleted' in nat:
+ # no need to verify the CLI as NAT66 is going to be deactivated
+ return None
+
+ if dict_search('source.rule', nat):
+ for rule, config in dict_search('source.rule', nat).items():
+ err_msg = f'Source NAT66 configuration error in rule {rule}:'
+
+ if 'outbound_interface' in config:
+ if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']:
+ raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"')
+ elif 'name' in config['outbound_interface']:
+ interface_name = config['outbound_interface']['name']
+ if interface_name not in 'any':
+ if interface_name.startswith('!'):
+ interface_name = interface_name[1:]
+ if not interface_exists(interface_name):
+ Warning(f'Interface "{interface_name}" for source NAT66 rule "{rule}" does not exist!')
+
+ addr = dict_search('translation.address', config)
+ if addr != None:
+ if addr != 'masquerade' and not is_ipv6(addr):
+ raise ConfigError(f'IPv6 address {addr} is not a valid address')
+ else:
+ if 'exclude' not in config:
+ raise ConfigError(f'{err_msg} translation address not specified')
+
+ prefix = dict_search('source.prefix', config)
+ if prefix != None:
+ if not is_ipv6(prefix):
+ raise ConfigError(f'{err_msg} source-prefix not specified')
+
+ if dict_search('destination.rule', nat):
+ for rule, config in dict_search('destination.rule', nat).items():
+ err_msg = f'Destination NAT66 configuration error in rule {rule}:'
+
+ if 'inbound_interface' in config:
+ if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']:
+ raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"')
+ elif 'name' in config['inbound_interface']:
+ interface_name = config['inbound_interface']['name']
+ if interface_name not in 'any':
+ if interface_name.startswith('!'):
+ interface_name = interface_name[1:]
+ if not interface_exists(interface_name):
+ Warning(f'Interface "{interface_name}" for destination NAT66 rule "{rule}" does not exist!')
+
+ if 'destination' in config and 'group' in config['destination']:
+ if len({'address_group', 'network_group', 'domain_group'} & set(config['destination']['group'])) > 1:
+ raise ConfigError('Only one address-group, network-group or domain-group can be specified')
+
+ return None
+
+def generate(nat):
+ if not os.path.exists(nftables_nat66_config):
+ nat['first_install'] = True
+
+ render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat)
+
+ # dry-run newly generated configuration
+ tmp = run(f'nft --check --file {nftables_nat66_config}')
+ if tmp > 0:
+ raise ConfigError('Configuration file errors encountered!')
+
+ return None
+
+def apply(nat):
+ check_kmod(k_mod)
+
+ cmd(f'nft --file {nftables_nat66_config}')
+
+ if not nat or 'deleted' in nat:
+ os.unlink(nftables_nat66_config)
+
+ call_dependents()
+
+ return None
+
+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/nat_cgnat.py b/src/conf_mode/nat_cgnat.py
new file mode 100644
index 0000000..3484e58
--- /dev/null
+++ b/src/conf_mode/nat_cgnat.py
@@ -0,0 +1,475 @@
+#!/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 ipaddress
+import jmespath
+import logging
+import os
+
+from sys import exit
+from logging.handlers import SysLogHandler
+
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.template import render
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+
+nftables_cgnat_config = '/run/nftables-cgnat.nft'
+
+# Logging
+logger = logging.getLogger('cgnat')
+logger.setLevel(logging.DEBUG)
+
+syslog_handler = SysLogHandler(address="/dev/log")
+syslog_handler.setLevel(logging.INFO)
+
+formatter = logging.Formatter('%(name)s: %(message)s')
+syslog_handler.setFormatter(formatter)
+
+logger.addHandler(syslog_handler)
+
+
+class IPOperations:
+ def __init__(self, ip_prefix: str):
+ self.ip_prefix = ip_prefix
+ self.ip_network = ipaddress.ip_network(ip_prefix) if '/' in ip_prefix else None
+
+ def get_ips_count(self) -> int:
+ """Returns the number of IPs in a prefix or range.
+
+ Example:
+ % ip = IPOperations('192.0.2.0/30')
+ % ip.get_ips_count()
+ 4
+ % ip = IPOperations('192.0.2.0-192.0.2.2')
+ % ip.get_ips_count()
+ 3
+ """
+ if '-' in self.ip_prefix:
+ start_ip, end_ip = self.ip_prefix.split('-')
+ start_ip = ipaddress.ip_address(start_ip)
+ end_ip = ipaddress.ip_address(end_ip)
+ return int(end_ip) - int(start_ip) + 1
+ elif '/31' in self.ip_prefix:
+ return 2
+ elif '/32' in self.ip_prefix:
+ return 1
+ else:
+ return sum(
+ 1
+ for _ in [self.ip_network.network_address]
+ + list(self.ip_network.hosts())
+ + [self.ip_network.broadcast_address]
+ )
+
+ def convert_prefix_to_list_ips(self) -> list:
+ """Converts a prefix or IP range to a list of IPs including the network and broadcast addresses.
+
+ Example:
+ % ip = IPOperations('192.0.2.0/30')
+ % ip.convert_prefix_to_list_ips()
+ ['192.0.2.0', '192.0.2.1', '192.0.2.2', '192.0.2.3']
+ %
+ % ip = IPOperations('192.0.0.1-192.0.2.5')
+ % ip.convert_prefix_to_list_ips()
+ ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4', '192.0.2.5']
+ """
+ if '-' in self.ip_prefix:
+ start_ip, end_ip = self.ip_prefix.split('-')
+ start_ip = ipaddress.ip_address(start_ip)
+ end_ip = ipaddress.ip_address(end_ip)
+ return [
+ str(ipaddress.ip_address(ip))
+ for ip in range(int(start_ip), int(end_ip) + 1)
+ ]
+ elif '/31' in self.ip_prefix:
+ return [
+ str(ip)
+ for ip in [
+ self.ip_network.network_address,
+ self.ip_network.broadcast_address,
+ ]
+ ]
+ elif '/32' in self.ip_prefix:
+ return [str(self.ip_network.network_address)]
+ else:
+ return [
+ str(ip)
+ for ip in [self.ip_network.network_address]
+ + list(self.ip_network.hosts())
+ + [self.ip_network.broadcast_address]
+ ]
+
+ def get_prefix_by_ip_range(self) -> list[ipaddress.IPv4Network]:
+ """Return the common prefix for the address range
+
+ Example:
+ % ip = IPOperations('100.64.0.1-100.64.0.5')
+ % ip.get_prefix_by_ip_range()
+ [IPv4Network('100.64.0.1/32'), IPv4Network('100.64.0.2/31'), IPv4Network('100.64.0.4/31')]
+ """
+ # We do not need to convert the IP range to network
+ # if it is already in network format
+ if self.ip_network:
+ return [self.ip_network]
+
+ # Raise an error if the IP range is not in the correct format
+ if '-' not in self.ip_prefix:
+ raise ValueError(
+ 'Invalid IP range format. Please provide the IP range in CIDR format or with "-" separator.'
+ )
+ # Split the IP range and convert it to IP address objects
+ range_start, range_end = self.ip_prefix.split('-')
+ range_start = ipaddress.IPv4Address(range_start)
+ range_end = ipaddress.IPv4Address(range_end)
+
+ # Return the summarized IP networks list
+ return list(ipaddress.summarize_address_range(range_start, range_end))
+
+
+def _delete_conntrack_entries(source_prefixes: list[ipaddress.IPv4Network]) -> None:
+ """Delete all conntrack entries for the list of prefixes"""
+ for source_prefix in source_prefixes:
+ run(f'conntrack -D -s {source_prefix}')
+
+
+def generate_port_rules(
+ external_hosts: list,
+ internal_hosts: list,
+ port_count: int,
+ global_port_range: str = '1024-65535',
+) -> list:
+ """Generates a list of nftables option rules for the batch file.
+
+ Args:
+ external_hosts (list): A list of external host IPs.
+ internal_hosts (list): A list of internal host IPs.
+ port_count (int): The number of ports required per host.
+ global_port_range (str): The global port range to be used. Default is '1024-65535'.
+
+ Returns:
+ list: A list containing two elements:
+ - proto_map_elements (list): A list of proto map elements.
+ - other_map_elements (list): A list of other map elements.
+ """
+ rules = []
+ proto_map_elements = []
+ other_map_elements = []
+ start_port, end_port = map(int, global_port_range.split('-'))
+ total_possible_ports = (end_port - start_port) + 1
+
+ # Calculate the required number of ports per host
+ required_ports_per_host = port_count
+ current_port = start_port
+ current_external_index = 0
+
+ for internal_host in internal_hosts:
+ external_host = external_hosts[current_external_index]
+ next_end_port = current_port + required_ports_per_host - 1
+
+ # If the port range exceeds the end_port, move to the next external host
+ while next_end_port > end_port:
+ current_external_index = (current_external_index + 1) % len(external_hosts)
+ external_host = external_hosts[current_external_index]
+ current_port = start_port
+ next_end_port = current_port + required_ports_per_host - 1
+
+ proto_map_elements.append(
+ f'{internal_host} : {external_host} . {current_port}-{next_end_port}'
+ )
+ other_map_elements.append(f'{internal_host} : {external_host}')
+
+ current_port = next_end_port + 1
+ if current_port > end_port:
+ current_port = start_port
+ current_external_index += 1 # Move to the next external host
+
+ return [proto_map_elements, other_map_elements]
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['nat', 'cgnat']
+ config = conf.get_config_dict(
+ base,
+ get_first_key=True,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True,
+ )
+
+ effective_config = conf.get_config_dict(
+ base,
+ get_first_key=True,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ effective=True,
+ )
+
+ # Check if the pool configuration has changed
+ if not conf.exists(base) or is_node_changed(conf, base + ['pool']):
+ config['delete_conntrack_entries'] = {}
+
+ # add running config
+ if effective_config:
+ config['effective'] = effective_config
+
+ if not conf.exists(base):
+ config['deleted'] = {}
+
+ return config
+
+
+def verify(config):
+ # bail out early - looks like removal from running config
+ if 'deleted' in config:
+ return None
+
+ if 'pool' not in config:
+ raise ConfigError(f'Pool must be defined!')
+ if 'rule' not in config:
+ raise ConfigError(f'Rule must be defined!')
+
+ for pool in ('external', 'internal'):
+ if pool not in config['pool']:
+ raise ConfigError(f'{pool} pool must be defined!')
+ for pool_name, pool_config in config['pool'][pool].items():
+ if 'range' not in pool_config:
+ raise ConfigError(
+ f'Range for "{pool} pool {pool_name}" must be defined!'
+ )
+
+ external_pools_query = "keys(pool.external)"
+ external_pools: list = jmespath.search(external_pools_query, config)
+ internal_pools_query = "keys(pool.internal)"
+ internal_pools: list = jmespath.search(internal_pools_query, config)
+
+ used_external_pools = {}
+ used_internal_pools = {}
+ for rule, rule_config in config['rule'].items():
+ if 'source' not in rule_config:
+ raise ConfigError(f'Rule "{rule}" source pool must be defined!')
+ if 'pool' not in rule_config['source']:
+ raise ConfigError(f'Rule "{rule}" source pool must be defined!')
+
+ if 'translation' not in rule_config:
+ raise ConfigError(f'Rule "{rule}" translation pool must be defined!')
+
+ # Check if pool exists
+ internal_pool = rule_config['source']['pool']
+ if internal_pool not in internal_pools:
+ raise ConfigError(f'Internal pool "{internal_pool}" does not exist!')
+ external_pool = rule_config['translation']['pool']
+ if external_pool not in external_pools:
+ raise ConfigError(f'External pool "{external_pool}" does not exist!')
+
+ # Check pool duplication in different rules
+ if external_pool in used_external_pools:
+ raise ConfigError(
+ f'External pool "{external_pool}" is already used in rule '
+ f'{used_external_pools[external_pool]} and cannot be used in '
+ f'rule {rule}!'
+ )
+
+ if internal_pool in used_internal_pools:
+ raise ConfigError(
+ f'Internal pool "{internal_pool}" is already used in rule '
+ f'{used_internal_pools[internal_pool]} and cannot be used in '
+ f'rule {rule}!'
+ )
+
+ used_external_pools[external_pool] = rule
+ used_internal_pools[internal_pool] = rule
+
+ # Check calculation for allocation
+ external_port_range: str = config['pool']['external'][external_pool]['external_port_range']
+
+ external_ip_ranges: list = list(
+ config['pool']['external'][external_pool]['range']
+ )
+ internal_ip_ranges: list = config['pool']['internal'][internal_pool]['range']
+ start_port, end_port = map(int, external_port_range.split('-'))
+ ports_per_range_count: int = (end_port - start_port) + 1
+
+ external_list_hosts_count = []
+ external_list_hosts = []
+ internal_list_hosts_count = []
+ internal_list_hosts = []
+ for ext_range in external_ip_ranges:
+ # External hosts count
+ e_count = IPOperations(ext_range).get_ips_count()
+ external_list_hosts_count.append(e_count)
+ # External hosts list
+ e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips()
+ external_list_hosts.extend(e_hosts)
+ for int_range in internal_ip_ranges:
+ # Internal hosts count
+ i_count = IPOperations(int_range).get_ips_count()
+ internal_list_hosts_count.append(i_count)
+ # Internal hosts list
+ i_hosts = IPOperations(int_range).convert_prefix_to_list_ips()
+ internal_list_hosts.extend(i_hosts)
+
+ external_host_count = sum(external_list_hosts_count)
+ internal_host_count = sum(internal_list_hosts_count)
+ ports_per_user: int = int(
+ config['pool']['external'][external_pool]['per_user_limit']['port']
+ )
+ users_per_extip = ports_per_range_count // ports_per_user
+ max_users = users_per_extip * external_host_count
+
+ if internal_host_count > max_users:
+ raise ConfigError(
+ f'Rule "{rule}" does not have enough ports available for the '
+ f'specified parameters'
+ )
+
+
+def generate(config):
+ if 'deleted' in config:
+ return None
+
+ proto_maps = []
+ other_maps = []
+
+ for rule, rule_config in config['rule'].items():
+ ext_pool_name: str = rule_config['translation']['pool']
+ int_pool_name: str = rule_config['source']['pool']
+
+ # Sort the external ranges by sequence
+ external_ranges: list = sorted(
+ config['pool']['external'][ext_pool_name]['range'],
+ key=lambda r: int(config['pool']['external'][ext_pool_name]['range'][r].get('seq', 999999))
+ )
+ internal_ranges: list = [range for range in config['pool']['internal'][int_pool_name]['range']]
+ external_list_hosts_count = []
+ external_list_hosts = []
+ internal_list_hosts_count = []
+ internal_list_hosts = []
+
+ for ext_range in external_ranges:
+ # External hosts count
+ e_count = IPOperations(ext_range).get_ips_count()
+ external_list_hosts_count.append(e_count)
+ # External hosts list
+ e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips()
+ external_list_hosts.extend(e_hosts)
+
+ for int_range in internal_ranges:
+ # Internal hosts count
+ i_count = IPOperations(int_range).get_ips_count()
+ internal_list_hosts_count.append(i_count)
+ # Internal hosts list
+ i_hosts = IPOperations(int_range).convert_prefix_to_list_ips()
+ internal_list_hosts.extend(i_hosts)
+
+ external_host_count = sum(external_list_hosts_count)
+ internal_host_count = sum(internal_list_hosts_count)
+ ports_per_user = int(
+ jmespath.search(f'pool.external."{ext_pool_name}".per_user_limit.port', config)
+ )
+ external_port_range: str = jmespath.search(
+ f'pool.external."{ext_pool_name}".external_port_range', config
+ )
+
+ rule_proto_maps, rule_other_maps = generate_port_rules(
+ external_list_hosts, internal_list_hosts, ports_per_user, external_port_range
+ )
+
+ proto_maps.extend(rule_proto_maps)
+ other_maps.extend(rule_other_maps)
+
+ config['proto_map_elements'] = ', '.join(proto_maps)
+ config['other_map_elements'] = ', '.join(other_maps)
+
+ render(nftables_cgnat_config, 'firewall/nftables-cgnat.j2', config)
+
+ # dry-run newly generated configuration
+ tmp = run(f'nft --check --file {nftables_cgnat_config}')
+ if tmp > 0:
+ raise ConfigError('Configuration file errors encountered!')
+
+
+def apply(config):
+ if 'deleted' in config:
+ # Cleanup cgnat
+ cmd('nft delete table ip cgnat')
+ if os.path.isfile(nftables_cgnat_config):
+ os.unlink(nftables_cgnat_config)
+ else:
+ cmd(f'nft --file {nftables_cgnat_config}')
+
+ # Delete conntrack entries
+ # if the pool configuration has changed
+ if 'delete_conntrack_entries' in config and 'effective' in config:
+ # Prepare the list of internal pool prefixes
+ internal_pool_prefix_list: list[ipaddress.IPv4Network] = []
+
+ # Get effective rules configurations
+ for rule_config in config['effective'].get('rule', {}).values():
+ # Get effective internal pool configuration
+ internal_pool = rule_config['source']['pool']
+ # Find the internal IP ranges for the internal pool
+ internal_ip_ranges: list[str] = config['effective']['pool']['internal'][
+ internal_pool
+ ]['range']
+ # Get the IP prefixes for the internal IP range
+ for internal_range in internal_ip_ranges:
+ ip_prefix: list[ipaddress.IPv4Network] = IPOperations(
+ internal_range
+ ).get_prefix_by_ip_range()
+ # Add the IP prefixes to the list of all internal pool prefixes
+ internal_pool_prefix_list += ip_prefix
+
+ # Delete required sources for conntrack
+ _delete_conntrack_entries(internal_pool_prefix_list)
+
+ # Logging allocations
+ if 'log_allocation' in config:
+ allocations = config['proto_map_elements']
+ allocations = allocations.split(',')
+ for allocation in allocations:
+ try:
+ # Split based on the delimiters used in the nft data format
+ internal_host, rest = allocation.split(' : ')
+ external_host, port_range = rest.split(' . ')
+ # Log the parsed data
+ logger.info(
+ f'Internal host: {internal_host.lstrip()}, external host: {external_host}, Port range: {port_range}')
+ except ValueError as e:
+ # Log error message
+ logger.error(f"Error processing line '{allocation}': {e}")
+
+
+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/netns.py b/src/conf_mode/netns.py
new file mode 100644
index 0000000..b57e46a
--- /dev/null
+++ b/src/conf_mode/netns.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import node_changed
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+def netns_interfaces(c, match):
+ """
+ get NETNS bound interfaces
+ """
+ matched = []
+ old_level = c.get_level()
+ c.set_level(['interfaces'])
+ section = c.get_config_dict([], get_first_key=True)
+ for type in section:
+ interfaces = section[type]
+ for name in interfaces:
+ interface = interfaces[name]
+ if 'netns' in interface:
+ v = interface.get('netns', '')
+ if v == match:
+ matched.append(name)
+
+ c.set_level(old_level)
+ return matched
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['netns']
+ netns = conf.get_config_dict(base, get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # determine which NETNS has been removed
+ for name in node_changed(conf, base + ['name']):
+ if 'netns_remove' not in netns:
+ netns.update({'netns_remove' : {}})
+
+ netns['netns_remove'][name] = {}
+ # get NETNS bound interfaces
+ interfaces = netns_interfaces(conf, name)
+ if interfaces: netns['netns_remove'][name]['interface'] = interfaces
+
+ return netns
+
+def verify(netns):
+ # ensure NETNS is not assigned to any interface
+ if 'netns_remove' in netns:
+ for name, config in netns['netns_remove'].items():
+ if 'interface' in config:
+ raise ConfigError(f'Can not remove network namespace "{name}", it '\
+ f'still has member interfaces!')
+
+ if 'name' in netns:
+ for name, config in netns['name'].items():
+ # no tests (yet)
+ pass
+
+ return None
+
+def generate(netns):
+ if not netns:
+ return None
+
+ return None
+
+
+def apply(netns):
+
+ for tmp in (dict_search('netns_remove', netns) or []):
+ if os.path.isfile(f'/run/netns/{tmp}'):
+ call(f'ip netns del {tmp}')
+
+ if 'name' in netns:
+ for name, config in netns['name'].items():
+ if not os.path.isfile(f'/run/netns/{name}'):
+ call(f'ip netns add {name}')
+
+ return None
+
+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/pki.py b/src/conf_mode/pki.py
new file mode 100644
index 0000000..215b22b
--- /dev/null
+++ b/src/conf_mode/pki.py
@@ -0,0 +1,486 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from sys import argv
+from sys import exit
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.configdict import node_changed
+from vyos.configdiff import Diff
+from vyos.configdiff import get_config_diff
+from vyos.defaults import directories
+from vyos.pki import is_ca_certificate
+from vyos.pki import load_certificate
+from vyos.pki import load_public_key
+from vyos.pki import load_openssh_public_key
+from vyos.pki import load_openssh_private_key
+from vyos.pki import load_private_key
+from vyos.pki import load_crl
+from vyos.pki import load_dh_parameters
+from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_search_recursive
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import is_systemd_service_active
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+vyos_certbot_dir = directories['certbot']
+
+# keys to recursively search for under specified path
+sync_search = [
+ {
+ 'keys': ['certificate'],
+ 'path': ['service', 'https'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['interfaces', 'ethernet'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate', 'dh_params', 'shared_secret_key', 'auth_key', 'crypt_key'],
+ 'path': ['interfaces', 'openvpn'],
+ },
+ {
+ 'keys': ['ca_certificate'],
+ 'path': ['interfaces', 'sstpc'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['load_balancing', 'reverse_proxy'],
+ },
+ {
+ 'keys': ['key'],
+ 'path': ['protocols', 'rpki', 'cache'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate', 'local_key', 'remote_key'],
+ 'path': ['vpn', 'ipsec'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['vpn', 'openconnect'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['vpn', 'sstp'],
+ },
+ {
+ 'keys': ['certificate', 'ca_certificate'],
+ 'path': ['service', 'stunnel'],
+ }
+]
+
+# key from other config nodes -> key in pki['changed'] and pki
+sync_translate = {
+ 'certificate': 'certificate',
+ 'ca_certificate': 'ca',
+ 'dh_params': 'dh',
+ 'local_key': 'key_pair',
+ 'remote_key': 'key_pair',
+ 'shared_secret_key': 'openvpn',
+ 'auth_key': 'openvpn',
+ 'crypt_key': 'openvpn',
+ 'key': 'openssh',
+}
+
+def certbot_delete(certificate):
+ if not boot_configuration_complete():
+ return
+ if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'):
+ cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}')
+
+def certbot_request(name: str, config: dict, dry_run: bool=True):
+ # We do not call certbot when booting the system - there is no need to do so and
+ # request new certificates during boot/image upgrade as the certbot configuration
+ # is stored persistent under /config - thus we do not open the door to transient
+ # errors
+ if not boot_configuration_complete():
+ return
+
+ domains = '--domains ' + ' --domains '.join(config['domain_name'])
+ tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\
+ f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\
+ f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\
+ f'{domains}'
+ if 'listen_address' in config:
+ tmp += f' --http-01-address {config["listen_address"]}'
+ # verify() does not need to actually request a cert but only test for plausability
+ if dry_run:
+ tmp += ' --dry-run'
+
+ cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!')
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['pki']
+
+ pki = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ if len(argv) > 1 and argv[1] == 'certbot_renew':
+ pki['certbot_renew'] = {}
+
+ tmp = node_changed(conf, base + ['ca'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'ca' : tmp})
+
+ tmp = node_changed(conf, base + ['certificate'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'certificate' : tmp})
+
+ tmp = node_changed(conf, base + ['dh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'dh' : tmp})
+
+ tmp = node_changed(conf, base + ['key-pair'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'key_pair' : tmp})
+
+ tmp = node_changed(conf, base + ['openssh'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'openssh' : tmp})
+
+ tmp = node_changed(conf, base + ['openvpn', 'shared-secret'], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD)
+ if tmp:
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'openvpn' : tmp})
+
+ # We only merge on the defaults of there is a configuration at all
+ if conf.exists(base):
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**pki.kwargs, recursive=True)
+ # remove ACME default configuration if unused by CLI
+ if 'certificate' in pki:
+ for name, cert_config in pki['certificate'].items():
+ if 'acme' not in cert_config:
+ # Remove ACME default values
+ del default_values['certificate'][name]['acme']
+
+ # merge CLI and default dictionary
+ pki = config_dict_merge(default_values, pki)
+
+ # Certbot triggered an external renew of the certificates.
+ # Mark all ACME based certificates as "changed" to trigger
+ # update of dependent services
+ if 'certificate' in pki and 'certbot_renew' in pki:
+ renew = []
+ for name, cert_config in pki['certificate'].items():
+ if 'acme' in cert_config:
+ renew.append(name)
+ # If triggered externally by certbot, certificate key is not present in changed
+ if 'changed' not in pki: pki.update({'changed':{}})
+ pki['changed'].update({'certificate' : renew})
+
+ # We need to get the entire system configuration to verify that we are not
+ # deleting a certificate that is still referenced somewhere!
+ pki['system'] = conf.get_config_dict([], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ D = get_config_diff(conf)
+
+ for search in sync_search:
+ for key in search['keys']:
+ changed_key = sync_translate[key]
+ if 'changed' not in pki or changed_key not in pki['changed']:
+ continue
+
+ for item_name in pki['changed'][changed_key]:
+ node_present = False
+ if changed_key == 'openvpn':
+ node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name)
+ else:
+ node_present = dict_search_args(pki, changed_key, item_name)
+
+ if node_present:
+ search_dict = dict_search_args(pki['system'], *search['path'])
+ if not search_dict:
+ continue
+ for found_name, found_path in dict_search_recursive(search_dict, key):
+ if isinstance(found_name, list) and item_name not in found_name:
+ continue
+
+ if isinstance(found_name, str) and found_name != item_name:
+ continue
+
+ path = search['path']
+ path_str = ' '.join(path + found_path)
+ #print(f'PKI: Updating config: {path_str} {item_name}')
+
+ if path[0] == 'interfaces':
+ ifname = found_path[0]
+ if not D.node_changed_presence(path + [ifname]):
+ set_dependents(path[1], conf, ifname)
+ else:
+ if not D.node_changed_presence(path):
+ set_dependents(path[1], conf)
+
+ return pki
+
+def is_valid_certificate(raw_data):
+ # If it loads correctly we're good, or return False
+ return load_certificate(raw_data, wrap_tags=True)
+
+def is_valid_ca_certificate(raw_data):
+ # Check if this is a valid certificate with CA attributes
+ cert = load_certificate(raw_data, wrap_tags=True)
+ if not cert:
+ return False
+ return is_ca_certificate(cert)
+
+def is_valid_public_key(raw_data):
+ # If it loads correctly we're good, or return False
+ return load_public_key(raw_data, wrap_tags=True)
+
+def is_valid_private_key(raw_data, protected=False):
+ # If it loads correctly we're good, or return False
+ # With encrypted private keys, we always return true as we cannot ask for password to verify
+ if protected:
+ return True
+ return load_private_key(raw_data, passphrase=None, wrap_tags=True)
+
+def is_valid_openssh_public_key(raw_data, type):
+ # If it loads correctly we're good, or return False
+ return load_openssh_public_key(raw_data, type)
+
+def is_valid_openssh_private_key(raw_data, protected=False):
+ # If it loads correctly we're good, or return False
+ # With encrypted private keys, we always return true as we cannot ask for password to verify
+ if protected:
+ return True
+ return load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True)
+
+def is_valid_crl(raw_data):
+ # If it loads correctly we're good, or return False
+ return load_crl(raw_data, wrap_tags=True)
+
+def is_valid_dh_parameters(raw_data):
+ # If it loads correctly we're good, or return False
+ return load_dh_parameters(raw_data, wrap_tags=True)
+
+def verify(pki):
+ if not pki:
+ return None
+
+ if 'ca' in pki:
+ for name, ca_conf in pki['ca'].items():
+ if 'certificate' in ca_conf:
+ if not is_valid_ca_certificate(ca_conf['certificate']):
+ raise ConfigError(f'Invalid certificate on CA certificate "{name}"')
+
+ if 'private' in ca_conf and 'key' in ca_conf['private']:
+ private = ca_conf['private']
+ protected = 'password_protected' in private
+
+ if not is_valid_private_key(private['key'], protected):
+ raise ConfigError(f'Invalid private key on CA certificate "{name}"')
+
+ if 'crl' in ca_conf:
+ ca_crls = ca_conf['crl']
+ if isinstance(ca_crls, str):
+ ca_crls = [ca_crls]
+
+ for crl in ca_crls:
+ if not is_valid_crl(crl):
+ raise ConfigError(f'Invalid CRL on CA certificate "{name}"')
+
+ if 'certificate' in pki:
+ for name, cert_conf in pki['certificate'].items():
+ if 'certificate' in cert_conf:
+ if not is_valid_certificate(cert_conf['certificate']):
+ raise ConfigError(f'Invalid certificate on certificate "{name}"')
+
+ if 'private' in cert_conf and 'key' in cert_conf['private']:
+ private = cert_conf['private']
+ protected = 'password_protected' in private
+
+ if not is_valid_private_key(private['key'], protected):
+ raise ConfigError(f'Invalid private key on certificate "{name}"')
+
+ if 'acme' in cert_conf:
+ if 'domain_name' not in cert_conf['acme']:
+ raise ConfigError(f'At least one domain-name is required to request '\
+ f'certificate for "{name}" via ACME!')
+
+ if 'email' not in cert_conf['acme']:
+ raise ConfigError(f'An email address is required to request '\
+ f'certificate for "{name}" via ACME!')
+
+ if 'certbot_renew' not in pki:
+ # Only run the ACME command if something on this entity changed,
+ # as this is time intensive
+ tmp = dict_search('changed.certificate', pki)
+ if tmp != None and name in tmp:
+ certbot_request(name, cert_conf['acme'])
+
+ if 'dh' in pki:
+ for name, dh_conf in pki['dh'].items():
+ if 'parameters' in dh_conf:
+ if not is_valid_dh_parameters(dh_conf['parameters']):
+ raise ConfigError(f'Invalid DH parameters on "{name}"')
+
+ if 'key_pair' in pki:
+ for name, key_conf in pki['key_pair'].items():
+ if 'public' in key_conf and 'key' in key_conf['public']:
+ if not is_valid_public_key(key_conf['public']['key']):
+ raise ConfigError(f'Invalid public key on key-pair "{name}"')
+
+ if 'private' in key_conf and 'key' in key_conf['private']:
+ private = key_conf['private']
+ protected = 'password_protected' in private
+ if not is_valid_private_key(private['key'], protected):
+ raise ConfigError(f'Invalid private key on key-pair "{name}"')
+
+ if 'openssh' in pki:
+ for name, key_conf in pki['openssh'].items():
+ if 'public' in key_conf and 'key' in key_conf['public']:
+ if 'type' not in key_conf['public']:
+ raise ConfigError(f'Must define OpenSSH public key type for "{name}"')
+ if not is_valid_openssh_public_key(key_conf['public']['key'], key_conf['public']['type']):
+ raise ConfigError(f'Invalid OpenSSH public key "{name}"')
+
+ if 'private' in key_conf and 'key' in key_conf['private']:
+ private = key_conf['private']
+ protected = 'password_protected' in private
+ if not is_valid_openssh_private_key(private['key'], protected):
+ raise ConfigError(f'Invalid OpenSSH private key "{name}"')
+
+ if 'x509' in pki:
+ if 'default' in pki['x509']:
+ default_values = pki['x509']['default']
+ if 'country' in default_values:
+ country = default_values['country']
+ if len(country) != 2 or not country.isalpha():
+ raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.')
+
+ if 'changed' in pki:
+ # if the list is getting longer, we can move to a dict() and also embed the
+ # search key as value from line 173 or 176
+ for search in sync_search:
+ for key in search['keys']:
+ changed_key = sync_translate[key]
+
+ if changed_key not in pki['changed']:
+ continue
+
+ for item_name in pki['changed'][changed_key]:
+ node_present = False
+ if changed_key == 'openvpn':
+ node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name)
+ else:
+ node_present = dict_search_args(pki, changed_key, item_name)
+
+ if not node_present:
+ search_dict = dict_search_args(pki['system'], *search['path'])
+
+ if not search_dict:
+ continue
+
+ for found_name, found_path in dict_search_recursive(search_dict, key):
+ if found_name == item_name:
+ path_str = " ".join(search['path'] + found_path)
+ raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"')
+
+ return None
+
+def generate(pki):
+ if not pki:
+ return None
+
+ # Certbot renewal only needs to re-trigger the services to load up the
+ # new PEM file
+ if 'certbot_renew' in pki:
+ return None
+
+ certbot_list = []
+ certbot_list_on_disk = []
+ if os.path.exists(f'{vyos_certbot_dir}/live'):
+ certbot_list_on_disk = [f.path.split('/')[-1] for f in os.scandir(f'{vyos_certbot_dir}/live') if f.is_dir()]
+
+ if 'certificate' in pki:
+ changed_certificates = dict_search('changed.certificate', pki)
+ for name, cert_conf in pki['certificate'].items():
+ if 'acme' in cert_conf:
+ certbot_list.append(name)
+ # generate certificate if not found on disk
+ if name not in certbot_list_on_disk:
+ certbot_request(name, cert_conf['acme'], dry_run=False)
+ elif changed_certificates != None and name in changed_certificates:
+ # when something for the certificate changed, we should delete it
+ if name in certbot_list_on_disk:
+ certbot_delete(name)
+ certbot_request(name, cert_conf['acme'], dry_run=False)
+
+ # Cleanup certbot configuration and certificates if no longer in use by CLI
+ # Get foldernames under vyos_certbot_dir which each represent a certbot cert
+ if os.path.exists(f'{vyos_certbot_dir}/live'):
+ for cert in certbot_list_on_disk:
+ if cert not in certbot_list:
+ # certificate is no longer active on the CLI - remove it
+ certbot_delete(cert)
+
+ return None
+
+def apply(pki):
+ systemd_certbot_name = 'certbot.timer'
+ if not pki:
+ call(f'systemctl stop {systemd_certbot_name}')
+ return None
+
+ has_certbot = False
+ if 'certificate' in pki:
+ for name, cert_conf in pki['certificate'].items():
+ if 'acme' in cert_conf:
+ has_certbot = True
+ break
+
+ if not has_certbot:
+ call(f'systemctl stop {systemd_certbot_name}')
+ elif has_certbot and not is_systemd_service_active(systemd_certbot_name):
+ call(f'systemctl restart {systemd_certbot_name}')
+
+ if 'changed' in pki:
+ call_dependents()
+
+ return None
+
+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/policy.py b/src/conf_mode/policy.py
new file mode 100644
index 0000000..a5963e7
--- /dev/null
+++ b/src/conf_mode/policy.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-2022 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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+
+airbag.enable()
+
+
+def community_action_compatibility(actions: dict) -> bool:
+ """
+ Check compatibility of values in community and large community sections
+ :param actions: dictionary with community
+ :type actions: dict
+ :return: true if compatible, false if not
+ :rtype: bool
+ """
+ if ('none' in actions) and ('replace' in actions or 'add' in actions):
+ return False
+ if 'replace' in actions and 'add' in actions:
+ return False
+ if ('delete' in actions) and ('none' in actions or 'replace' in actions):
+ return False
+ return True
+
+
+def extcommunity_action_compatibility(actions: dict) -> bool:
+ """
+ Check compatibility of values in extended community sections
+ :param actions: dictionary with community
+ :type actions: dict
+ :return: true if compatible, false if not
+ :rtype: bool
+ """
+ if ('none' in actions) and (
+ 'rt' in actions or 'soo' in actions or 'bandwidth' in actions or 'bandwidth_non_transitive' in actions):
+ return False
+ if ('bandwidth_non_transitive' in actions) and ('bandwidth' not in actions):
+ return False
+ return True
+
+def routing_policy_find(key, dictionary):
+ # Recursively traverse a dictionary and extract the value assigned to
+ # a given key as generator object. This is made for routing policies,
+ # thus also import/export is checked
+ for k, v in dictionary.items():
+ if k == key:
+ if isinstance(v, dict):
+ for a, b in v.items():
+ if a in ['import', 'export']:
+ yield b
+ else:
+ yield v
+ elif isinstance(v, dict):
+ for result in routing_policy_find(key, v):
+ yield result
+ elif isinstance(v, list):
+ for d in v:
+ if isinstance(d, dict):
+ for result in routing_policy_find(key, d):
+ yield result
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['policy']
+ policy = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['protocols'], key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True)
+ # Merge policy dict into "regular" config dict
+ policy = dict_merge(tmp, policy)
+ return policy
+
+
+def verify(policy):
+ if not policy:
+ return None
+
+ for policy_type in ['access_list', 'access_list6', 'as_path_list',
+ 'community_list', 'extcommunity_list',
+ 'large_community_list',
+ 'prefix_list', 'prefix_list6', 'route_map']:
+ # Bail out early and continue with next policy type
+ if policy_type not in policy:
+ continue
+
+ # instance can be an ACL name/number, prefix-list name or route-map name
+ for instance, instance_config in policy[policy_type].items():
+ # If no rule was found within the instance ... sad, but we can leave
+ # early as nothing needs to be verified
+ if 'rule' not in instance_config:
+ continue
+
+ # human readable instance name (hypen instead of underscore)
+ policy_hr = policy_type.replace('_', '-')
+ entries = []
+ for rule, rule_config in instance_config['rule'].items():
+ mandatory_error = f'must be specified for "{policy_hr} {instance} rule {rule}"!'
+ if 'action' not in rule_config:
+ raise ConfigError(f'Action {mandatory_error}')
+
+ if policy_type == 'access_list':
+ if 'source' not in rule_config:
+ raise ConfigError(f'A source {mandatory_error}')
+
+ if int(instance) in range(100, 200) or int(
+ instance) in range(2000, 2700):
+ if 'destination' not in rule_config:
+ raise ConfigError(
+ f'A destination {mandatory_error}')
+
+ if policy_type == 'access_list6':
+ if 'source' not in rule_config:
+ raise ConfigError(f'A source {mandatory_error}')
+
+ if policy_type in ['as_path_list', 'community_list',
+ 'extcommunity_list',
+ 'large_community_list']:
+ if 'regex' not in rule_config:
+ raise ConfigError(f'A regex {mandatory_error}')
+
+ if policy_type in ['prefix_list', 'prefix_list6']:
+ if 'prefix' not in rule_config:
+ raise ConfigError(f'A prefix {mandatory_error}')
+
+ if rule_config in entries:
+ raise ConfigError(
+ f'Rule "{rule}" contains a duplicate prefix definition!')
+ entries.append(rule_config)
+
+ # route-maps tend to be a bit more complex so they get their own verify() section
+ if 'route_map' in policy:
+ for route_map, route_map_config in policy['route_map'].items():
+ if 'rule' not in route_map_config:
+ continue
+
+ for rule, rule_config in route_map_config['rule'].items():
+ # Action 'deny' cannot be used with "continue" or "on-match"
+ # FRR does not validate it T4827, T6676
+ if rule_config['action'] == 'deny' and ('continue' in rule_config or 'on_match' in rule_config):
+ raise ConfigError(f'rule {rule} "continue" or "on-match" cannot be used with action deny!')
+
+ # Specified community-list must exist
+ tmp = dict_search('match.community.community_list',
+ rule_config)
+ if tmp and tmp not in policy.get('community_list', []):
+ raise ConfigError(f'community-list {tmp} does not exist!')
+
+ # Specified extended community-list must exist
+ tmp = dict_search('match.extcommunity', rule_config)
+ if tmp and tmp not in policy.get('extcommunity_list', []):
+ raise ConfigError(
+ f'extcommunity-list {tmp} does not exist!')
+
+ # Specified large-community-list must exist
+ tmp = dict_search('match.large_community.large_community_list',
+ rule_config)
+ if tmp and tmp not in policy.get('large_community_list', []):
+ raise ConfigError(
+ f'large-community-list {tmp} does not exist!')
+
+ # Specified prefix-list must exist
+ tmp = dict_search('match.ip.address.prefix_list', rule_config)
+ if tmp and tmp not in policy.get('prefix_list', []):
+ raise ConfigError(f'prefix-list {tmp} does not exist!')
+
+ # Specified prefix-list must exist
+ tmp = dict_search('match.ipv6.address.prefix_list',
+ rule_config)
+ if tmp and tmp not in policy.get('prefix_list6', []):
+ raise ConfigError(f'prefix-list6 {tmp} does not exist!')
+
+ # Specified access_list6 in nexthop must exist
+ tmp = dict_search('match.ipv6.nexthop.access_list',
+ rule_config)
+ if tmp and tmp not in policy.get('access_list6', []):
+ raise ConfigError(f'access_list6 {tmp} does not exist!')
+
+ # Specified prefix-list6 in nexthop must exist
+ tmp = dict_search('match.ipv6.nexthop.prefix_list',
+ rule_config)
+ if tmp and tmp not in policy.get('prefix_list6', []):
+ raise ConfigError(f'prefix-list6 {tmp} does not exist!')
+
+ tmp = dict_search('set.community.delete', rule_config)
+ if tmp and tmp not in policy.get('community_list', []):
+ raise ConfigError(f'community-list {tmp} does not exist!')
+
+ tmp = dict_search('set.large_community.delete',
+ rule_config)
+ if tmp and tmp not in policy.get('large_community_list', []):
+ raise ConfigError(
+ f'large-community-list {tmp} does not exist!')
+
+ if 'set' in rule_config:
+ rule_action = rule_config['set']
+ if 'community' in rule_action:
+ if not community_action_compatibility(
+ rule_action['community']):
+ raise ConfigError(
+ f'Unexpected combination between action replace, add, delete or none in community')
+ if 'large_community' in rule_action:
+ if not community_action_compatibility(
+ rule_action['large_community']):
+ raise ConfigError(
+ f'Unexpected combination between action replace, add, delete or none in large-community')
+ if 'extcommunity' in rule_action:
+ if not extcommunity_action_compatibility(
+ rule_action['extcommunity']):
+ raise ConfigError(
+ f'Unexpected combination between none, rt, soo, bandwidth, bandwidth-non-transitive in extended-community')
+ # When routing protocols are active some use prefix-lists, route-maps etc.
+ # to apply the systems routing policy to the learned or redistributed routes.
+ # When the "routing policy" changes and policies, route-maps etc. are deleted,
+ # it is our responsibility to verify that the policy can not be deleted if it
+ # is used by any routing protocol
+ if 'protocols' in policy:
+ for policy_type in ['access_list', 'access_list6', 'as_path_list',
+ 'community_list',
+ 'extcommunity_list', 'large_community_list',
+ 'prefix_list', 'route_map']:
+ if policy_type in policy:
+ for policy_name in list(set(routing_policy_find(policy_type,
+ policy[
+ 'protocols']))):
+ found = False
+ if policy_name in policy[policy_type]:
+ found = True
+ # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related
+ # list - we need to go the extra mile here and check both prefix-lists
+ if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \
+ policy['prefix_list6']:
+ found = True
+ if not found:
+ tmp = policy_type.replace('_', '-')
+ raise ConfigError(
+ f'Can not delete {tmp} "{policy_name}", still in use!')
+
+ return None
+
+
+def generate(policy):
+ if not policy:
+ return None
+ policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy)
+ return None
+
+
+def apply(policy):
+ bgp_daemon = 'bgpd'
+ zebra_daemon = 'zebra'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(bgp_daemon)
+ frr_cfg.modify_section(r'^bgp as-path access-list .*')
+ frr_cfg.modify_section(r'^bgp community-list .*')
+ frr_cfg.modify_section(r'^bgp extcommunity-list .*')
+ frr_cfg.modify_section(r'^bgp large-community-list .*')
+ frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit',
+ remove_stop_mark=True)
+ if 'new_frr_config' in policy:
+ frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])
+ frr_cfg.commit_configuration(bgp_daemon)
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(r'^access-list .*')
+ frr_cfg.modify_section(r'^ipv6 access-list .*')
+ frr_cfg.modify_section(r'^ip prefix-list .*')
+ frr_cfg.modify_section(r'^ipv6 prefix-list .*')
+ frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit',
+ remove_stop_mark=True)
+ if 'new_frr_config' in policy:
+ frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ return None
+
+
+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/policy_local-route.py b/src/conf_mode/policy_local-route.py
new file mode 100644
index 0000000..331fd97
--- /dev/null
+++ b/src/conf_mode/policy_local-route.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+from itertools import product
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configdict import leaf_node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['policy']
+
+ pbr = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ for route in ['local_route', 'local_route6']:
+ dict_id = 'rule_remove' if route == 'local_route' else 'rule6_remove'
+ route_key = 'local-route' if route == 'local_route' else 'local-route6'
+ base_rule = base + [route_key, 'rule']
+
+ # delete policy local-route
+ dict = {}
+ tmp = node_changed(conf, base_rule, key_mangling=('-', '_'))
+ if tmp:
+ for rule in (tmp or []):
+ src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address'])
+ src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port'])
+ fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark'])
+ iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface'])
+ dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address'])
+ dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port'])
+ table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table'])
+ proto = leaf_node_changed(conf, base_rule + [rule, 'protocol'])
+ rule_def = {}
+ if src:
+ rule_def = dict_merge({'source': {'address': src}}, rule_def)
+ if src_port:
+ rule_def = dict_merge({'source': {'port': src_port}}, rule_def)
+ if fwmk:
+ rule_def = dict_merge({'fwmark' : fwmk}, rule_def)
+ if iif:
+ rule_def = dict_merge({'inbound_interface' : iif}, rule_def)
+ if dst:
+ rule_def = dict_merge({'destination': {'address': dst}}, rule_def)
+ if dst_port:
+ rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def)
+ if table:
+ rule_def = dict_merge({'table' : table}, rule_def)
+ if proto:
+ rule_def = dict_merge({'protocol' : proto}, rule_def)
+ dict = dict_merge({dict_id : {rule : rule_def}}, dict)
+ pbr.update(dict)
+
+ if not route in pbr:
+ continue
+
+ # delete policy local-route rule x source x.x.x.x
+ # delete policy local-route rule x fwmark x
+ # delete policy local-route rule x destination x.x.x.x
+ if 'rule' in pbr[route]:
+ for rule, rule_config in pbr[route]['rule'].items():
+ src = leaf_node_changed(conf, base_rule + [rule, 'source', 'address'])
+ src_port = leaf_node_changed(conf, base_rule + [rule, 'source', 'port'])
+ fwmk = leaf_node_changed(conf, base_rule + [rule, 'fwmark'])
+ iif = leaf_node_changed(conf, base_rule + [rule, 'inbound-interface'])
+ dst = leaf_node_changed(conf, base_rule + [rule, 'destination', 'address'])
+ dst_port = leaf_node_changed(conf, base_rule + [rule, 'destination', 'port'])
+ table = leaf_node_changed(conf, base_rule + [rule, 'set', 'table'])
+ proto = leaf_node_changed(conf, base_rule + [rule, 'protocol'])
+ # keep track of changes in configuration
+ # otherwise we might remove an existing node although nothing else has changed
+ changed = False
+
+ rule_def = {}
+ # src is None if there are no changes to src
+ if src is None:
+ # if src hasn't changed, include it in the removal selector
+ # if a new selector is added, we have to remove all previous rules without this selector
+ # to make sure we remove all previous rules with this source(s), it will be included
+ if 'source' in rule_config:
+ if 'address' in rule_config['source']:
+ rule_def = dict_merge({'source': {'address': rule_config['source']['address']}}, rule_def)
+ else:
+ # if src is not None, it's previous content will be returned
+ # this can be an empty array if it's just being set, or the previous value
+ # either way, something has to be changed and we only want to remove previous values
+ changed = True
+ # set the old value for removal if it's not empty
+ if len(src) > 0:
+ rule_def = dict_merge({'source': {'address': src}}, rule_def)
+
+ # source port
+ if src_port is None:
+ if 'source' in rule_config:
+ if 'port' in rule_config['source']:
+ tmp = rule_config['source']['port']
+ if isinstance(tmp, str):
+ tmp = [tmp]
+ rule_def = dict_merge({'source': {'port': tmp}}, rule_def)
+ else:
+ changed = True
+ if len(src_port) > 0:
+ rule_def = dict_merge({'source': {'port': src_port}}, rule_def)
+
+ # fwmark
+ if fwmk is None:
+ if 'fwmark' in rule_config:
+ tmp = rule_config['fwmark']
+ if isinstance(tmp, str):
+ tmp = [tmp]
+ rule_def = dict_merge({'fwmark': tmp}, rule_def)
+ else:
+ changed = True
+ if len(fwmk) > 0:
+ rule_def = dict_merge({'fwmark' : fwmk}, rule_def)
+
+ # inbound-interface
+ if iif is None:
+ if 'inbound_interface' in rule_config:
+ rule_def = dict_merge({'inbound_interface': rule_config['inbound_interface']}, rule_def)
+ else:
+ changed = True
+ if len(iif) > 0:
+ rule_def = dict_merge({'inbound_interface' : iif}, rule_def)
+
+ # destination address
+ if dst is None:
+ if 'destination' in rule_config:
+ if 'address' in rule_config['destination']:
+ rule_def = dict_merge({'destination': {'address': rule_config['destination']['address']}}, rule_def)
+ else:
+ changed = True
+ if len(dst) > 0:
+ rule_def = dict_merge({'destination': {'address': dst}}, rule_def)
+
+ # destination port
+ if dst_port is None:
+ if 'destination' in rule_config:
+ if 'port' in rule_config['destination']:
+ tmp = rule_config['destination']['port']
+ if isinstance(tmp, str):
+ tmp = [tmp]
+ rule_def = dict_merge({'destination': {'port': tmp}}, rule_def)
+ else:
+ changed = True
+ if len(dst_port) > 0:
+ rule_def = dict_merge({'destination': {'port': dst_port}}, rule_def)
+
+ # table
+ if table is None:
+ if 'set' in rule_config and 'table' in rule_config['set']:
+ rule_def = dict_merge({'table': [rule_config['set']['table']]}, rule_def)
+ else:
+ changed = True
+ if len(table) > 0:
+ rule_def = dict_merge({'table' : table}, rule_def)
+
+ # protocol
+ if proto is None:
+ if 'protocol' in rule_config:
+ tmp = rule_config['protocol']
+ if isinstance(tmp, str):
+ tmp = [tmp]
+ rule_def = dict_merge({'protocol': tmp}, rule_def)
+ else:
+ changed = True
+ if len(proto) > 0:
+ rule_def = dict_merge({'protocol' : proto}, rule_def)
+
+ if changed:
+ dict = dict_merge({dict_id : {rule : rule_def}}, dict)
+ pbr.update(dict)
+
+ return pbr
+
+def verify(pbr):
+ # bail out early - looks like removal from running config
+ if not pbr:
+ return None
+
+ for route in ['local_route', 'local_route6']:
+ if not route in pbr:
+ continue
+
+ pbr_route = pbr[route]
+ if 'rule' in pbr_route:
+ for rule in pbr_route['rule']:
+ if (
+ 'source' not in pbr_route['rule'][rule] and
+ 'destination' not in pbr_route['rule'][rule] and
+ 'fwmark' not in pbr_route['rule'][rule] and
+ 'inbound_interface' not in pbr_route['rule'][rule] and
+ 'protocol' not in pbr_route['rule'][rule]
+ ):
+ raise ConfigError('Source or destination address or fwmark or inbound-interface or protocol is required!')
+
+ if 'set' not in pbr_route['rule'][rule] or 'table' not in pbr_route['rule'][rule]['set']:
+ raise ConfigError('Table set is required!')
+
+ if 'inbound_interface' in pbr_route['rule'][rule]:
+ interface = pbr_route['rule'][rule]['inbound_interface']
+ verify_interface_exists(pbr, interface)
+
+ return None
+
+def generate(pbr):
+ if not pbr:
+ return None
+
+ return None
+
+def apply(pbr):
+ if not pbr:
+ return None
+
+ # Delete old rule if needed
+ for rule_rm in ['rule_remove', 'rule6_remove']:
+ if rule_rm in pbr:
+ v6 = " -6" if rule_rm == 'rule6_remove' else ""
+
+ for rule, rule_config in pbr[rule_rm].items():
+ source = rule_config.get('source', {}).get('address', [''])
+ source_port = rule_config.get('source', {}).get('port', [''])
+ destination = rule_config.get('destination', {}).get('address', [''])
+ destination_port = rule_config.get('destination', {}).get('port', [''])
+ fwmark = rule_config.get('fwmark', [''])
+ inbound_interface = rule_config.get('inbound_interface', [''])
+ protocol = rule_config.get('protocol', [''])
+ table = rule_config.get('table', [''])
+
+ for src, dst, src_port, dst_port, fwmk, iif, proto, table in product(
+ source, destination, source_port, destination_port,
+ fwmark, inbound_interface, protocol, table):
+ f_src = '' if src == '' else f' from {src} '
+ f_src_port = '' if src_port == '' else f' sport {src_port} '
+ f_dst = '' if dst == '' else f' to {dst} '
+ f_dst_port = '' if dst_port == '' else f' dport {dst_port} '
+ f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} '
+ f_iif = '' if iif == '' else f' iif {iif} '
+ f_proto = '' if proto == '' else f' ipproto {proto} '
+ f_table = '' if table == '' else f' lookup {table} '
+
+ call(f'ip{v6} rule del prio {rule} {f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif}{f_table}')
+
+ # Generate new config
+ for route in ['local_route', 'local_route6']:
+ if not route in pbr:
+ continue
+
+ v6 = " -6" if route == 'local_route6' else ""
+ pbr_route = pbr[route]
+
+ if 'rule' in pbr_route:
+ for rule, rule_config in pbr_route['rule'].items():
+ table = rule_config['set'].get('table', '')
+ source = rule_config.get('source', {}).get('address', ['all'])
+ source_port = rule_config.get('source', {}).get('port', '')
+ destination = rule_config.get('destination', {}).get('address', ['all'])
+ destination_port = rule_config.get('destination', {}).get('port', '')
+ fwmark = rule_config.get('fwmark', '')
+ inbound_interface = rule_config.get('inbound_interface', '')
+ protocol = rule_config.get('protocol', '')
+
+ for src in source:
+ f_src = f' from {src} ' if src else ''
+ for dst in destination:
+ f_dst = f' to {dst} ' if dst else ''
+ f_src_port = f' sport {source_port} ' if source_port else ''
+ f_dst_port = f' dport {destination_port} ' if destination_port else ''
+ f_fwmk = f' fwmark {fwmark} ' if fwmark else ''
+ f_iif = f' iif {inbound_interface} ' if inbound_interface else ''
+ f_proto = f' ipproto {protocol} ' if protocol else ''
+
+ call(f'ip{v6} rule add prio {rule}{f_src}{f_dst}{f_proto}{f_src_port}{f_dst_port}{f_fwmk}{f_iif} lookup {table}')
+
+ return None
+
+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/policy_route.py b/src/conf_mode/policy_route.py
new file mode 100644
index 0000000..223175b
--- /dev/null
+++ b/src/conf_mode/policy_route.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from json import loads
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.dict import dict_search_args
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.utils.network import get_vrf_tableid
+from vyos.defaults import rt_global_table
+from vyos.defaults import rt_global_vrf
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+mark_offset = 0x7FFFFFFF
+nftables_conf = '/run/nftables_policy.conf'
+
+valid_groups = [
+ 'address_group',
+ 'domain_group',
+ 'network_group',
+ 'port_group',
+ 'interface_group'
+]
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['policy']
+
+ policy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ policy['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Remove dynamic firewall groups if present:
+ if 'dynamic_group' in policy['firewall_group']:
+ del policy['firewall_group']['dynamic_group']
+
+ return policy
+
+def verify_rule(policy, name, rule_conf, ipv6, rule_id):
+ icmp = 'icmp' if not ipv6 else 'icmpv6'
+ if icmp in rule_conf:
+ icmp_defined = False
+ if 'type_name' in rule_conf[icmp]:
+ icmp_defined = True
+ if 'code' in rule_conf[icmp] or 'type' in rule_conf[icmp]:
+ raise ConfigError(f'{name} rule {rule_id}: Cannot use ICMP type/code with ICMP type-name')
+ if 'code' in rule_conf[icmp]:
+ icmp_defined = True
+ if 'type' not in rule_conf[icmp]:
+ raise ConfigError(f'{name} rule {rule_id}: ICMP code can only be defined if ICMP type is defined')
+ if 'type' in rule_conf[icmp]:
+ icmp_defined = True
+
+ if icmp_defined and 'protocol' not in rule_conf or rule_conf['protocol'] != icmp:
+ raise ConfigError(f'{name} rule {rule_id}: ICMP type/code or type-name can only be defined if protocol is ICMP')
+
+ if 'set' in rule_conf:
+ if 'tcp_mss' in rule_conf['set']:
+ tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
+ if not tcp_flags or 'syn' not in tcp_flags:
+ raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS')
+
+ if 'vrf' in rule_conf['set'] and 'table' in rule_conf['set']:
+ raise ConfigError(f'{name} rule {rule_id}: Cannot set both forwarding route table and VRF')
+
+ tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
+ if tcp_flags:
+ if dict_search_args(rule_conf, 'protocol') != 'tcp':
+ raise ConfigError('Protocol must be tcp when specifying tcp flags')
+
+ not_flags = dict_search_args(rule_conf, 'tcp', 'flags', 'not')
+ if not_flags:
+ duplicates = [flag for flag in tcp_flags if flag in not_flags]
+ if duplicates:
+ raise ConfigError(f'Cannot match a tcp flag as set and not set')
+
+ for side in ['destination', 'source']:
+ if side in rule_conf:
+ side_conf = rule_conf[side]
+
+ if 'group' in side_conf:
+ if len({'address_group', 'domain_group', 'network_group'} & set(side_conf['group'])) > 1:
+ raise ConfigError('Only one address-group, domain-group or network-group can be specified')
+
+ for group in valid_groups:
+ if group in side_conf['group']:
+ group_name = side_conf['group'][group]
+
+ if group_name.startswith('!'):
+ group_name = group_name[1:]
+
+ fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group
+ error_group = fw_group.replace("_", "-")
+ group_obj = dict_search_args(policy['firewall_group'], fw_group, group_name)
+
+ if group_obj is None:
+ raise ConfigError(f'Invalid {error_group} "{group_name}" on policy route rule')
+
+ if not group_obj:
+ Warning(f'{error_group} "{group_name}" has no members')
+
+ if 'port' in side_conf or dict_search_args(side_conf, 'group', 'port_group'):
+ if 'protocol' not in rule_conf:
+ raise ConfigError('Protocol must be defined if specifying a port or port-group')
+
+ if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+ raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port or port-group')
+
+def verify(policy):
+ for route in ['route', 'route6']:
+ ipv6 = route == 'route6'
+ if route in policy:
+ for name, pol_conf in policy[route].items():
+ if 'rule' in pol_conf:
+ for rule_id, rule_conf in pol_conf['rule'].items():
+ verify_rule(policy, name, rule_conf, ipv6, rule_id)
+
+ return None
+
+def generate(policy):
+ if not os.path.exists(nftables_conf):
+ policy['first_install'] = True
+
+ render(nftables_conf, 'firewall/nftables-policy.j2', policy)
+ return None
+
+def apply_table_marks(policy):
+ for route in ['route', 'route6']:
+ if route in policy:
+ cmd_str = 'ip' if route == 'route' else 'ip -6'
+ tables = []
+ for name, pol_conf in policy[route].items():
+ if 'rule' in pol_conf:
+ for rule_id, rule_conf in pol_conf['rule'].items():
+ vrf_table_id = None
+ set_table = dict_search_args(rule_conf, 'set', 'table')
+ set_vrf = dict_search_args(rule_conf, 'set', 'vrf')
+ if set_vrf:
+ if set_vrf == 'default':
+ vrf_table_id = rt_global_vrf
+ else:
+ vrf_table_id = get_vrf_tableid(set_vrf)
+ elif set_table:
+ if set_table == 'main':
+ vrf_table_id = rt_global_table
+ else:
+ vrf_table_id = set_table
+ if vrf_table_id is not None:
+ vrf_table_id = int(vrf_table_id)
+ if vrf_table_id in tables:
+ continue
+ tables.append(vrf_table_id)
+ table_mark = mark_offset - vrf_table_id
+ cmd(f'{cmd_str} rule add pref {vrf_table_id} fwmark {table_mark} table {vrf_table_id}')
+
+def cleanup_table_marks():
+ for cmd_str in ['ip', 'ip -6']:
+ json_rules = cmd(f'{cmd_str} -j -N rule list')
+ rules = loads(json_rules)
+ for rule in rules:
+ if 'fwmark' not in rule or 'table' not in rule:
+ continue
+ fwmark = rule['fwmark']
+ table = int(rule['table'])
+ if fwmark[:2] == '0x':
+ fwmark = int(fwmark, 16)
+ if (int(fwmark) == (mark_offset - table)):
+ cmd(f'{cmd_str} rule del fwmark {fwmark} table {table}')
+
+def apply(policy):
+ install_result = run(f'nft --file {nftables_conf}')
+ if install_result == 1:
+ raise ConfigError('Failed to apply policy based routing')
+
+ if 'first_install' not in policy:
+ cleanup_table_marks()
+
+ apply_table_marks(policy)
+
+ return None
+
+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/protocols_babel.py b/src/conf_mode/protocols_babel.py
new file mode 100644
index 0000000..90b6e4a
--- /dev/null
+++ b/src/conf_mode/protocols_babel.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_access_list
+from vyos.configverify import verify_prefix_list
+from vyos.utils.dict import dict_search
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'babel']
+ babel = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ babel['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does not exist
+ if not conf.exists(base):
+ babel.update({'deleted' : ''})
+ return babel
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # values which we need to update into the dictionary retrieved.
+ default_values = conf.get_config_defaults(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ recursive=True)
+
+ # merge in default values
+ babel = config_dict_merge(default_values, babel)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ babel = dict_merge(tmp, babel)
+ return babel
+
+def verify(babel):
+ if not babel:
+ return None
+
+ # verify distribute_list
+ if "distribute_list" in babel:
+ acl_keys = {
+ "ipv4": [
+ "distribute_list.ipv4.access_list.in",
+ "distribute_list.ipv4.access_list.out",
+ ],
+ "ipv6": [
+ "distribute_list.ipv6.access_list.in",
+ "distribute_list.ipv6.access_list.out",
+ ]
+ }
+ prefix_list_keys = {
+ "ipv4": [
+ "distribute_list.ipv4.prefix_list.in",
+ "distribute_list.ipv4.prefix_list.out",
+ ],
+ "ipv6":[
+ "distribute_list.ipv6.prefix_list.in",
+ "distribute_list.ipv6.prefix_list.out",
+ ]
+ }
+ for address_family in ["ipv4", "ipv6"]:
+ for iface_key in babel["distribute_list"].get(address_family, {}).get("interface", {}).keys():
+ acl_keys[address_family].extend([
+ f"distribute_list.{address_family}.interface.{iface_key}.access_list.in",
+ f"distribute_list.{address_family}.interface.{iface_key}.access_list.out"
+ ])
+ prefix_list_keys[address_family].extend([
+ f"distribute_list.{address_family}.interface.{iface_key}.prefix_list.in",
+ f"distribute_list.{address_family}.interface.{iface_key}.prefix_list.out"
+ ])
+
+ for address_family, keys in acl_keys.items():
+ for key in keys:
+ acl = dict_search(key, babel)
+ if acl:
+ verify_access_list(acl, babel, version='6' if address_family == 'ipv6' else '')
+
+ for address_family, keys in prefix_list_keys.items():
+ for key in keys:
+ prefix_list = dict_search(key, babel)
+ if prefix_list:
+ verify_prefix_list(prefix_list, babel, version='6' if address_family == 'ipv6' else '')
+
+
+def generate(babel):
+ if not babel or 'deleted' in babel:
+ return None
+
+ babel['new_frr_config'] = render_to_string('frr/babeld.frr.j2', babel)
+ return None
+
+def apply(babel):
+ babel_daemon = 'babeld'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ frr_cfg.load_configuration(babel_daemon)
+ frr_cfg.modify_section('^router babel', stop_pattern='^exit', remove_stop_mark=True)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in babel:
+ continue
+ for interface in babel[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'new_frr_config' in babel:
+ frr_cfg.add_before(frr.default_add_before, babel['new_frr_config'])
+ frr_cfg.commit_configuration(babel_daemon)
+
+ return None
+
+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/protocols_bfd.py b/src/conf_mode/protocols_bfd.py
new file mode 100644
index 0000000..1361bb1
--- /dev/null
+++ b/src/conf_mode/protocols_bfd.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from vyos.config import Config
+from vyos.configverify import verify_vrf
+from vyos.template import is_ipv6
+from vyos.template import render_to_string
+from vyos.utils.network import is_ipv6_link_local
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'bfd']
+ bfd = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ # Bail out early if configuration tree does not exist
+ if not conf.exists(base):
+ return bfd
+
+ bfd = conf.merge_defaults(bfd, recursive=True)
+
+ return bfd
+
+def verify(bfd):
+ if not bfd:
+ return None
+
+ if 'peer' in bfd:
+ for peer, peer_config in bfd['peer'].items():
+ # IPv6 link local peers require an explicit local address/interface
+ if is_ipv6_link_local(peer):
+ if 'source' not in peer_config or len(peer_config['source']) < 2:
+ raise ConfigError('BFD IPv6 link-local peers require explicit local address and interface setting')
+
+ # IPv6 peers require an explicit local address
+ if is_ipv6(peer):
+ if 'source' not in peer_config or 'address' not in peer_config['source']:
+ raise ConfigError('BFD IPv6 peers require explicit local address setting')
+
+ if 'multihop' in peer_config:
+ # multihop require source address
+ if 'source' not in peer_config or 'address' not in peer_config['source']:
+ raise ConfigError('BFD multihop require source address')
+
+ # multihop and echo-mode cannot be used together
+ if 'echo_mode' in peer_config:
+ raise ConfigError('BFD multihop and echo-mode cannot be used together')
+
+ # multihop doesn't accept interface names
+ if 'source' in peer_config and 'interface' in peer_config['source']:
+ raise ConfigError('BFD multihop and source interface cannot be used together')
+
+ if 'minimum_ttl' in peer_config and 'multihop' not in peer_config:
+ raise ConfigError('Minimum TTL is only available for multihop BFD sessions!')
+
+ if 'profile' in peer_config:
+ profile_name = peer_config['profile']
+ if 'profile' not in bfd or profile_name not in bfd['profile']:
+ raise ConfigError(f'BFD profile "{profile_name}" does not exist!')
+
+ if 'vrf' in peer_config:
+ verify_vrf(peer_config)
+
+ return None
+
+def generate(bfd):
+ if not bfd:
+ return None
+ bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.j2', bfd)
+
+def apply(bfd):
+ bfd_daemon = 'bfdd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+ frr_cfg.load_configuration(bfd_daemon)
+ frr_cfg.modify_section('^bfd', stop_pattern='^exit', remove_stop_mark=True)
+ if 'new_frr_config' in bfd:
+ frr_cfg.add_before(frr.default_add_before, bfd['new_frr_config'])
+ frr_cfg.commit_configuration(bfd_daemon)
+
+ return None
+
+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/protocols_bgp.py b/src/conf_mode/protocols_bgp.py
new file mode 100644
index 0000000..22f0200
--- /dev/null
+++ b/src/conf_mode/protocols_bgp.py
@@ -0,0 +1,655 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_prefix_list
+from vyos.configverify import verify_route_map
+from vyos.configverify import verify_vrf
+from vyos.template import is_ip
+from vyos.template import is_interface
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_vrf
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.process import process_named_running
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'bgp']
+
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'bgp'] or base_path
+ bgp = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Remove per interface MPLS configuration - get a list if changed
+ # nodes under the interface tagNode
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ bgp['interface_removed'] = list(interfaces_removed)
+
+ # Assign the name of our VRF context. This MUST be done before the return
+ # statement below, else on deletion we will delete the default instance
+ # instead of the VRF instance.
+ if vrf:
+ bgp.update({'vrf' : vrf})
+ # We can not delete the BGP VRF instance if there is a L3VNI configured
+ # FRR L3VNI must be deleted first otherwise we will see error:
+ # "FRR error: Please unconfigure l3vni 3000"
+ tmp = ['vrf', 'name', vrf, 'vni']
+ if conf.exists_effective(tmp):
+ bgp.update({'vni' : conf.return_effective_value(tmp)})
+ # We can safely delete ourself from the dependent vrf list
+ if vrf in bgp['dependent_vrfs']:
+ del bgp['dependent_vrfs'][vrf]
+
+ bgp['dependent_vrfs'].update({'default': {'protocols': {
+ 'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)}}})
+
+ if not conf.exists(base):
+ # If bgp instance is deleted then mark it
+ bgp.update({'deleted' : ''})
+ return bgp
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ bgp = conf.merge_defaults(bgp, recursive=True)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ bgp = dict_merge(tmp, bgp)
+
+ return bgp
+
+
+def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) -> bool:
+ """
+ :param search_vrf_name: search vrf name in import list
+ :type search_vrf_name: str
+ :param afi_name: afi/safi name
+ :type afi_name: str
+ :param vrfs_config: configuration dependents vrfs
+ :type vrfs_config: dict
+ :return: if vrf in import list retrun true else false
+ :rtype: bool
+ """
+ for vrf_name, vrf_config in vrfs_config.items():
+ import_list = dict_search(
+ f'protocols.bgp.address_family.{afi_name}.import.vrf',
+ vrf_config)
+ if import_list:
+ if search_vrf_name in import_list:
+ return True
+ return False
+
+def verify_vrf_import_options(afi_config: dict) -> bool:
+ """
+ Search if afi contains one of options
+ :param afi_config: afi/safi
+ :type afi_config: dict
+ :return: if vrf contains rd and route-target options return true else false
+ :rtype: bool
+ """
+ options = [
+ f'rd.vpn.export',
+ f'route_target.vpn.import',
+ f'route_target.vpn.export',
+ f'route_target.vpn.both'
+ ]
+ for option in options:
+ if dict_search(option, afi_config):
+ return True
+ return False
+
+def verify_vrf_import(vrf_name: str, vrfs_config: dict, afi_name: str) -> bool:
+ """
+ Verify if vrf exists and contain options
+ :param vrf_name: name of VRF
+ :type vrf_name: str
+ :param vrfs_config: dependent vrfs config
+ :type vrfs_config: dict
+ :param afi_name: afi/safi name
+ :type afi_name: str
+ :return: if vrf contains rd and route-target options return true else false
+ :rtype: bool
+ """
+ if vrf_name != 'default':
+ verify_vrf({'vrf': vrf_name})
+ if dict_search(f'{vrf_name}.protocols.bgp.address_family.{afi_name}',
+ vrfs_config):
+ afi_config = \
+ vrfs_config[vrf_name]['protocols']['bgp']['address_family'][
+ afi_name]
+ if verify_vrf_import_options(afi_config):
+ return True
+ return False
+
+def verify_vrflist_import(afi_name: str, afi_config: dict, vrfs_config: dict) -> bool:
+ """
+ Call function to verify
+ if scpecific vrf contains rd and route-target
+ options return true else false
+
+ :param afi_name: afi/safi name
+ :type afi_name: str
+ :param afi_config: afi/safi configuration
+ :type afi_config: dict
+ :param vrfs_config: dependent vrfs config
+ :type vrfs_config:dict
+ :return: if vrf contains rd and route-target options return true else false
+ :rtype: bool
+ """
+ for vrf_name in afi_config['import']['vrf']:
+ if verify_vrf_import(vrf_name, vrfs_config, afi_name):
+ return True
+ return False
+
+def verify_remote_as(peer_config, bgp_config):
+ if 'remote_as' in peer_config:
+ return peer_config['remote_as']
+
+ if 'peer_group' in peer_config:
+ peer_group_name = peer_config['peer_group']
+ tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config)
+ if tmp: return tmp
+
+ if 'interface' in peer_config:
+ if 'remote_as' in peer_config['interface']:
+ return peer_config['interface']['remote_as']
+
+ if 'peer_group' in peer_config['interface']:
+ peer_group_name = peer_config['interface']['peer_group']
+ tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config)
+ if tmp: return tmp
+
+ if 'v6only' in peer_config['interface']:
+ if 'remote_as' in peer_config['interface']['v6only']:
+ return peer_config['interface']['v6only']['remote_as']
+ if 'peer_group' in peer_config['interface']['v6only']:
+ peer_group_name = peer_config['interface']['v6only']['peer_group']
+ tmp = dict_search(f'peer_group.{peer_group_name}.remote_as', bgp_config)
+ if tmp: return tmp
+
+ return None
+
+def verify_afi(peer_config, bgp_config):
+ # If address_family configured under neighboor
+ if 'address_family' in peer_config:
+ return True
+
+ # If address_family configured under peer-group
+ # if neighbor interface configured
+ peer_group_name = None
+ if dict_search('interface.peer_group', peer_config):
+ peer_group_name = peer_config['interface']['peer_group']
+ elif dict_search('interface.v6only.peer_group', peer_config):
+ peer_group_name = peer_config['interface']['v6only']['peer_group']
+
+ # if neighbor IP configured.
+ if 'peer_group' in peer_config:
+ peer_group_name = peer_config['peer_group']
+ if peer_group_name:
+ tmp = dict_search(f'peer_group.{peer_group_name}.address_family', bgp_config)
+ if tmp: return True
+ return False
+
+def verify(bgp):
+ if 'deleted' in bgp:
+ if 'vrf' in bgp:
+ # Cannot delete vrf if it exists in import vrf list in other vrfs
+ for tmp_afi in ['ipv4_unicast', 'ipv6_unicast']:
+ if verify_vrf_as_import(bgp['vrf'], tmp_afi, bgp['dependent_vrfs']):
+ raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \
+ 'unconfigure "import vrf" commands!')
+ else:
+ # We are running in the default VRF context, thus we can not delete
+ # our main BGP instance if there are dependent BGP VRF instances.
+ if 'dependent_vrfs' in bgp:
+ for vrf, vrf_options in bgp['dependent_vrfs'].items():
+ if vrf != 'default':
+ if dict_search('protocols.bgp', vrf_options):
+ raise ConfigError('Cannot delete default BGP instance, ' \
+ 'dependent VRF instance(s) exist(s)!')
+ if 'vni' in vrf_options:
+ raise ConfigError('Cannot delete default BGP instance, ' \
+ 'dependent L3VNI exists!')
+
+ return None
+
+ if 'system_as' not in bgp:
+ raise ConfigError('BGP system-as number must be defined!')
+
+ # Verify BMP
+ if 'bmp' in bgp:
+ # check bmp flag "bgpd -d -F traditional --daemon -A 127.0.0.1 -M rpki -M bmp"
+ if not process_named_running('bgpd', 'bmp'):
+ raise ConfigError(
+ f'"bmp" flag is not found in bgpd. Configure "set system frr bmp" and restart bgp process'
+ )
+ # check bmp target
+ if 'target' in bgp['bmp']:
+ for target, target_config in bgp['bmp']['target'].items():
+ if 'address' not in target_config:
+ raise ConfigError(f'BMP target "{target}" address must be defined!')
+
+ # Verify vrf on interface and bgp section
+ if 'interface' in bgp:
+ for interface in bgp['interface']:
+ error_msg = f'Interface "{interface}" belongs to different VRF instance'
+ tmp = get_interface_vrf(interface)
+ if 'vrf' in bgp:
+ if bgp['vrf'] != tmp:
+ vrf = bgp['vrf']
+ raise ConfigError(f'{error_msg} "{vrf}"!')
+ elif tmp != 'default':
+ raise ConfigError(f'{error_msg} "{tmp}"!')
+
+ peer_groups_context = dict()
+ # Common verification for both peer-group and neighbor statements
+ for neighbor in ['neighbor', 'peer_group']:
+ # bail out early if there is no neighbor or peer-group statement
+ # this also saves one indention level
+ if neighbor not in bgp:
+ continue
+
+ for peer, peer_config in bgp[neighbor].items():
+ # Only regular "neighbor" statement can have a peer-group set
+ # Check if the configure peer-group exists
+ if 'peer_group' in peer_config:
+ peer_group = peer_config['peer_group']
+ if 'peer_group' not in bgp or peer_group not in bgp['peer_group']:
+ raise ConfigError(f'Specified peer-group "{peer_group}" for '\
+ f'neighbor "{neighbor}" does not exist!')
+
+ if 'remote_as' in peer_config:
+ is_ibgp = True
+ if peer_config['remote_as'] != 'internal' and \
+ peer_config['remote_as'] != bgp['system_as']:
+ is_ibgp = False
+
+ if peer_group not in peer_groups_context:
+ peer_groups_context[peer_group] = is_ibgp
+ elif peer_groups_context[peer_group] != is_ibgp:
+ raise ConfigError(f'Peer-group members must be '
+ f'all internal or all external')
+
+ if 'local_role' in peer_config:
+ #Ensure Local Role has only one value.
+ if len(peer_config['local_role']) > 1:
+ raise ConfigError(f'Only one local role can be specified for peer "{peer}"!')
+
+ if 'local_as' in peer_config:
+ if len(peer_config['local_as']) > 1:
+ raise ConfigError(f'Only one local-as number can be specified for peer "{peer}"!')
+
+ # Neighbor local-as override can not be the same as the local-as
+ # we use for this BGP instane!
+ asn = list(peer_config['local_as'].keys())[0]
+ if asn == bgp['system_as']:
+ raise ConfigError('Cannot have local-as same as system-as number')
+
+ # Neighbor AS specified for local-as and remote-as can not be the same
+ if dict_search('remote_as', peer_config) == asn and neighbor != 'peer_group':
+ raise ConfigError(f'Neighbor "{peer}" has local-as specified which is '\
+ 'the same as remote-as, this is not allowed!')
+
+ # ttl-security and ebgp-multihop can't be used in the same configration
+ if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config:
+ raise ConfigError('You can not set both ebgp-multihop and ttl-security hops')
+
+ # interface and ebgp-multihop can't be used in the same configration
+ if 'ebgp_multihop' in peer_config and 'interface' in peer_config:
+ raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\
+ f'neighbor "{peer}"')
+
+ # Check if neighbor has both override capability and strict capability match
+ # configured at the same time.
+ if 'override_capability' in peer_config and 'strict_capability_match' in peer_config:
+ raise ConfigError(f'Neighbor "{peer}" cannot have both override-capability and '\
+ 'strict-capability-match configured at the same time!')
+
+ # Check spaces in the password
+ if 'password' in peer_config and ' ' in peer_config['password']:
+ raise ConfigError('Whitespace is not allowed in passwords!')
+
+ # Some checks can/must only be done on a neighbor and not a peer-group
+ if neighbor == 'neighbor':
+ # remote-as must be either set explicitly for the neighbor
+ # or for the entire peer-group
+ if not verify_remote_as(peer_config, bgp):
+ raise ConfigError(f'Neighbor "{peer}" remote-as must be set!')
+
+ if not verify_afi(peer_config, bgp):
+ Warning(f'BGP neighbor "{peer}" requires address-family!')
+
+ # Peer-group member cannot override remote-as of peer-group
+ if 'peer_group' in peer_config:
+ peer_group = peer_config['peer_group']
+ if 'remote_as' in peer_config and 'remote_as' in bgp['peer_group'][peer_group]:
+ raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!')
+ if 'interface' in peer_config:
+ if 'peer_group' in peer_config['interface']:
+ peer_group = peer_config['interface']['peer_group']
+ if 'remote_as' in peer_config['interface'] and 'remote_as' in bgp['peer_group'][peer_group]:
+ raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!')
+ if 'v6only' in peer_config['interface']:
+ if 'peer_group' in peer_config['interface']['v6only']:
+ peer_group = peer_config['interface']['v6only']['peer_group']
+ if 'remote_as' in peer_config['interface']['v6only'] and 'remote_as' in bgp['peer_group'][peer_group]:
+ raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!')
+
+ # Only checks for ipv4 and ipv6 neighbors
+ # Check if neighbor address is assigned as system interface address
+ vrf = None
+ vrf_error_msg = f' in default VRF!'
+ if 'vrf' in bgp:
+ vrf = bgp['vrf']
+ vrf_error_msg = f' in VRF "{vrf}"!'
+
+ if is_ip(peer) and is_addr_assigned(peer, vrf):
+ raise ConfigError(f'Can not configure local address as neighbor "{peer}"{vrf_error_msg}')
+ elif is_interface(peer):
+ if 'peer_group' in peer_config:
+ raise ConfigError(f'peer-group must be set under the interface node of "{peer}"')
+ if 'remote_as' in peer_config:
+ raise ConfigError(f'remote-as must be set under the interface node of "{peer}"')
+ if 'source_interface' in peer_config['interface']:
+ raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"')
+
+ # Local-AS allowed only for EBGP peers
+ if 'local_as' in peer_config:
+ remote_as = verify_remote_as(peer_config, bgp)
+ if remote_as == bgp['system_as']:
+ raise ConfigError(f'local-as configured for "{peer}", allowed only for eBGP peers!')
+
+ for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec',
+ 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec',
+ 'l2vpn_evpn']:
+ # Bail out early if address family is not configured
+ if 'address_family' not in peer_config or afi not in peer_config['address_family']:
+ continue
+
+ # Check if neighbor has both ipv4 unicast and ipv4 labeled unicast configured at the same time.
+ if 'ipv4_unicast' in peer_config['address_family'] and 'ipv4_labeled_unicast' in peer_config['address_family']:
+ raise ConfigError(f'Neighbor "{peer}" cannot have both ipv4-unicast and ipv4-labeled-unicast configured at the same time!')
+
+ # Check if neighbor has both ipv6 unicast and ipv6 labeled unicast configured at the same time.
+ if 'ipv6_unicast' in peer_config['address_family'] and 'ipv6_labeled_unicast' in peer_config['address_family']:
+ raise ConfigError(f'Neighbor "{peer}" cannot have both ipv6-unicast and ipv6-labeled-unicast configured at the same time!')
+
+ afi_config = peer_config['address_family'][afi]
+
+ if 'conditionally_advertise' in afi_config:
+ if 'advertise_map' not in afi_config['conditionally_advertise']:
+ raise ConfigError('Must speficy advertise-map when conditionally-advertise is in use!')
+ # Verify advertise-map (which is a route-map) exists
+ verify_route_map(afi_config['conditionally_advertise']['advertise_map'], bgp)
+
+ if ('exist_map' not in afi_config['conditionally_advertise'] and
+ 'non_exist_map' not in afi_config['conditionally_advertise']):
+ raise ConfigError('Must either speficy exist-map or non-exist-map when ' \
+ 'conditionally-advertise is in use!')
+
+ if {'exist_map', 'non_exist_map'} <= set(afi_config['conditionally_advertise']):
+ raise ConfigError('Can not specify both exist-map and non-exist-map for ' \
+ 'conditionally-advertise!')
+
+ if 'exist_map' in afi_config['conditionally_advertise']:
+ verify_route_map(afi_config['conditionally_advertise']['exist_map'], bgp)
+
+ if 'non_exist_map' in afi_config['conditionally_advertise']:
+ verify_route_map(afi_config['conditionally_advertise']['non_exist_map'], bgp)
+
+ # T4332: bgp deterministic-med cannot be disabled while addpath-tx-bestpath-per-AS is in use
+ if 'addpath_tx_per_as' in afi_config:
+ if dict_search('parameters.deterministic_med', bgp) == None:
+ raise ConfigError('addpath-tx-per-as requires BGP deterministic-med paramtere to be set!')
+
+ # Validate if configured Prefix list exists
+ if 'prefix_list' in afi_config:
+ for tmp in ['import', 'export']:
+ if tmp not in afi_config['prefix_list']:
+ # bail out early
+ continue
+ if afi == 'ipv4_unicast':
+ verify_prefix_list(afi_config['prefix_list'][tmp], bgp)
+ elif afi == 'ipv6_unicast':
+ verify_prefix_list(afi_config['prefix_list'][tmp], bgp, version='6')
+
+ if 'route_map' in afi_config:
+ for tmp in ['import', 'export']:
+ if tmp in afi_config['route_map']:
+ verify_route_map(afi_config['route_map'][tmp], bgp)
+
+ if 'route_reflector_client' in afi_config:
+ peer_group_as = peer_config.get('remote_as')
+
+ if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']):
+ raise ConfigError('route-reflector-client only supported for iBGP peers')
+ else:
+ if 'peer_group' in peer_config:
+ peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp)
+ if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']):
+ raise ConfigError('route-reflector-client only supported for iBGP peers')
+
+ # T5833 not all AFIs are supported for VRF
+ if 'vrf' in bgp and 'address_family' in peer_config:
+ unsupported_vrf_afi = {
+ 'ipv4_flowspec',
+ 'ipv6_flowspec',
+ 'ipv4_labeled_unicast',
+ 'ipv6_labeled_unicast',
+ 'ipv4_vpn',
+ 'ipv6_vpn',
+ }
+ for afi in peer_config['address_family']:
+ if afi in unsupported_vrf_afi:
+ raise ConfigError(
+ f"VRF is not allowed for address-family '{afi.replace('_', '-')}'"
+ )
+
+ # Throw an error if a peer group is not configured for allow range
+ for prefix in dict_search('listen.range', bgp) or []:
+ # we can not use dict_search() here as prefix contains dots ...
+ if 'peer_group' not in bgp['listen']['range'][prefix]:
+ raise ConfigError(f'Listen range for prefix "{prefix}" has no peer group configured.')
+
+ peer_group = bgp['listen']['range'][prefix]['peer_group']
+ if 'peer_group' not in bgp or peer_group not in bgp['peer_group']:
+ raise ConfigError(f'Peer-group "{peer_group}" for listen range "{prefix}" does not exist!')
+
+ if not verify_remote_as(bgp['listen']['range'][prefix], bgp):
+ raise ConfigError(f'Peer-group "{peer_group}" requires remote-as to be set!')
+
+ # Throw an error if the global administrative distance parameters aren't all filled out.
+ if dict_search('parameters.distance.global', bgp) != None:
+ for key in ['external', 'internal', 'local']:
+ if dict_search(f'parameters.distance.global.{key}', bgp) == None:
+ raise ConfigError('Missing mandatory configuration option for '\
+ f'global administrative distance {key}!')
+
+ # TCP keepalive requires all three parameters to be set
+ if dict_search('parameters.tcp_keepalive', bgp) != None:
+ if not {'idle', 'interval', 'probes'} <= set(bgp['parameters']['tcp_keepalive']):
+ raise ConfigError('TCP keepalive incomplete - idle, keepalive and probes must be set')
+
+ # Address Family specific validation
+ if 'address_family' in bgp:
+ for afi, afi_config in bgp['address_family'].items():
+ if 'distance' in afi_config:
+ # Throw an error if the address family specific administrative
+ # distance parameters aren't all filled out.
+ for key in ['external', 'internal', 'local']:
+ if key not in afi_config['distance']:
+ raise ConfigError('Missing mandatory configuration option for '\
+ f'{afi} administrative distance {key}!')
+
+ if afi in ['ipv4_unicast', 'ipv6_unicast']:
+ vrf_name = bgp['vrf'] if dict_search('vrf', bgp) else 'default'
+ # Verify if currant VRF contains rd and route-target options
+ # and does not exist in import list in other VRFs
+ if dict_search(f'rd.vpn.export', afi_config):
+ if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']):
+ raise ConfigError(
+ 'Command "import vrf" conflicts with "rd vpn export" command!')
+ if not dict_search('parameters.router_id', bgp):
+ Warning(f'BGP "router-id" is required when using "rd" and "route-target"!')
+
+ if dict_search('route_target.vpn.both', afi_config):
+ if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']):
+ raise ConfigError(
+ 'Command "import vrf" conflicts with "route-target vpn both" command!')
+ if dict_search('route_target.vpn.export', afi_config):
+ raise ConfigError(
+ 'Command "route-target vpn export" conflicts '\
+ 'with "route-target vpn both" command!')
+ if dict_search('route_target.vpn.import', afi_config):
+ raise ConfigError(
+ 'Command "route-target vpn import" conflicts '\
+ 'with "route-target vpn both" command!')
+
+ if dict_search('route_target.vpn.import', afi_config):
+ if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']):
+ raise ConfigError(
+ 'Command "import vrf conflicts" with "route-target vpn import" command!')
+
+ if dict_search('route_target.vpn.export', afi_config):
+ if verify_vrf_as_import(vrf_name, afi, bgp['dependent_vrfs']):
+ raise ConfigError(
+ 'Command "import vrf" conflicts with "route-target vpn export" command!')
+
+ # Verify if VRFs in import do not contain rd
+ # and route-target options
+ if dict_search('import.vrf', afi_config) is not None:
+ # Verify if VRF with import does not contain rd
+ # and route-target options
+ if verify_vrf_import_options(afi_config):
+ raise ConfigError(
+ 'Please unconfigure "import vrf" commands before using vpn commands in the same VRF!')
+ # Verify if VRFs in import list do not contain rd
+ # and route-target options
+ if verify_vrflist_import(afi, afi_config, bgp['dependent_vrfs']):
+ raise ConfigError(
+ 'Please unconfigure import vrf commands before using vpn commands in dependent VRFs!')
+
+ # FRR error: please unconfigure vpn to vrf commands before
+ # using import vrf commands
+ if 'vpn' in afi_config['import'] or dict_search('export.vpn', afi_config) != None:
+ raise ConfigError('Please unconfigure VPN to VRF commands before '\
+ 'using "import vrf" commands!')
+
+ # Verify that the export/import route-maps do exist
+ for export_import in ['export', 'import']:
+ tmp = dict_search(f'route_map.vpn.{export_import}', afi_config)
+ if tmp: verify_route_map(tmp, bgp)
+
+ # per-vrf sid and per-af sid are mutually exclusive
+ if 'sid' in afi_config and 'sid' in bgp:
+ raise ConfigError('SID per VRF and SID per address-family are mutually exclusive!')
+
+ # Checks only required for L2VPN EVPN
+ if afi in ['l2vpn_evpn']:
+ if 'vni' in afi_config:
+ for vni, vni_config in afi_config['vni'].items():
+ if 'rd' in vni_config and 'advertise_all_vni' not in afi_config:
+ raise ConfigError('BGP EVPN "rd" requires "advertise-all-vni" to be set!')
+ if 'route_target' in vni_config and 'advertise_all_vni' not in afi_config:
+ raise ConfigError('BGP EVPN "route-target" requires "advertise-all-vni" to be set!')
+
+ return None
+
+def generate(bgp):
+ if not bgp or 'deleted' in bgp:
+ return None
+
+ bgp['frr_bgpd_config'] = render_to_string('frr/bgpd.frr.j2', bgp)
+ return None
+
+def apply(bgp):
+ if 'deleted' in bgp:
+ # We need to ensure that the L3VNI is deleted first.
+ # This is not possible with old config backend
+ # priority bug
+ if {'vrf', 'vni'} <= set(bgp):
+ call('vtysh -c "conf t" -c "vrf {vrf}" -c "no vni {vni}"'.format(**bgp))
+
+ bgp_daemon = 'bgpd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # Generate empty helper string which can be ammended to FRR commands, it
+ # will be either empty (default VRF) or contain the "vrf <name" statement
+ vrf = ''
+ if 'vrf' in bgp:
+ vrf = ' vrf ' + bgp['vrf']
+
+ frr_cfg.load_configuration(bgp_daemon)
+
+ # Remove interface specific config
+ for key in ['interface', 'interface_removed']:
+ if key not in bgp:
+ continue
+ for interface in bgp[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ frr_cfg.modify_section(f'^router bgp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True)
+ if 'frr_bgpd_config' in bgp:
+ frr_cfg.add_before(frr.default_add_before, bgp['frr_bgpd_config'])
+ frr_cfg.commit_configuration(bgp_daemon)
+
+ return None
+
+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/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py
new file mode 100644
index 0000000..c13e52a
--- /dev/null
+++ b/src/conf_mode/protocols_eigrp.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_vrf
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'eigrp']
+
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'eigrp'] or base_path
+ eigrp = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ # Assign the name of our VRF context. This MUST be done before the return
+ # statement below, else on deletion we will delete the default instance
+ # instead of the VRF instance.
+ if vrf: eigrp.update({'vrf' : vrf})
+
+ if not conf.exists(base):
+ eigrp.update({'deleted' : ''})
+ if not vrf:
+ # We are running in the default VRF context, thus we can not delete
+ # our main EIGRP instance if there are dependent EIGRP VRF instances.
+ eigrp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ return eigrp
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ eigrp = dict_merge(tmp, eigrp)
+
+ return eigrp
+
+def verify(eigrp):
+ if not eigrp or 'deleted' in eigrp:
+ return
+
+ if 'system_as' not in eigrp:
+ raise ConfigError('EIGRP system-as must be defined!')
+
+ if 'vrf' in eigrp:
+ verify_vrf(eigrp)
+
+def generate(eigrp):
+ if not eigrp or 'deleted' in eigrp:
+ return None
+
+ eigrp['frr_eigrpd_config'] = render_to_string('frr/eigrpd.frr.j2', eigrp)
+
+def apply(eigrp):
+ eigrp_daemon = 'eigrpd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # Generate empty helper string which can be ammended to FRR commands, it
+ # will be either empty (default VRF) or contain the "vrf <name" statement
+ vrf = ''
+ if 'vrf' in eigrp:
+ vrf = ' vrf ' + eigrp['vrf']
+
+ frr_cfg.load_configuration(eigrp_daemon)
+ frr_cfg.modify_section(f'^router eigrp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True)
+ if 'frr_eigrpd_config' in eigrp:
+ frr_cfg.add_before(frr.default_add_before, eigrp['frr_eigrpd_config'])
+ frr_cfg.commit_configuration(eigrp_daemon)
+
+ return None
+
+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/protocols_failover.py b/src/conf_mode/protocols_failover.py
new file mode 100644
index 0000000..e7e44db
--- /dev/null
+++ b/src/conf_mode/protocols_failover.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 json
+
+from pathlib import Path
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+
+service_name = 'vyos-failover'
+service_conf = Path(f'/run/{service_name}.conf')
+systemd_service = '/run/systemd/system/vyos-failover.service'
+rt_proto_failover = '/etc/iproute2/rt_protos.d/failover.conf'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'failover']
+ failover = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+
+ # Set default values only if we set config
+ if failover.get('route') is not None:
+ failover = conf.merge_defaults(failover, recursive=True)
+
+ return failover
+
+def verify(failover):
+ # bail out early - looks like removal from running config
+ if not failover:
+ return None
+
+ if 'route' not in failover:
+ raise ConfigError(f'Failover "route" is mandatory!')
+
+ for route, route_config in failover['route'].items():
+ if not route_config.get('next_hop'):
+ raise ConfigError(f'Next-hop for "{route}" is mandatory!')
+
+ for next_hop, next_hop_config in route_config.get('next_hop').items():
+ if 'interface' not in next_hop_config:
+ raise ConfigError(f'Interface for route "{route}" next-hop "{next_hop}" is mandatory!')
+
+ if not next_hop_config.get('check'):
+ raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!')
+
+ if 'target' not in next_hop_config['check']:
+ raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!')
+
+ check_type = next_hop_config['check']['type']
+ if check_type == 'tcp' and 'port' not in next_hop_config['check']:
+ raise ConfigError(f'Check port for next-hop "{next_hop}" and type TCP is mandatory!')
+
+ return None
+
+def generate(failover):
+ if not failover:
+ service_conf.unlink(missing_ok=True)
+ return None
+
+ # Add own rt_proto 'failover'
+ # Helps to detect all own routes 'proto failover'
+ with open(rt_proto_failover, 'w') as f:
+ f.write('111 failover\n')
+
+ # Write configuration file
+ conf_json = json.dumps(failover, indent=4)
+ service_conf.write_text(conf_json)
+ render(systemd_service, 'protocols/systemd_vyos_failover_service.j2', failover)
+
+ return None
+
+def apply(failover):
+ if not failover:
+ call(f'systemctl stop {service_name}.service')
+ call('ip route flush protocol failover')
+ else:
+ call('systemctl daemon-reload')
+ call(f'systemctl restart {service_name}.service')
+ call(f'ip route flush protocol failover')
+
+ return None
+
+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/protocols_igmp-proxy.py b/src/conf_mode/protocols_igmp-proxy.py
new file mode 100644
index 0000000..9a07adf
--- /dev/null
+++ b/src/conf_mode/protocols_igmp-proxy.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/etc/igmpproxy.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'igmp-proxy']
+ igmp_proxy = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_defaults=True)
+
+ if conf.exists(['protocols', 'igmp']):
+ igmp_proxy.update({'igmp_configured': ''})
+
+ if conf.exists(['protocols', 'pim']):
+ igmp_proxy.update({'pim_configured': ''})
+
+ return igmp_proxy
+
+def verify(igmp_proxy):
+ # bail out early - looks like removal from running config
+ if not igmp_proxy or 'disable' in igmp_proxy:
+ return None
+
+ if 'igmp_configured' in igmp_proxy or 'pim_configured' in igmp_proxy:
+ raise ConfigError('Can not configure both IGMP proxy and PIM '\
+ 'at the same time')
+
+ # at least two interfaces are required, one upstream and one downstream
+ if 'interface' not in igmp_proxy or len(igmp_proxy['interface']) < 2:
+ raise ConfigError('Must define exactly one upstream and at least one ' \
+ 'downstream interface!')
+
+ upstream = 0
+ for interface, config in igmp_proxy['interface'].items():
+ verify_interface_exists(igmp_proxy, interface)
+ if dict_search('role', config) == 'upstream':
+ upstream += 1
+
+ if upstream == 0:
+ raise ConfigError('At least 1 upstream interface is required!')
+ elif upstream > 1:
+ raise ConfigError('Only 1 upstream interface allowed!')
+
+ return None
+
+def generate(igmp_proxy):
+ # bail out early - looks like removal from running config
+ if not igmp_proxy:
+ return None
+
+ # bail out early - service is disabled, but inform user
+ if 'disable' in igmp_proxy:
+ Warning('IGMP Proxy will be deactivated because it is disabled')
+ return None
+
+ render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy)
+
+ return None
+
+def apply(igmp_proxy):
+ if not igmp_proxy or 'disable' in igmp_proxy:
+ # IGMP Proxy support is removed in the commit
+ call('systemctl stop igmpproxy.service')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ else:
+ call('systemctl restart igmpproxy.service')
+
+ return None
+
+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/protocols_isis.py b/src/conf_mode/protocols_isis.py
new file mode 100644
index 0000000..ba2f3cf
--- /dev/null
+++ b/src/conf_mode/protocols_isis.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_interface_exists
+from vyos.ifconfig import Interface
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'isis']
+
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'isis'] or base_path
+ isis = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Assign the name of our VRF context. This MUST be done before the return
+ # statement below, else on deletion we will delete the default instance
+ # instead of the VRF instance.
+ if vrf: isis['vrf'] = vrf
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ isis['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does no longer exist. this must
+ # be done after retrieving the list of interfaces to be removed.
+ if not conf.exists(base):
+ isis.update({'deleted' : ''})
+ return isis
+
+ # merge in default values
+ isis = conf.merge_defaults(isis, recursive=True)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ isis = dict_merge(tmp, isis)
+
+ return isis
+
+def verify(isis):
+ # bail out early - looks like removal from running config
+ if not isis or 'deleted' in isis:
+ return None
+
+ if 'net' not in isis:
+ raise ConfigError('Network entity is mandatory!')
+
+ # last byte in IS-IS area address must be 0
+ tmp = isis['net'].split('.')
+ if int(tmp[-1]) != 0:
+ raise ConfigError('Last byte of IS-IS network entity title must always be 0!')
+
+ verify_common_route_maps(isis)
+
+ # If interface not set
+ if 'interface' not in isis:
+ raise ConfigError('Interface used for routing updates is mandatory!')
+
+ for interface in isis['interface']:
+ verify_interface_exists(isis, interface)
+ # Interface MTU must be >= configured lsp-mtu
+ mtu = Interface(interface).get_mtu()
+ area_mtu = isis['lsp_mtu']
+ # Recommended maximum PDU size = interface MTU - 3 bytes
+ recom_area_mtu = mtu - 3
+ if mtu < int(area_mtu) or int(area_mtu) > recom_area_mtu:
+ raise ConfigError(f'Interface {interface} has MTU {mtu}, ' \
+ f'current area MTU is {area_mtu}! \n' \
+ f'Recommended area lsp-mtu {recom_area_mtu} or less ' \
+ '(calculated on MTU size).')
+
+ if 'vrf' in isis:
+ # If interface specific options are set, we must ensure that the
+ # interface is bound to our requesting VRF. Due to the VyOS
+ # priorities the interface is bound to the VRF after creation of
+ # the VRF itself, and before any routing protocol is configured.
+ vrf = isis['vrf']
+ tmp = get_interface_config(interface)
+ if 'master' not in tmp or tmp['master'] != vrf:
+ raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
+
+ # If md5 and plaintext-password set at the same time
+ for password in ['area_password', 'domain_password']:
+ if password in isis:
+ if {'md5', 'plaintext_password'} <= set(isis[password]):
+ tmp = password.replace('_', '-')
+ raise ConfigError(f'Can use either md5 or plaintext-password for {tmp}!')
+
+ # If one param from delay set, but not set others
+ if 'spf_delay_ietf' in isis:
+ required_timers = ['holddown', 'init_delay', 'long_delay', 'short_delay', 'time_to_learn']
+ exist_timers = []
+ for elm_timer in required_timers:
+ if elm_timer in isis['spf_delay_ietf']:
+ exist_timers.append(elm_timer)
+
+ exist_timers = set(required_timers).difference(set(exist_timers))
+ if len(exist_timers) > 0:
+ raise ConfigError('All types of spf-delay must be configured. Missing: ' + ', '.join(exist_timers).replace('_', '-'))
+
+ # If Redistribute set, but level don't set
+ if 'redistribute' in isis:
+ proc_level = isis.get('level','').replace('-','_')
+ for afi in ['ipv4', 'ipv6']:
+ if afi not in isis['redistribute']:
+ continue
+
+ for proto, proto_config in isis['redistribute'][afi].items():
+ if 'level_1' not in proto_config and 'level_2' not in proto_config:
+ raise ConfigError(f'Redistribute level-1 or level-2 should be specified in ' \
+ f'"protocols isis redistribute {afi} {proto}"!')
+
+ for redistr_level, redistr_config in proto_config.items():
+ if proc_level and proc_level != 'level_1_2' and proc_level != redistr_level:
+ raise ConfigError(f'"protocols isis redistribute {afi} {proto} {redistr_level}" ' \
+ f'can not be used with \"protocols isis level {proc_level}\"!')
+
+ # Segment routing checks
+ if dict_search('segment_routing.global_block', isis):
+ g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis)
+ g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis)
+
+ # If segment routing global block high or low value is blank, throw error
+ if not (g_low_label_value or g_high_label_value):
+ raise ConfigError('Segment routing global-block requires both low and high value!')
+
+ # If segment routing global block low value is higher than the high value, throw error
+ if int(g_low_label_value) > int(g_high_label_value):
+ raise ConfigError('Segment routing global-block low value must be lower than high value')
+
+ if dict_search('segment_routing.local_block', isis):
+ if dict_search('segment_routing.global_block', isis) == None:
+ raise ConfigError('Segment routing local-block requires global-block to be configured!')
+
+ l_high_label_value = dict_search('segment_routing.local_block.high_label_value', isis)
+ l_low_label_value = dict_search('segment_routing.local_block.low_label_value', isis)
+
+ # If segment routing local-block high or low value is blank, throw error
+ if not (l_low_label_value or l_high_label_value):
+ raise ConfigError('Segment routing local-block requires both high and low value!')
+
+ # If segment routing local-block low value is higher than the high value, throw error
+ if int(l_low_label_value) > int(l_high_label_value):
+ raise ConfigError('Segment routing local-block low value must be lower than high value')
+
+ # local-block most live outside global block
+ global_range = range(int(g_low_label_value), int(g_high_label_value) +1)
+ local_range = range(int(l_low_label_value), int(l_high_label_value) +1)
+
+ # Check for overlapping ranges
+ if list(set(global_range) & set(local_range)):
+ raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\
+ f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!')
+
+ # Check for a blank or invalid value per prefix
+ if dict_search('segment_routing.prefix', isis):
+ for prefix, prefix_config in isis['segment_routing']['prefix'].items():
+ if 'absolute' in prefix_config:
+ if prefix_config['absolute'].get('value') is None:
+ raise ConfigError(f'Segment routing prefix {prefix} absolute value cannot be blank.')
+ elif 'index' in prefix_config:
+ if prefix_config['index'].get('value') is None:
+ raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.')
+
+ # Check for explicit-null and no-php-flag configured at the same time per prefix
+ if dict_search('segment_routing.prefix', isis):
+ for prefix, prefix_config in isis['segment_routing']['prefix'].items():
+ if 'absolute' in prefix_config:
+ if ("explicit_null" in prefix_config['absolute']) and ("no_php_flag" in prefix_config['absolute']):
+ raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\
+ f'and no-php-flag configured at the same time.')
+ elif 'index' in prefix_config:
+ if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']):
+ raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\
+ f'and no-php-flag configured at the same time.')
+
+ # Check for index ranges being larger than the segment routing global block
+ if dict_search('segment_routing.global_block', isis):
+ g_high_label_value = dict_search('segment_routing.global_block.high_label_value', isis)
+ g_low_label_value = dict_search('segment_routing.global_block.low_label_value', isis)
+ g_label_difference = int(g_high_label_value) - int(g_low_label_value)
+ if dict_search('segment_routing.prefix', isis):
+ for prefix, prefix_config in isis['segment_routing']['prefix'].items():
+ if 'index' in prefix_config:
+ index_size = isis['segment_routing']['prefix'][prefix]['index']['value']
+ if int(index_size) > int(g_label_difference):
+ raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\
+ f'index base size larger than the SRGB label base.')
+
+ # Check for LFA tiebreaker index duplication
+ if dict_search('fast_reroute.lfa.local.tiebreaker', isis):
+ comparison_dictionary = {}
+ for item, item_options in isis['fast_reroute']['lfa']['local']['tiebreaker'].items():
+ for index, index_options in item_options.items():
+ for index_value, index_value_options in index_options.items():
+ if index_value not in comparison_dictionary.keys():
+ comparison_dictionary[index_value] = [item]
+ else:
+ comparison_dictionary[index_value].append(item)
+ for index, index_length in comparison_dictionary.items():
+ if int(len(index_length)) > 1:
+ raise ConfigError(f'LFA index {index} cannot have more than one tiebreaker configured.')
+
+ # Check for LFA priority-limit configured multiple times per level
+ if dict_search('fast_reroute.lfa.local.priority_limit', isis):
+ comparison_dictionary = {}
+ for priority, priority_options in isis['fast_reroute']['lfa']['local']['priority_limit'].items():
+ for level, level_options in priority_options.items():
+ if level not in comparison_dictionary.keys():
+ comparison_dictionary[level] = [priority]
+ else:
+ comparison_dictionary[level].append(priority)
+ for level, level_length in comparison_dictionary.items():
+ if int(len(level_length)) > 1:
+ raise ConfigError(f'LFA priority-limit on {level.replace("_", "-")} cannot have more than one priority configured.')
+
+ # Check for LFA remote prefix list configured with more than one list
+ if dict_search('fast_reroute.lfa.remote.prefix_list', isis):
+ if int(len(isis['fast_reroute']['lfa']['remote']['prefix_list'].items())) > 1:
+ raise ConfigError(f'LFA remote prefix-list has more than one configured. Cannot have more than one configured.')
+
+ return None
+
+def generate(isis):
+ if not isis or 'deleted' in isis:
+ return None
+
+ isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.j2', isis)
+ return None
+
+def apply(isis):
+ isis_daemon = 'isisd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # Generate empty helper string which can be ammended to FRR commands, it
+ # will be either empty (default VRF) or contain the "vrf <name" statement
+ vrf = ''
+ if 'vrf' in isis:
+ vrf = ' vrf ' + isis['vrf']
+
+ frr_cfg.load_configuration(isis_daemon)
+ frr_cfg.modify_section(f'^router isis VyOS{vrf}', stop_pattern='^exit', remove_stop_mark=True)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in isis:
+ continue
+ for interface in isis[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'frr_isisd_config' in isis:
+ frr_cfg.add_before(frr.default_add_before, isis['frr_isisd_config'])
+
+ frr_cfg.commit_configuration(isis_daemon)
+
+ return None
+
+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/protocols_mpls.py b/src/conf_mode/protocols_mpls.py
new file mode 100644
index 0000000..ad164db
--- /dev/null
+++ b/src/conf_mode/protocols_mpls.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-2022 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
+
+from sys import exit
+
+from glob import glob
+from vyos.config import Config
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.file import read_file
+from vyos.utils.system import sysctl_write
+from vyos.configverify import verify_interface_exists
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/tmp/ldpd.frr'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'mpls']
+
+ mpls = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+ return mpls
+
+def verify(mpls):
+ # If no config, then just bail out early.
+ if not mpls:
+ return None
+
+ if 'interface' in mpls:
+ for interface in mpls['interface']:
+ verify_interface_exists(mpls, interface)
+
+ # Checks to see if LDP is properly configured
+ if 'ldp' in mpls:
+ # If router ID not defined
+ if 'router_id' not in mpls['ldp']:
+ raise ConfigError('Router ID missing. An LDP router id is mandatory!')
+
+ # If interface not set
+ if 'interface' not in mpls['ldp']:
+ raise ConfigError('LDP interfaces are missing. An LDP interface is mandatory!')
+
+ # If transport addresses are not set
+ if not dict_search('ldp.discovery.transport_ipv4_address', mpls) and \
+ not dict_search('ldp.discovery.transport_ipv6_address', mpls):
+ raise ConfigError('LDP transport address missing!')
+
+ return None
+
+def generate(mpls):
+ # If there's no MPLS config generated, create dictionary key with no value.
+ if not mpls or 'deleted' in mpls:
+ return None
+
+ mpls['frr_ldpd_config'] = render_to_string('frr/ldpd.frr.j2', mpls)
+ return None
+
+def apply(mpls):
+ ldpd_damon = 'ldpd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ frr_cfg.load_configuration(ldpd_damon)
+ frr_cfg.modify_section(f'^mpls ldp', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'frr_ldpd_config' in mpls:
+ frr_cfg.add_before(frr.default_add_before, mpls['frr_ldpd_config'])
+ frr_cfg.commit_configuration(ldpd_damon)
+
+ # Set number of entries in the platform label tables
+ labels = '0'
+ if 'interface' in mpls:
+ labels = '1048575'
+ sysctl_write('net.mpls.platform_labels', labels)
+
+ # Check for changes in global MPLS options
+ if 'parameters' in mpls:
+ # Choose whether to copy IP TTL to MPLS header TTL
+ if 'no_propagate_ttl' in mpls['parameters']:
+ sysctl_write('net.mpls.ip_ttl_propagate', 0)
+ # Choose whether to limit maximum MPLS header TTL
+ if 'maximum_ttl' in mpls['parameters']:
+ ttl = mpls['parameters']['maximum_ttl']
+ sysctl_write('net.mpls.default_ttl', ttl)
+ else:
+ # Set default global MPLS options if not defined.
+ sysctl_write('net.mpls.ip_ttl_propagate', 1)
+ sysctl_write('net.mpls.default_ttl', 255)
+
+ # Enable and disable MPLS processing on interfaces per configuration
+ if 'interface' in mpls:
+ system_interfaces = []
+ # Populate system interfaces list with local MPLS capable interfaces
+ for interface in glob('/proc/sys/net/mpls/conf/*'):
+ system_interfaces.append(os.path.basename(interface))
+ # This is where the comparison is done on if an interface needs to be enabled/disabled.
+ for system_interface in system_interfaces:
+ interface_state = read_file(f'/proc/sys/net/mpls/conf/{system_interface}/input')
+ if '1' in interface_state:
+ if system_interface not in mpls['interface']:
+ system_interface = system_interface.replace('.', '/')
+ sysctl_write(f'net.mpls.conf.{system_interface}.input', 0)
+ elif '0' in interface_state:
+ if system_interface in mpls['interface']:
+ system_interface = system_interface.replace('.', '/')
+ sysctl_write(f'net.mpls.conf.{system_interface}.input', 1)
+ else:
+ system_interfaces = []
+ # If MPLS interfaces are not configured, set MPLS processing disabled
+ for interface in glob('/proc/sys/net/mpls/conf/*'):
+ system_interfaces.append(os.path.basename(interface))
+ for system_interface in system_interfaces:
+ system_interface = system_interface.replace('.', '/')
+ sysctl_write(f'net.mpls.conf.{system_interface}.input', 0)
+
+ return None
+
+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/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py
new file mode 100644
index 0000000..0bd68b7
--- /dev/null
+++ b/src/conf_mode/protocols_nhrp.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from vyos.config import Config
+from vyos.configdict import node_changed
+from vyos.template import render
+from vyos.utils.process import run
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+opennhrp_conf = '/run/opennhrp/opennhrp.conf'
+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)
+
+ 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']:
+ raise ConfigError(f'Tunnel interface "{name}" does not exist')
+
+ tunnel_conf = nhrp['if_tunnel'][name]
+
+ if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre':
+ raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel')
+
+ 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:
+ 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}')
+ 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)
+ return None
+
+def apply(nhrp):
+ 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')
+
+ return None
+
+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/protocols_openfabric.py b/src/conf_mode/protocols_openfabric.py
new file mode 100644
index 0000000..8e8c50c
--- /dev/null
+++ b/src/conf_mode/protocols_openfabric.py
@@ -0,0 +1,145 @@
+#!/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/>.
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base_path = ['protocols', 'openfabric']
+
+ openfabric = conf.get_config_dict(base_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # Remove per domain MPLS configuration - get a list of all changed Openfabric domains
+ # (removed and added) so that they will be properly rendered for the FRR config.
+ openfabric['domains_all'] = list(conf.list_nodes(' '.join(base_path) + f' domain') +
+ node_changed(conf, base_path + ['domain']))
+
+ # Get a list of all interfaces
+ openfabric['interfaces_all'] = []
+ for domain in openfabric['domains_all']:
+ interfaces_modified = list(node_changed(conf, base_path + ['domain', domain, 'interface']) +
+ conf.list_nodes(' '.join(base_path) + f' domain {domain} interface'))
+ openfabric['interfaces_all'].extend(interfaces_modified)
+
+ if not conf.exists(base_path):
+ openfabric.update({'deleted': ''})
+
+ return openfabric
+
+def verify(openfabric):
+ # bail out early - looks like removal from running config
+ if not openfabric or 'deleted' in openfabric:
+ return None
+
+ if 'net' not in openfabric:
+ raise ConfigError('Network entity is mandatory!')
+
+ # last byte in OpenFabric area address must be 0
+ tmp = openfabric['net'].split('.')
+ if int(tmp[-1]) != 0:
+ raise ConfigError('Last byte of OpenFabric network entity title must always be 0!')
+
+ if 'domain' not in openfabric:
+ raise ConfigError('OpenFabric domain name is mandatory!')
+
+ interfaces_used = []
+
+ for domain, domain_config in openfabric['domain'].items():
+ # If interface not set
+ if 'interface' not in domain_config:
+ raise ConfigError(f'Interface used for routing updates in OpenFabric "{domain}" is mandatory!')
+
+ for iface, iface_config in domain_config['interface'].items():
+ verify_interface_exists(openfabric, iface)
+
+ # interface can be activated only on one OpenFabric instance
+ if iface in interfaces_used:
+ raise ConfigError(f'Interface {iface} is already used in different OpenFabric instance!')
+
+ if 'address_family' not in iface_config or len(iface_config['address_family']) < 1:
+ raise ConfigError(f'Need to specify address family for the interface "{iface}"!')
+
+ # If md5 and plaintext-password set at the same time
+ if 'password' in iface_config:
+ if {'md5', 'plaintext_password'} <= set(iface_config['password']):
+ raise ConfigError(f'Can use either md5 or plaintext-password for password for the interface!')
+
+ if iface == 'lo' and 'passive' not in iface_config:
+ Warning('For loopback interface passive mode is implied!')
+
+ interfaces_used.append(iface)
+
+ # If md5 and plaintext-password set at the same time
+ password = 'domain_password'
+ if password in domain_config:
+ if {'md5', 'plaintext_password'} <= set(domain_config[password]):
+ raise ConfigError(f'Can use either md5 or plaintext-password for domain-password!')
+
+ return None
+
+def generate(openfabric):
+ if not openfabric or 'deleted' in openfabric:
+ return None
+
+ openfabric['frr_fabricd_config'] = render_to_string('frr/fabricd.frr.j2', openfabric)
+ return None
+
+def apply(openfabric):
+ openfabric_daemon = 'fabricd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ frr_cfg.load_configuration(openfabric_daemon)
+ for domain in openfabric['domains_all']:
+ frr_cfg.modify_section(f'^router openfabric {domain}', stop_pattern='^exit', remove_stop_mark=True)
+
+ for interface in openfabric['interfaces_all']:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'frr_fabricd_config' in openfabric:
+ frr_cfg.add_before(frr.default_add_before, openfabric['frr_fabricd_config'])
+
+ frr_cfg.commit_configuration(openfabric_daemon)
+
+ return None
+
+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/protocols_ospf.py b/src/conf_mode/protocols_ospf.py
new file mode 100644
index 0000000..7347c4f
--- /dev/null
+++ b/src/conf_mode/protocols_ospf.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_route_map
+from vyos.configverify import verify_interface_exists
+from vyos.configverify import verify_access_list
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'ospf']
+
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospf'] or base_path
+ ospf = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+
+ # Assign the name of our VRF context. This MUST be done before the return
+ # statement below, else on deletion we will delete the default instance
+ # instead of the VRF instance.
+ if vrf: ospf['vrf'] = vrf
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ ospf['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does no longer exist. this must
+ # be done after retrieving the list of interfaces to be removed.
+ if not conf.exists(base):
+ ospf.update({'deleted' : ''})
+ return ospf
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**ospf.kwargs, recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: default-information
+ # originate comes with a default metric-type of 2, which will enable the
+ # entire default-information originate tree, even when not set via CLI so we
+ # need to check this first and probably drop that key.
+ if dict_search('default_information.originate', ospf) is None:
+ del default_values['default_information']
+ if 'mpls_te' not in ospf:
+ del default_values['mpls_te']
+ if 'graceful_restart' not in ospf:
+ del default_values['graceful_restart']
+ for area_num in default_values.get('area', []):
+ if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None:
+ del default_values['area'][area_num]['area_type']['nssa']
+
+ for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']:
+ if dict_search(f'redistribute.{protocol}', ospf) is None:
+ del default_values['redistribute'][protocol]
+ if not bool(default_values['redistribute']):
+ del default_values['redistribute']
+
+ for interface in ospf.get('interface', []):
+ # We need to reload the defaults on every pass b/c of
+ # hello-multiplier dependency on dead-interval
+ # If hello-multiplier is set, we need to remove the default from
+ # dead-interval.
+ if 'hello_multiplier' in ospf['interface'][interface]:
+ del default_values['interface'][interface]['dead_interval']
+
+ ospf = config_dict_merge(default_values, ospf)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ ospf = dict_merge(tmp, ospf)
+
+ return ospf
+
+def verify(ospf):
+ if not ospf:
+ return None
+
+ verify_common_route_maps(ospf)
+
+ # As we can have a default-information route-map, we need to validate it!
+ route_map_name = dict_search('default_information.originate.route_map', ospf)
+ if route_map_name: verify_route_map(route_map_name, ospf)
+
+ # Validate if configured Access-list exists
+ if 'area' in ospf:
+ networks = []
+ for area, area_config in ospf['area'].items():
+ if 'import_list' in area_config:
+ acl_import = area_config['import_list']
+ if acl_import: verify_access_list(acl_import, ospf)
+ if 'export_list' in area_config:
+ acl_export = area_config['export_list']
+ if acl_export: verify_access_list(acl_export, ospf)
+
+ if 'network' in area_config:
+ for network in area_config['network']:
+ if network in networks:
+ raise ConfigError(f'Network "{network}" already defined in different area!')
+ networks.append(network)
+
+ if 'interface' in ospf:
+ for interface, interface_config in ospf['interface'].items():
+ verify_interface_exists(ospf, interface)
+ # One can not use dead-interval and hello-multiplier at the same
+ # time. FRR will only activate the last option set via CLI.
+ if {'hello_multiplier', 'dead_interval'} <= set(interface_config):
+ raise ConfigError(f'Can not use hello-multiplier and dead-interval ' \
+ f'concurrently for {interface}!')
+
+ # One can not use the "network <prefix> area <id>" command and an
+ # per interface area assignment at the same time. FRR will error
+ # out using: "Please remove all network commands first."
+ if 'area' in ospf and 'area' in interface_config:
+ for area, area_config in ospf['area'].items():
+ if 'network' in area_config:
+ raise ConfigError('Can not use OSPF interface area and area ' \
+ 'network configuration at the same time!')
+
+ # If interface specific options are set, we must ensure that the
+ # interface is bound to our requesting VRF. Due to the VyOS
+ # priorities the interface is bound to the VRF after creation of
+ # the VRF itself, and before any routing protocol is configured.
+ if 'vrf' in ospf:
+ vrf = ospf['vrf']
+ tmp = get_interface_config(interface)
+ if 'master' not in tmp or tmp['master'] != vrf:
+ raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
+
+ # Segment routing checks
+ if dict_search('segment_routing.global_block', ospf):
+ g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf)
+ g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf)
+
+ # If segment routing global block high or low value is blank, throw error
+ if not (g_low_label_value or g_high_label_value):
+ raise ConfigError('Segment routing global-block requires both low and high value!')
+
+ # If segment routing global block low value is higher than the high value, throw error
+ if int(g_low_label_value) > int(g_high_label_value):
+ raise ConfigError('Segment routing global-block low value must be lower than high value')
+
+ if dict_search('segment_routing.local_block', ospf):
+ if dict_search('segment_routing.global_block', ospf) == None:
+ raise ConfigError('Segment routing local-block requires global-block to be configured!')
+
+ l_high_label_value = dict_search('segment_routing.local_block.high_label_value', ospf)
+ l_low_label_value = dict_search('segment_routing.local_block.low_label_value', ospf)
+
+ # If segment routing local-block high or low value is blank, throw error
+ if not (l_low_label_value or l_high_label_value):
+ raise ConfigError('Segment routing local-block requires both high and low value!')
+
+ # If segment routing local-block low value is higher than the high value, throw error
+ if int(l_low_label_value) > int(l_high_label_value):
+ raise ConfigError('Segment routing local-block low value must be lower than high value')
+
+ # local-block most live outside global block
+ global_range = range(int(g_low_label_value), int(g_high_label_value) +1)
+ local_range = range(int(l_low_label_value), int(l_high_label_value) +1)
+
+ # Check for overlapping ranges
+ if list(set(global_range) & set(local_range)):
+ raise ConfigError(f'Segment-Routing Global Block ({g_low_label_value}/{g_high_label_value}) '\
+ f'conflicts with Local Block ({l_low_label_value}/{l_high_label_value})!')
+
+ # Check for a blank or invalid value per prefix
+ if dict_search('segment_routing.prefix', ospf):
+ for prefix, prefix_config in ospf['segment_routing']['prefix'].items():
+ if 'index' in prefix_config:
+ if prefix_config['index'].get('value') is None:
+ raise ConfigError(f'Segment routing prefix {prefix} index value cannot be blank.')
+
+ # Check for explicit-null and no-php-flag configured at the same time per prefix
+ if dict_search('segment_routing.prefix', ospf):
+ for prefix, prefix_config in ospf['segment_routing']['prefix'].items():
+ if 'index' in prefix_config:
+ if ("explicit_null" in prefix_config['index']) and ("no_php_flag" in prefix_config['index']):
+ raise ConfigError(f'Segment routing prefix {prefix} cannot have both explicit-null '\
+ f'and no-php-flag configured at the same time.')
+
+ # Check for index ranges being larger than the segment routing global block
+ if dict_search('segment_routing.global_block', ospf):
+ g_high_label_value = dict_search('segment_routing.global_block.high_label_value', ospf)
+ g_low_label_value = dict_search('segment_routing.global_block.low_label_value', ospf)
+ g_label_difference = int(g_high_label_value) - int(g_low_label_value)
+ if dict_search('segment_routing.prefix', ospf):
+ for prefix, prefix_config in ospf['segment_routing']['prefix'].items():
+ if 'index' in prefix_config:
+ index_size = ospf['segment_routing']['prefix'][prefix]['index']['value']
+ if int(index_size) > int(g_label_difference):
+ raise ConfigError(f'Segment routing prefix {prefix} cannot have an '\
+ f'index base size larger than the SRGB label base.')
+
+ # Check route summarisation
+ if 'summary_address' in ospf:
+ for prefix, prefix_options in ospf['summary_address'].items():
+ if {'tag', 'no_advertise'} <= set(prefix_options):
+ raise ConfigError(f'Can not set both "tag" and "no-advertise" for Type-5 '\
+ f'and Type-7 route summarisation of "{prefix}"!')
+
+ return None
+
+def generate(ospf):
+ if not ospf or 'deleted' in ospf:
+ return None
+
+ ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.j2', ospf)
+ return None
+
+def apply(ospf):
+ ospf_daemon = 'ospfd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # Generate empty helper string which can be ammended to FRR commands, it
+ # will be either empty (default VRF) or contain the "vrf <name" statement
+ vrf = ''
+ if 'vrf' in ospf:
+ vrf = ' vrf ' + ospf['vrf']
+
+ frr_cfg.load_configuration(ospf_daemon)
+ frr_cfg.modify_section(f'^router ospf{vrf}', stop_pattern='^exit', remove_stop_mark=True)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in ospf:
+ continue
+ for interface in ospf[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'frr_ospfd_config' in ospf:
+ frr_cfg.add_before(frr.default_add_before, ospf['frr_ospfd_config'])
+
+ frr_cfg.commit_configuration(ospf_daemon)
+
+ return None
+
+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/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py
new file mode 100644
index 0000000..60c2a9b
--- /dev/null
+++ b/src/conf_mode/protocols_ospfv3.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_route_map
+from vyos.configverify import verify_interface_exists
+from vyos.template import render_to_string
+from vyos.ifconfig import Interface
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'ospfv3']
+
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospfv3'] or base_path
+ ospfv3 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ # Assign the name of our VRF context. This MUST be done before the return
+ # statement below, else on deletion we will delete the default instance
+ # instead of the VRF instance.
+ if vrf: ospfv3['vrf'] = vrf
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ ospfv3['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does no longer exist. this must
+ # be done after retrieving the list of interfaces to be removed.
+ if not conf.exists(base):
+ ospfv3.update({'deleted' : ''})
+ return ospfv3
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**ospfv3.kwargs,
+ recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: default-information
+ # originate comes with a default metric-type of 2, which will enable the
+ # entire default-information originate tree, even when not set via CLI so we
+ # need to check this first and probably drop that key.
+ if dict_search('default_information.originate', ospfv3) is None:
+ del default_values['default_information']
+ if 'graceful_restart' not in ospfv3:
+ del default_values['graceful_restart']
+
+ for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'ripng', 'static']:
+ if dict_search(f'redistribute.{protocol}', ospfv3) is None:
+ del default_values['redistribute'][protocol]
+ if not bool(default_values['redistribute']):
+ del default_values['redistribute']
+
+ default_values.pop('interface', {})
+
+ # merge in remaining default values
+ ospfv3 = config_dict_merge(default_values, ospfv3)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ ospfv3 = dict_merge(tmp, ospfv3)
+
+ return ospfv3
+
+def verify(ospfv3):
+ if not ospfv3:
+ return None
+
+ verify_common_route_maps(ospfv3)
+
+ # As we can have a default-information route-map, we need to validate it!
+ route_map_name = dict_search('default_information.originate.route_map', ospfv3)
+ if route_map_name: verify_route_map(route_map_name, ospfv3)
+
+ if 'area' in ospfv3:
+ for area, area_config in ospfv3['area'].items():
+ if 'area_type' in area_config:
+ if len(area_config['area_type']) > 1:
+ raise ConfigError(f'Can only configure one area-type for OSPFv3 area "{area}"!')
+ if 'range' in area_config:
+ for range, range_config in area_config['range'].items():
+ if {'not_advertise', 'advertise'} <= range_config.keys():
+ raise ConfigError(f'"not-advertise" and "advertise" for "range {range}" cannot be both configured at the same time!')
+
+ if 'interface' in ospfv3:
+ for interface, interface_config in ospfv3['interface'].items():
+ verify_interface_exists(ospfv3, interface)
+ if 'ifmtu' in interface_config:
+ mtu = Interface(interface).get_mtu()
+ if int(interface_config['ifmtu']) > int(mtu):
+ raise ConfigError(f'OSPFv3 ifmtu can not exceed physical MTU of "{mtu}"')
+
+ # If interface specific options are set, we must ensure that the
+ # interface is bound to our requesting VRF. Due to the VyOS
+ # priorities the interface is bound to the VRF after creation of
+ # the VRF itself, and before any routing protocol is configured.
+ if 'vrf' in ospfv3:
+ vrf = ospfv3['vrf']
+ tmp = get_interface_config(interface)
+ if 'master' not in tmp or tmp['master'] != vrf:
+ raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
+
+ return None
+
+def generate(ospfv3):
+ if not ospfv3 or 'deleted' in ospfv3:
+ return None
+
+ ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.j2', ospfv3)
+ return None
+
+def apply(ospfv3):
+ ospf6_daemon = 'ospf6d'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # Generate empty helper string which can be ammended to FRR commands, it
+ # will be either empty (default VRF) or contain the "vrf <name" statement
+ vrf = ''
+ if 'vrf' in ospfv3:
+ vrf = ' vrf ' + ospfv3['vrf']
+
+ frr_cfg.load_configuration(ospf6_daemon)
+ frr_cfg.modify_section(f'^router ospf6{vrf}', stop_pattern='^exit', remove_stop_mark=True)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in ospfv3:
+ continue
+ for interface in ospfv3[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'new_frr_config' in ospfv3:
+ frr_cfg.add_before(frr.default_add_before, ospfv3['new_frr_config'])
+
+ frr_cfg.commit_configuration(ospf6_daemon)
+
+ return None
+
+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/protocols_pim.py b/src/conf_mode/protocols_pim.py
new file mode 100644
index 0000000..79294a1
--- /dev/null
+++ b/src/conf_mode/protocols_pim.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+
+from ipaddress import IPv4Address
+from ipaddress import IPv4Network
+from signal import SIGTERM
+from sys import exit
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.utils.process import process_named_running
+from vyos.utils.process import call
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+RESERVED_MC_NET = '224.0.0.0/24'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'pim']
+
+ pim = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ # We can not run both IGMP proxy and PIM at the same time - get IGMP
+ # proxy status
+ if conf.exists(['protocols', 'igmp-proxy']):
+ pim.update({'igmp_proxy_enabled' : {}})
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ pim['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does no longer exist. this must
+ # be done after retrieving the list of interfaces to be removed.
+ if not conf.exists(base):
+ pim.update({'deleted' : ''})
+ return pim
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**pim.kwargs, recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: default-information
+ # originate comes with a default metric-type of 2, which will enable the
+ # entire default-information originate tree, even when not set via CLI so we
+ # need to check this first and probably drop that key.
+ for interface in pim.get('interface', []):
+ # We need to reload the defaults on every pass b/c of
+ # hello-multiplier dependency on dead-interval
+ # If hello-multiplier is set, we need to remove the default from
+ # dead-interval.
+ if 'igmp' not in pim['interface'][interface]:
+ del default_values['interface'][interface]['igmp']
+
+ pim = config_dict_merge(default_values, pim)
+ return pim
+
+def verify(pim):
+ if not pim or 'deleted' in pim:
+ return None
+
+ if 'igmp_proxy_enabled' in pim:
+ raise ConfigError('IGMP proxy and PIM cannot be configured at the same time!')
+
+ if 'interface' not in pim:
+ raise ConfigError('PIM require defined interfaces!')
+
+ for interface, interface_config in pim['interface'].items():
+ verify_interface_exists(pim, interface)
+
+ # Check join group in reserved net
+ if 'igmp' in interface_config and 'join' in interface_config['igmp']:
+ for join_addr in interface_config['igmp']['join']:
+ if IPv4Address(join_addr) in IPv4Network(RESERVED_MC_NET):
+ raise ConfigError(f'Groups within {RESERVED_MC_NET} are reserved and cannot be joined!')
+
+ if 'rp' in pim:
+ if 'address' not in pim['rp']:
+ raise ConfigError('PIM rendezvous point needs to be defined!')
+
+ # Check unique multicast groups
+ unique = []
+ pim_base_error = 'PIM rendezvous point group'
+ for address, address_config in pim['rp']['address'].items():
+ if 'group' not in address_config:
+ raise ConfigError(f'{pim_base_error} should be defined for "{address}"!')
+
+ # Check if it is a multicast group
+ for gr_addr in address_config['group']:
+ if not IPv4Network(gr_addr).is_multicast:
+ raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!')
+ if gr_addr in unique:
+ raise ConfigError(f'{pim_base_error} must be unique!')
+ unique.append(gr_addr)
+
+def generate(pim):
+ if not pim or 'deleted' in pim:
+ return None
+ pim['frr_pimd_config'] = render_to_string('frr/pimd.frr.j2', pim)
+ return None
+
+def apply(pim):
+ pim_daemon = 'pimd'
+ pim_pid = process_named_running(pim_daemon)
+
+ if not pim or 'deleted' in pim:
+ if 'deleted' in pim:
+ os.kill(int(pim_pid), SIGTERM)
+
+ return None
+
+ if not pim_pid:
+ call('/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1')
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ frr_cfg.load_configuration(pim_daemon)
+ frr_cfg.modify_section(f'^ip pim')
+ frr_cfg.modify_section(f'^ip igmp')
+
+ for key in ['interface', 'interface_removed']:
+ if key not in pim:
+ continue
+ for interface in pim[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'frr_pimd_config' in pim:
+ frr_cfg.add_before(frr.default_add_before, pim['frr_pimd_config'])
+ frr_cfg.commit_configuration(pim_daemon)
+ return None
+
+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/protocols_pim6.py b/src/conf_mode/protocols_pim6.py
new file mode 100644
index 0000000..581ffe2
--- /dev/null
+++ b/src/conf_mode/protocols_pim6.py
@@ -0,0 +1,133 @@
+#!/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/>.
+
+from ipaddress import IPv6Address
+from ipaddress import IPv6Network
+from sys import exit
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'pim6']
+ pim6 = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, with_recursive_defaults=True)
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ pim6['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does no longer exist. this must
+ # be done after retrieving the list of interfaces to be removed.
+ if not conf.exists(base):
+ pim6.update({'deleted' : ''})
+ return pim6
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**pim6.kwargs, recursive=True)
+
+ pim6 = config_dict_merge(default_values, pim6)
+ return pim6
+
+def verify(pim6):
+ if not pim6 or 'deleted' in pim6:
+ return
+
+ for interface, interface_config in pim6.get('interface', {}).items():
+ verify_interface_exists(pim6, interface)
+ if 'mld' in interface_config:
+ mld = interface_config['mld']
+ for group in mld.get('join', {}).keys():
+ # Validate multicast group address
+ if not IPv6Address(group).is_multicast:
+ raise ConfigError(f"{group} is not a multicast group")
+
+ if 'rp' in pim6:
+ if 'address' not in pim6['rp']:
+ raise ConfigError('PIM6 rendezvous point needs to be defined!')
+
+ # Check unique multicast groups
+ unique = []
+ pim_base_error = 'PIM6 rendezvous point group'
+
+ if {'address', 'prefix-list6'} <= set(pim6['rp']):
+ raise ConfigError(f'{pim_base_error} supports either address or a prefix-list!')
+
+ for address, address_config in pim6['rp']['address'].items():
+ if 'group' not in address_config:
+ raise ConfigError(f'{pim_base_error} should be defined for "{address}"!')
+
+ # Check if it is a multicast group
+ for gr_addr in address_config['group']:
+ if not IPv6Network(gr_addr).is_multicast:
+ raise ConfigError(f'{pim_base_error} "{gr_addr}" is not a multicast group!')
+ if gr_addr in unique:
+ raise ConfigError(f'{pim_base_error} must be unique!')
+ unique.append(gr_addr)
+
+def generate(pim6):
+ if not pim6 or 'deleted' in pim6:
+ return
+ pim6['new_frr_config'] = render_to_string('frr/pim6d.frr.j2', pim6)
+ return None
+
+def apply(pim6):
+ if pim6 is None:
+ return
+
+ pim6_daemon = 'pim6d'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ frr_cfg.load_configuration(pim6_daemon)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in pim6:
+ continue
+ for interface in pim6[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'new_frr_config' in pim6:
+ frr_cfg.add_before(frr.default_add_before, pim6['new_frr_config'])
+ frr_cfg.commit_configuration(pim6_daemon)
+ return None
+
+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/protocols_rip.py b/src/conf_mode/protocols_rip.py
new file mode 100644
index 0000000..9afac54
--- /dev/null
+++ b/src/conf_mode/protocols_rip.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_access_list
+from vyos.configverify import verify_prefix_list
+from vyos.utils.dict import dict_search
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'rip']
+ rip = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ rip['interface_removed'] = list(interfaces_removed)
+
+ # Bail out early if configuration tree does not exist
+ if not conf.exists(base):
+ rip.update({'deleted' : ''})
+ return rip
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ rip = conf.merge_defaults(rip, recursive=True)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ rip = dict_merge(tmp, rip)
+
+ return rip
+
+def verify(rip):
+ if not rip:
+ return None
+
+ verify_common_route_maps(rip)
+
+ acl_in = dict_search('distribute_list.access_list.in', rip)
+ if acl_in: verify_access_list(acl_in, rip)
+
+ acl_out = dict_search('distribute_list.access_list.out', rip)
+ if acl_out: verify_access_list(acl_out, rip)
+
+ prefix_list_in = dict_search('distribute_list.prefix-list.in', rip)
+ if prefix_list_in: verify_prefix_list(prefix_list_in, rip)
+
+ prefix_list_out = dict_search('distribute_list.prefix_list.out', rip)
+ if prefix_list_out: verify_prefix_list(prefix_list_out, rip)
+
+ if 'interface' in rip:
+ for interface, interface_options in rip['interface'].items():
+ if 'authentication' in interface_options:
+ if {'md5', 'plaintext_password'} <= set(interface_options['authentication']):
+ raise ConfigError('Can not use both md5 and plaintext-password at the same time!')
+ if 'split_horizon' in interface_options:
+ if {'disable', 'poison_reverse'} <= set(interface_options['split_horizon']):
+ raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \
+ f'with "split-horizon disable" for "{interface}"!')
+
+def generate(rip):
+ if not rip or 'deleted' in rip:
+ return None
+
+ rip['new_frr_config'] = render_to_string('frr/ripd.frr.j2', rip)
+ return None
+
+def apply(rip):
+ rip_daemon = 'ripd'
+ zebra_daemon = 'zebra'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section('^ip protocol rip route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ frr_cfg.load_configuration(rip_daemon)
+ frr_cfg.modify_section('^key chain \S+', stop_pattern='^exit', remove_stop_mark=True)
+ frr_cfg.modify_section('^router rip', stop_pattern='^exit', remove_stop_mark=True)
+
+ for key in ['interface', 'interface_removed']:
+ if key not in rip:
+ continue
+ for interface in rip[key]:
+ frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
+
+ if 'new_frr_config' in rip:
+ frr_cfg.add_before(frr.default_add_before, rip['new_frr_config'])
+ frr_cfg.commit_configuration(rip_daemon)
+
+ return None
+
+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/protocols_ripng.py b/src/conf_mode/protocols_ripng.py
new file mode 100644
index 0000000..23416ff
--- /dev/null
+++ b/src/conf_mode/protocols_ripng.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_access_list
+from vyos.configverify import verify_prefix_list
+from vyos.utils.dict import dict_search
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'ripng']
+ ripng = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ # Bail out early if configuration tree does not exist
+ if not conf.exists(base):
+ return ripng
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ ripng = conf.merge_defaults(ripng, recursive=True)
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ ripng = dict_merge(tmp, ripng)
+
+ return ripng
+
+def verify(ripng):
+ if not ripng:
+ return None
+
+ verify_common_route_maps(ripng)
+
+ acl_in = dict_search('distribute_list.access_list.in', ripng)
+ if acl_in: verify_access_list(acl_in, ripng, version='6')
+
+ acl_out = dict_search('distribute_list.access_list.out', ripng)
+ if acl_out: verify_access_list(acl_out, ripng, version='6')
+
+ prefix_list_in = dict_search('distribute_list.prefix_list.in', ripng)
+ if prefix_list_in: verify_prefix_list(prefix_list_in, ripng, version='6')
+
+ prefix_list_out = dict_search('distribute_list.prefix_list.out', ripng)
+ if prefix_list_out: verify_prefix_list(prefix_list_out, ripng, version='6')
+
+ if 'interface' in ripng:
+ for interface, interface_options in ripng['interface'].items():
+ if 'authentication' in interface_options:
+ if {'md5', 'plaintext_password'} <= set(interface_options['authentication']):
+ raise ConfigError('Can not use both md5 and plaintext-password at the same time!')
+ if 'split_horizon' in interface_options:
+ if {'disable', 'poison_reverse'} <= set(interface_options['split_horizon']):
+ raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \
+ f'with "split-horizon disable" for "{interface}"!')
+
+def generate(ripng):
+ if not ripng:
+ ripng['new_frr_config'] = ''
+ return None
+
+ ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.j2', ripng)
+ return None
+
+def apply(ripng):
+ ripng_daemon = 'ripngd'
+ zebra_daemon = 'zebra'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section('^ipv6 protocol ripng route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ frr_cfg.load_configuration(ripng_daemon)
+ frr_cfg.modify_section('key chain \S+', stop_pattern='^exit', remove_stop_mark=True)
+ frr_cfg.modify_section('interface \S+', stop_pattern='^exit', remove_stop_mark=True)
+ frr_cfg.modify_section('^router ripng', stop_pattern='^exit', remove_stop_mark=True)
+ if 'new_frr_config' in ripng:
+ frr_cfg.add_before(frr.default_add_before, ripng['new_frr_config'])
+ frr_cfg.commit_configuration(ripng_daemon)
+
+ return None
+
+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/protocols_rpki.py b/src/conf_mode/protocols_rpki.py
new file mode 100644
index 0000000..a59ecf3
--- /dev/null
+++ b/src/conf_mode/protocols_rpki.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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
+
+from glob import glob
+from sys import exit
+
+from vyos.config import Config
+from vyos.pki import wrap_openssh_public_key
+from vyos.pki import wrap_openssh_private_key
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search_args
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+rpki_ssh_key_base = '/run/frr/id_rpki'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['protocols', 'rpki']
+
+ rpki = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, with_pki=True)
+ # Bail out early if configuration tree does not exist
+ if not conf.exists(base):
+ rpki.update({'deleted' : ''})
+ return rpki
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ rpki = conf.merge_defaults(rpki, recursive=True)
+
+ return rpki
+
+def verify(rpki):
+ if not rpki:
+ return None
+
+ if 'cache' in rpki:
+ preferences = []
+ for peer, peer_config in rpki['cache'].items():
+ for mandatory in ['port', 'preference']:
+ if mandatory not in peer_config:
+ raise ConfigError(f'RPKI cache "{peer}" {mandatory} must be defined!')
+
+ if 'preference' in peer_config:
+ preference = peer_config['preference']
+ if preference in preferences:
+ raise ConfigError(f'RPKI cache with preference {preference} already configured!')
+ preferences.append(preference)
+
+ if 'ssh' in peer_config:
+ if 'username' not in peer_config['ssh']:
+ raise ConfigError('RPKI+SSH requires username to be defined!')
+
+ if 'key' not in peer_config['ssh'] or 'openssh' not in rpki['pki']:
+ raise ConfigError('RPKI+SSH requires key to be defined!')
+
+ if peer_config['ssh']['key'] not in rpki['pki']['openssh']:
+ raise ConfigError('RPKI+SSH key not found on PKI subsystem!')
+
+ return None
+
+def generate(rpki):
+ for key in glob(f'{rpki_ssh_key_base}*'):
+ os.unlink(key)
+
+ if not rpki:
+ return
+
+ if 'cache' in rpki:
+ for cache, cache_config in rpki['cache'].items():
+ if 'ssh' in cache_config:
+ key_name = cache_config['ssh']['key']
+ public_key_data = dict_search_args(rpki['pki'], 'openssh', key_name, 'public', 'key')
+ public_key_type = dict_search_args(rpki['pki'], 'openssh', key_name, 'public', 'type')
+ private_key_data = dict_search_args(rpki['pki'], 'openssh', key_name, 'private', 'key')
+
+ cache_config['ssh']['public_key_file'] = f'{rpki_ssh_key_base}_{cache}.pub'
+ cache_config['ssh']['private_key_file'] = f'{rpki_ssh_key_base}_{cache}'
+
+ write_file(cache_config['ssh']['public_key_file'], wrap_openssh_public_key(public_key_data, public_key_type))
+ write_file(cache_config['ssh']['private_key_file'], wrap_openssh_private_key(private_key_data))
+
+ rpki['new_frr_config'] = render_to_string('frr/rpki.frr.j2', rpki)
+
+ return None
+
+def apply(rpki):
+ bgp_daemon = 'bgpd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+ frr_cfg.load_configuration(bgp_daemon)
+ frr_cfg.modify_section('^rpki', stop_pattern='^exit', remove_stop_mark=True)
+ if 'new_frr_config' in rpki:
+ frr_cfg.add_before(frr.default_add_before, rpki['new_frr_config'])
+
+ frr_cfg.commit_configuration(bgp_daemon)
+ return None
+
+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/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py
new file mode 100644
index 0000000..b36c2ca
--- /dev/null
+++ b/src/conf_mode/protocols_segment-routing.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import node_changed
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.system import sysctl_write
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'segment-routing']
+ sr = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+
+ # FRR has VRF support for different routing daemons. As interfaces belong
+ # to VRFs - or the global VRF, we need to check for changed interfaces so
+ # that they will be properly rendered for the FRR config. Also this eases
+ # removal of interfaces from the running configuration.
+ interfaces_removed = node_changed(conf, base + ['interface'])
+ if interfaces_removed:
+ sr['interface_removed'] = list(interfaces_removed)
+
+ import pprint
+ pprint.pprint(sr)
+ return sr
+
+def verify(sr):
+ if 'srv6' in sr:
+ srv6_enable = False
+ if 'interface' in sr:
+ for interface, interface_config in sr['interface'].items():
+ if 'srv6' in interface_config:
+ srv6_enable = True
+ break
+ if not srv6_enable:
+ raise ConfigError('SRv6 should be enabled on at least one interface!')
+ return None
+
+def generate(sr):
+ if not sr:
+ return None
+
+ sr['new_frr_config'] = render_to_string('frr/zebra.segment_routing.frr.j2', sr)
+ return None
+
+def apply(sr):
+ zebra_daemon = 'zebra'
+
+ if 'interface_removed' in sr:
+ for interface in sr['interface_removed']:
+ # Disable processing of IPv6-SR packets
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0')
+
+ if 'interface' in sr:
+ for interface, interface_config in sr['interface'].items():
+ # Accept or drop SR-enabled IPv6 packets on this interface
+ if 'srv6' in interface_config:
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '1')
+ # Define HMAC policy for ingress SR-enabled packets on this interface
+ # It's a redundant check as HMAC has a default value - but better safe
+ # then sorry
+ tmp = dict_search('srv6.hmac', interface_config)
+ if tmp == 'accept':
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '0')
+ elif tmp == 'drop':
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1')
+ elif tmp == 'ignore':
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1')
+ else:
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0')
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(r'^segment-routing')
+ if 'new_frr_config' in sr:
+ frr_cfg.add_before(frr.default_add_before, sr['new_frr_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ return None
+
+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/protocols_static.py b/src/conf_mode/protocols_static.py
new file mode 100644
index 0000000..a237321
--- /dev/null
+++ b/src/conf_mode/protocols_static.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+from sys import argv
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import get_dhcp_interfaces
+from vyos.configdict import get_pppoe_interfaces
+from vyos.configverify import verify_common_route_maps
+from vyos.configverify import verify_vrf
+from vyos.template import render
+from vyos.template import render_to_string
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ vrf = None
+ if len(argv) > 1:
+ vrf = argv[1]
+
+ base_path = ['protocols', 'static']
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ base = vrf and ['vrf', 'name', vrf, 'protocols', 'static'] or base_path
+ static = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True)
+
+ # Assign the name of our VRF context
+ if vrf: static['vrf'] = vrf
+
+ # We also need some additional information from the config, prefix-lists
+ # and route-maps for instance. They will be used in verify().
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = conf.get_config_dict(['policy'])
+ # Merge policy dict into "regular" config dict
+ static = dict_merge(tmp, static)
+
+ # T3680 - get a list of all interfaces currently configured to use DHCP
+ tmp = get_dhcp_interfaces(conf, vrf)
+ if tmp: static.update({'dhcp' : tmp})
+ tmp = get_pppoe_interfaces(conf, vrf)
+ if tmp: static.update({'pppoe' : tmp})
+
+ return static
+
+def verify(static):
+ verify_common_route_maps(static)
+
+ for route in ['route', 'route6']:
+ # if there is no route(6) key in the dictionary we can immediately
+ # bail out early
+ if route not in static:
+ continue
+
+ # When leaking routes to other VRFs we must ensure that the destination
+ # VRF exists
+ for prefix, prefix_options in static[route].items():
+ # both the interface and next-hop CLI node can have a VRF subnode,
+ # thus we check this using a for loop
+ for type in ['interface', 'next_hop']:
+ if type in prefix_options:
+ for interface, interface_config in prefix_options[type].items():
+ verify_vrf(interface_config)
+
+ if {'blackhole', 'reject'} <= set(prefix_options):
+ raise ConfigError(f'Can not use both blackhole and reject for '\
+ 'prefix "{prefix}"!')
+
+ return None
+
+def generate(static):
+ if not static:
+ return None
+
+ # Put routing table names in /etc/iproute2/rt_tables
+ render(config_file, 'iproute2/static.conf.j2', static)
+ static['new_frr_config'] = render_to_string('frr/staticd.frr.j2', static)
+ return None
+
+def apply(static):
+ static_daemon = 'staticd'
+
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+ frr_cfg.load_configuration(static_daemon)
+
+ if 'vrf' in static:
+ vrf = static['vrf']
+ frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit-vrf', remove_stop_mark=True)
+ else:
+ frr_cfg.modify_section(r'^ip route .*')
+ frr_cfg.modify_section(r'^ipv6 route .*')
+
+ if 'new_frr_config' in static:
+ frr_cfg.add_before(frr.default_add_before, static['new_frr_config'])
+ frr_cfg.commit_configuration(static_daemon)
+
+ return None
+
+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/protocols_static_arp.py b/src/conf_mode/protocols_static_arp.py
new file mode 100644
index 0000000..b141f11
--- /dev/null
+++ b/src/conf_mode/protocols_static_arp.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2022 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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import node_changed
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'static', 'arp']
+ arp = conf.get_config_dict(base, get_first_key=True)
+
+ if 'interface' in arp:
+ for interface in arp['interface']:
+ tmp = node_changed(conf, base + ['interface', interface, 'address'], recursive=True)
+ if tmp: arp['interface'][interface].update({'address_old' : tmp})
+
+ return arp
+
+def verify(arp):
+ pass
+
+def generate(arp):
+ pass
+
+def apply(arp):
+ if not arp:
+ return None
+
+ if 'interface' in arp:
+ for interface, interface_config in arp['interface'].items():
+ # Delete old static ARP assignments first
+ if 'address_old' in interface_config:
+ for address in interface_config['address_old']:
+ call(f'ip neigh del {address} dev {interface}')
+
+ # Add new static ARP entries to interface
+ if 'address' not in interface_config:
+ continue
+ for address, address_config in interface_config['address'].items():
+ mac = address_config['mac']
+ call(f'ip neigh replace {address} lladdr {mac} dev {interface}')
+
+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/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py
new file mode 100644
index 0000000..d323ceb
--- /dev/null
+++ b/src/conf_mode/protocols_static_multicast.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+
+from ipaddress import IPv4Address
+from sys import exit
+
+from vyos import ConfigError
+from vyos import frr
+from vyos.config import Config
+from vyos.template import render_to_string
+
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/tmp/static_mcast.frr'
+
+# Get configuration for static multicast route
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ mroute = {
+ 'old_mroute' : {},
+ 'mroute' : {}
+ }
+
+ base_path = "protocols static multicast"
+
+ if not (conf.exists(base_path) or conf.exists_effective(base_path)):
+ return None
+
+ conf.set_level(base_path)
+
+ # Get multicast effective routes
+ for route in conf.list_effective_nodes('route'):
+ mroute['old_mroute'][route] = {}
+ for next_hop in conf.list_effective_nodes('route {0} next-hop'.format(route)):
+ mroute['old_mroute'][route].update({
+ next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop))
+ })
+
+ # Get multicast effective interface-routes
+ for route in conf.list_effective_nodes('interface-route'):
+ if not route in mroute['old_mroute']:
+ mroute['old_mroute'][route] = {}
+ for next_hop in conf.list_effective_nodes('interface-route {0} next-hop-interface'.format(route)):
+ mroute['old_mroute'][route].update({
+ next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop))
+ })
+
+ # Get multicast routes
+ for route in conf.list_nodes('route'):
+ mroute['mroute'][route] = {}
+ for next_hop in conf.list_nodes('route {0} next-hop'.format(route)):
+ mroute['mroute'][route].update({
+ next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop))
+ })
+
+ # Get multicast interface-routes
+ for route in conf.list_nodes('interface-route'):
+ if not route in mroute['mroute']:
+ mroute['mroute'][route] = {}
+ for next_hop in conf.list_nodes('interface-route {0} next-hop-interface'.format(route)):
+ mroute['mroute'][route].update({
+ next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop))
+ })
+
+ return mroute
+
+def verify(mroute):
+ if mroute is None:
+ return None
+
+ for route in mroute['mroute']:
+ route = route.split('/')
+ if IPv4Address(route[0]) < IPv4Address('224.0.0.0'):
+ raise ConfigError(route + " not a multicast network")
+
+
+def generate(mroute):
+ if mroute is None:
+ return None
+
+ mroute['new_frr_config'] = render_to_string('frr/static_mcast.frr.j2', mroute)
+ return None
+
+
+def apply(mroute):
+ if mroute is None:
+ return None
+ static_daemon = 'staticd'
+
+ frr_cfg = frr.FRRConfig()
+ frr_cfg.load_configuration(static_daemon)
+
+ if 'old_mroute' in mroute:
+ for route_gr in mroute['old_mroute']:
+ for nh in mroute['old_mroute'][route_gr]:
+ if mroute['old_mroute'][route_gr][nh]:
+ frr_cfg.modify_section(f'^ip mroute {route_gr} {nh} {mroute["old_mroute"][route_gr][nh]}')
+ else:
+ frr_cfg.modify_section(f'^ip mroute {route_gr} {nh}')
+
+ if 'new_frr_config' in mroute:
+ frr_cfg.add_before(frr.default_add_before, mroute['new_frr_config'])
+
+ frr_cfg.commit_configuration(static_daemon)
+
+ return None
+
+
+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/protocols_static_neighbor-proxy.py b/src/conf_mode/protocols_static_neighbor-proxy.py
new file mode 100644
index 0000000..8a1ea1d
--- /dev/null
+++ b/src/conf_mode/protocols_static_neighbor-proxy.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['protocols', 'static', 'neighbor-proxy']
+ config = conf.get_config_dict(base, get_first_key=True)
+
+ return config
+
+def verify(config):
+ if 'arp' in config:
+ for neighbor, neighbor_conf in config['arp'].items():
+ if 'interface' not in neighbor_conf:
+ raise ConfigError(
+ f"ARP neighbor-proxy for '{neighbor}' requires an interface to be set!"
+ )
+
+ if 'nd' in config:
+ for neighbor, neighbor_conf in config['nd'].items():
+ if 'interface' not in neighbor_conf:
+ raise ConfigError(
+ f"ARP neighbor-proxy for '{neighbor}' requires an interface to be set!"
+ )
+
+def generate(config):
+ pass
+
+def apply(config):
+ if not config:
+ # Cleanup proxy
+ call('ip neighbor flush proxy')
+ call('ip -6 neighbor flush proxy')
+ return None
+
+ # Add proxy ARP
+ if 'arp' in config:
+ # Cleanup entries before config
+ call('ip neighbor flush proxy')
+ for neighbor, neighbor_conf in config['arp'].items():
+ for interface in neighbor_conf.get('interface'):
+ call(f'ip neighbor add proxy {neighbor} dev {interface}')
+
+ # Add proxy NDP
+ if 'nd' in config:
+ # Cleanup entries before config
+ call('ip -6 neighbor flush proxy')
+ for neighbor, neighbor_conf in config['nd'].items():
+ for interface in neighbor_conf['interface']:
+ call(f'ip -6 neighbor add proxy {neighbor} dev {interface}')
+
+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/qos.py b/src/conf_mode/qos.py
new file mode 100644
index 0000000..7dfad31
--- /dev/null
+++ b/src/conf_mode/qos.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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/>.
+
+from sys import exit
+from netifaces import interfaces
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_interface_exists
+from vyos.ifconfig import Section
+from vyos.qos import CAKE
+from vyos.qos import DropTail
+from vyos.qos import FairQueue
+from vyos.qos import FQCodel
+from vyos.qos import Limiter
+from vyos.qos import NetEm
+from vyos.qos import Priority
+from vyos.qos import RandomDetect
+from vyos.qos import RateLimiter
+from vyos.qos import RoundRobin
+from vyos.qos import TrafficShaper
+from vyos.qos import TrafficShaperHFSC
+from vyos.utils.dict import dict_search_recursive
+from vyos.utils.process import run
+from vyos import ConfigError
+from vyos import airbag
+from vyos.xml_ref import relative_defaults
+
+
+airbag.enable()
+
+map_vyops_tc = {
+ 'cake' : CAKE,
+ 'drop_tail' : DropTail,
+ 'fair_queue' : FairQueue,
+ 'fq_codel' : FQCodel,
+ 'limiter' : Limiter,
+ 'network_emulator' : NetEm,
+ 'priority_queue' : Priority,
+ 'random_detect' : RandomDetect,
+ 'rate_control' : RateLimiter,
+ 'round_robin' : RoundRobin,
+ 'shaper' : TrafficShaper,
+ 'shaper_hfsc' : TrafficShaperHFSC,
+}
+
+def get_shaper(qos, interface_config, direction):
+ policy_name = interface_config[direction]
+ # An interface might have a QoS configuration, search the used
+ # configuration referenced by this. Path will hold the dict element
+ # referenced by the config, as this will be of sort:
+ #
+ # ['policy', 'drop_tail', 'foo-dtail'] <- we are only interested in
+ # drop_tail as the policy/shaper type
+ _, path = next(dict_search_recursive(qos, policy_name))
+ shaper_type = path[1]
+ shaper_config = qos['policy'][shaper_type][policy_name]
+
+ return (map_vyops_tc[shaper_type], shaper_config)
+
+
+def _clean_conf_dict(conf):
+ """
+ Delete empty nodes from config e.g.
+ match ADDRESS30 {
+ ip {
+ source {}
+ }
+ }
+ """
+ if isinstance(conf, dict):
+ return {node: _clean_conf_dict(val) for node, val in conf.items() if val != {} and _clean_conf_dict(val) != {}}
+ else:
+ return conf
+
+
+def _get_group_filters(config: dict, group_name: str, visited=None) -> dict:
+ filters = dict()
+ if not visited:
+ visited = [group_name, ]
+ else:
+ if group_name in visited:
+ return filters
+ visited.append(group_name)
+
+ for filter, filter_config in config.get(group_name, {}).items():
+ if filter == 'match':
+ for match, match_config in filter_config.items():
+ filters[f'{group_name}-{match}'] = match_config
+ elif filter == 'match_group':
+ for group in filter_config:
+ filters.update(_get_group_filters(config, group, visited))
+
+ return filters
+
+
+def _get_group_match(config:dict, group_name:str) -> dict:
+ match = dict()
+ for key, val in _get_group_filters(config, group_name).items():
+ # delete duplicate matches
+ if val not in match.values():
+ match[key] = val
+
+ return match
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['qos']
+ if not conf.exists(base):
+ return None
+
+ qos = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ for ifname in interfaces():
+ if_node = Section.get_config_path(ifname)
+
+ if not if_node:
+ continue
+
+ path = f'interfaces {if_node}'
+ if conf.exists(f'{path} mirror') or conf.exists(f'{path} redirect'):
+ type_node = path.split(" ")[1] # return only interface type node
+ set_dependents(type_node, conf, ifname.split(".")[0])
+
+ for policy in qos.get('policy', []):
+ if policy in ['random_detect']:
+ for rd_name in list(qos['policy'][policy]):
+ # There are eight precedence levels - ensure all are present
+ # to be filled later down with the appropriate default values
+ default_p_val = relative_defaults(
+ ['qos', 'policy', 'random-detect', rd_name, 'precedence'],
+ {'precedence': {'0': {}}},
+ get_first_key=True, recursive=True
+ )['0']
+ default_p_val = {key.replace('-', '_'): value for key, value in default_p_val.items()}
+ default_precedence = {
+ 'precedence': {'0': default_p_val, '1': default_p_val,
+ '2': default_p_val, '3': default_p_val,
+ '4': default_p_val, '5': default_p_val,
+ '6': default_p_val, '7': default_p_val}}
+
+ qos['policy']['random_detect'][rd_name] = dict_merge(
+ default_precedence, qos['policy']['random_detect'][rd_name])
+
+ qos = conf.merge_defaults(qos, recursive=True)
+
+ if 'traffic_match_group' in qos:
+ for group, group_config in qos['traffic_match_group'].items():
+ if 'match_group' in group_config:
+ qos['traffic_match_group'][group]['match'] = _get_group_match(qos['traffic_match_group'], group)
+
+ for policy in qos.get('policy', []):
+ for p_name, p_config in qos['policy'][policy].items():
+ # cleanup empty match config
+ if 'class' in p_config:
+ for cls, cls_config in p_config['class'].items():
+ if 'match_group' in cls_config:
+ # merge group match to match
+ for group in cls_config['match_group']:
+ for match, match_conf in qos['traffic_match_group'].get(group, {'match': {}})['match'].items():
+ if 'match' not in cls_config:
+ cls_config['match'] = dict()
+ if match in cls_config['match']:
+ cls_config['match'][f'{group}-{match}'] = match_conf
+ else:
+ cls_config['match'][match] = match_conf
+
+ if 'match' in cls_config:
+ cls_config['match'] = _clean_conf_dict(cls_config['match'])
+ if cls_config['match'] == {}:
+ del cls_config['match']
+
+ return qos
+
+
+def _verify_match(cls_config: dict) -> None:
+ if 'match' in cls_config:
+ for match, match_config in cls_config['match'].items():
+ if {'ip', 'ipv6'} <= set(match_config):
+ raise ConfigError(
+ f'Can not use both IPv6 and IPv4 in one match ({match})!')
+
+
+def _verify_match_group_exist(cls_config, qos):
+ if 'match_group' in cls_config:
+ for group in cls_config['match_group']:
+ if 'traffic_match_group' not in qos or group not in qos['traffic_match_group']:
+ Warning(f'Match group "{group}" does not exist!')
+
+
+def verify(qos):
+ if not qos or 'interface' not in qos:
+ return None
+
+ # network policy emulator
+ # reorder rerquires delay to be set
+ if 'policy' in qos:
+ for policy_type in qos['policy']:
+ for policy, policy_config in qos['policy'][policy_type].items():
+ # a policy with it's given name is only allowed to exist once
+ # on the system. This is because an interface selects a policy
+ # for ingress/egress traffic, and thus there can only be one
+ # policy with a given name.
+ #
+ # We check if the policy name occurs more then once - error out
+ # if this is true
+ counter = 0
+ for _, path in dict_search_recursive(qos['policy'], policy):
+ counter += 1
+ if counter > 1:
+ raise ConfigError(f'Conflicting policy name "{policy}", already in use!')
+
+ if 'class' in policy_config:
+ for cls, cls_config in policy_config['class'].items():
+ # bandwidth is not mandatory for priority-queue - that is why this is on the exception list
+ if 'bandwidth' not in cls_config and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']:
+ raise ConfigError(f'Bandwidth must be defined for policy "{policy}" class "{cls}"!')
+ _verify_match(cls_config)
+ _verify_match_group_exist(cls_config, qos)
+ if policy_type in ['random_detect']:
+ if 'precedence' in policy_config:
+ for precedence, precedence_config in policy_config['precedence'].items():
+ max_tr = int(precedence_config['maximum_threshold'])
+ if {'maximum_threshold', 'minimum_threshold'} <= set(precedence_config):
+ min_tr = int(precedence_config['minimum_threshold'])
+ if min_tr >= max_tr:
+ raise ConfigError(f'Policy "{policy}" uses min-threshold "{min_tr}" >= max-threshold "{max_tr}"!')
+
+ if {'maximum_threshold', 'queue_limit'} <= set(precedence_config):
+ queue_lim = int(precedence_config['queue_limit'])
+ if queue_lim < max_tr:
+ raise ConfigError(f'Policy "{policy}" uses queue-limit "{queue_lim}" < max-threshold "{max_tr}"!')
+ if policy_type in ['priority_queue']:
+ if 'default' not in policy_config:
+ raise ConfigError(f'Policy {policy} misses "default" class!')
+ if 'default' in policy_config:
+ if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']:
+ raise ConfigError('Bandwidth not defined for default traffic!')
+
+ # we should check interface ingress/egress configuration after verifying that
+ # the policy name is used only once - this makes the logic easier!
+ for interface, interface_config in qos['interface'].items():
+ for direction in ['egress', 'ingress']:
+ # bail out early if shaper for given direction is not used at all
+ if direction not in interface_config:
+ continue
+
+ policy_name = interface_config[direction]
+ if 'policy' not in qos or list(dict_search_recursive(qos['policy'], policy_name)) == []:
+ raise ConfigError(f'Selected QoS policy "{policy_name}" does not exist!')
+
+ shaper_type, shaper_config = get_shaper(qos, interface_config, direction)
+ tmp = shaper_type(interface).get_direction()
+ if direction not in tmp:
+ raise ConfigError(f'Selected QoS policy on interface "{interface}" only supports "{tmp}"!')
+
+ if 'traffic_match_group' in qos:
+ for group, group_config in qos['traffic_match_group'].items():
+ _verify_match(group_config)
+ _verify_match_group_exist(group_config, qos)
+
+ return None
+
+
+def generate(qos):
+ if not qos or 'interface' not in qos:
+ return None
+
+ return None
+
+def apply(qos):
+ # Always delete "old" shapers first
+ for interface in interfaces():
+ # Ignore errors (may have no qdisc)
+ run(f'tc qdisc del dev {interface} parent ffff:')
+ run(f'tc qdisc del dev {interface} root')
+
+ call_dependents()
+
+ if not qos or 'interface' not in qos:
+ return None
+
+ for interface, interface_config in qos['interface'].items():
+ if not verify_interface_exists(qos, interface, state_required=True, warning_only=True):
+ # When shaper is bound to a dialup (e.g. PPPoE) interface it is
+ # possible that it is yet not availbale when to QoS code runs.
+ # Skip the configuration and inform the user via warning_only=True
+ continue
+
+ for direction in ['egress', 'ingress']:
+ # bail out early if shaper for given direction is not used at all
+ if direction not in interface_config:
+ continue
+
+ shaper_type, shaper_config = get_shaper(qos, interface_config, direction)
+ tmp = shaper_type(interface)
+ tmp.update(shaper_config, direction)
+
+ return None
+
+
+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/service_aws_glb.py b/src/conf_mode/service_aws_glb.py
new file mode 100644
index 0000000..d1ed5a0
--- /dev/null
+++ b/src/conf_mode/service_aws_glb.py
@@ -0,0 +1,76 @@
+#!/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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+systemd_service = 'aws-gwlbtun.service'
+systemd_override = '/run/systemd/system/aws-gwlbtun.service.d/10-override.conf'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'aws', 'glb']
+ if not conf.exists(base):
+ return None
+
+ glb = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ return glb
+
+
+def verify(glb):
+ # bail out early - looks like removal from running config
+ if not glb:
+ return None
+
+
+def generate(glb):
+ if not glb:
+ return None
+
+ render(systemd_override, 'aws/override_aws_gwlbtun.conf.j2', glb)
+
+
+def apply(glb):
+ call('systemctl daemon-reload')
+ if not glb:
+ call(f'systemctl stop {systemd_service}')
+ else:
+ call(f'systemctl restart {systemd_service}')
+ return None
+
+
+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/service_broadcast-relay.py b/src/conf_mode/service_broadcast-relay.py
new file mode 100644
index 0000000..d359547
--- /dev/null
+++ b/src/conf_mode/service_broadcast-relay.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2017-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 os
+
+from glob import glob
+from netifaces import AF_INET
+from sys import exit
+
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.network import is_afi_configured
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file_base = r'/etc/default/udp-broadcast-relay'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'broadcast-relay']
+
+ relay = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+ return relay
+
+def verify(relay):
+ if not relay or 'disabled' in relay:
+ return None
+
+ for instance, config in relay.get('id', {}).items():
+ # we don't have to check this instance when it's disabled
+ if 'disabled' in config:
+ continue
+
+ # we certainly require a UDP port to listen to
+ if 'port' not in config:
+ raise ConfigError(f'Port number is mandatory for UDP broadcast relay "{instance}"')
+
+ # Relaying data without two interface is kinda senseless ...
+ if len(config.get('interface', [])) < 2:
+ raise ConfigError('At least two interfaces are required for UDP broadcast relay "{instance}"')
+
+ for interface in config.get('interface', []):
+ verify_interface_exists(relay, interface)
+ if not is_afi_configured(interface, AF_INET):
+ raise ConfigError(f'Interface "{interface}" has no IPv4 address configured!')
+
+ return None
+
+def generate(relay):
+ if not relay or 'disabled' in relay:
+ return None
+
+ for config in glob(config_file_base + '*'):
+ os.remove(config)
+
+ for instance, config in relay.get('id').items():
+ # we don't have to check this instance when it's disabled
+ if 'disabled' in config:
+ continue
+
+ config['instance'] = instance
+ render(config_file_base + instance, 'bcast-relay/udp-broadcast-relay.j2',
+ config)
+
+ return None
+
+def apply(relay):
+ # first stop all running services
+ call('systemctl stop udp-broadcast-relay@*.service')
+
+ if not relay or 'disable' in relay:
+ return None
+
+ # start only required service instances
+ for instance, config in relay.get('id').items():
+ # we don't have to check this instance when it's disabled
+ if 'disabled' in config:
+ continue
+
+ call(f'systemctl start udp-broadcast-relay@{instance}.service')
+
+ return None
+
+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/service_config-sync.py b/src/conf_mode/service_config-sync.py
new file mode 100644
index 0000000..4b8a7f6
--- /dev/null
+++ b/src/conf_mode/service_config-sync.py
@@ -0,0 +1,105 @@
+#!/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 os
+import json
+from pathlib import Path
+
+from vyos.config import Config
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+
+service_conf = Path(f'/run/config_sync_conf.conf')
+post_commit_dir = '/run/scripts/commit/post-hooks.d'
+post_commit_file_src = '/usr/libexec/vyos/vyos_config_sync.py'
+post_commit_file = f'{post_commit_dir}/vyos_config_sync'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'config-sync']
+ if not conf.exists(base):
+ return None
+ config = conf.get_config_dict(base, get_first_key=True,
+ with_recursive_defaults=True)
+
+ return config
+
+
+def verify(config):
+ # bail out early - looks like removal from running config
+ if not config:
+ return None
+
+ if 'mode' not in config:
+ raise ConfigError(f'config-sync mode is mandatory!')
+
+ for option in ['secondary', 'section']:
+ if option not in config:
+ raise ConfigError(f"config-sync '{option}' is not configured!")
+
+ if 'address' not in config['secondary']:
+ raise ConfigError(f'secondary address is mandatory!')
+ if 'key' not in config['secondary']:
+ raise ConfigError(f'secondary key is mandatory!')
+
+
+def generate(config):
+ if not config:
+
+ if os.path.exists(post_commit_file):
+ os.unlink(post_commit_file)
+
+ if service_conf.exists():
+ service_conf.unlink()
+
+ return None
+
+ # Write configuration file
+ conf_json = json.dumps(config, indent=4)
+ service_conf.write_text(conf_json)
+
+ # Create post commit dir
+ if not os.path.isdir(post_commit_dir):
+ os.makedirs(post_commit_dir)
+
+ # Symlink from helpers to post-commit
+ if not os.path.exists(post_commit_file):
+ os.symlink(post_commit_file_src, post_commit_file)
+
+ return None
+
+
+def apply(config):
+ return None
+
+
+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/service_conntrack-sync.py b/src/conf_mode/service_conntrack-sync.py
new file mode 100644
index 0000000..3a233a1
--- /dev/null
+++ b/src/conf_mode/service_conntrack-sync.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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
+
+from sys import exit
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.utils.dict import dict_search
+from vyos.utils.process import process_named_running
+from vyos.utils.file import read_file
+from vyos.utils.process import call
+from vyos.utils.process import run
+from vyos.template import render
+from vyos.template import get_ipv4
+from vyos.utils.network import is_addr_assigned
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/run/conntrackd/conntrackd.conf'
+
+def resync_vrrp():
+ tmp = run('/usr/libexec/vyos/conf_mode/high-availability.py')
+ if tmp > 0:
+ print('ERROR: error restarting VRRP daemon!')
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'conntrack-sync']
+ if not conf.exists(base):
+ return None
+
+ conntrack = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, with_defaults=True)
+
+ conntrack['hash_size'] = read_file('/sys/module/nf_conntrack/parameters/hashsize')
+ conntrack['table_size'] = read_file('/proc/sys/net/netfilter/nf_conntrack_max')
+
+ conntrack['vrrp'] = conf.get_config_dict(['high-availability', 'vrrp', 'sync-group'],
+ get_first_key=True)
+
+ return conntrack
+
+def verify(conntrack):
+ if not conntrack:
+ return None
+
+ if 'interface' not in conntrack:
+ raise ConfigError('Interface not defined!')
+
+ has_peer = False
+ for interface, interface_config in conntrack['interface'].items():
+ verify_interface_exists(conntrack, interface)
+ # Interface must not only exist, it must also carry an IP address
+ if len(get_ipv4(interface)) < 1:
+ raise ConfigError(f'Interface {interface} requires an IP address!')
+ if 'peer' in interface_config:
+ has_peer = True
+
+ # If one interface runs in unicast mode instead of multicast, so must all the
+ # others, else conntrackd will error out with: "cannot use UDP with other
+ # dedicated link protocols"
+ if has_peer:
+ for interface, interface_config in conntrack['interface'].items():
+ if 'peer' not in interface_config:
+ raise ConfigError('Can not mix unicast and multicast mode!')
+
+ if 'expect_sync' in conntrack:
+ if len(conntrack['expect_sync']) > 1 and 'all' in conntrack['expect_sync']:
+ raise ConfigError('Can not configure expect-sync "all" with other protocols!')
+
+ if 'listen_address' in conntrack:
+ for address in conntrack['listen_address']:
+ if not is_addr_assigned(address):
+ raise ConfigError(f'Specified listen-address {address} not assigned to any interface!')
+
+ vrrp_group = dict_search('failover_mechanism.vrrp.sync_group', conntrack)
+ if vrrp_group == None:
+ raise ConfigError(f'No VRRP sync-group defined!')
+ if vrrp_group not in conntrack['vrrp']:
+ raise ConfigError(f'VRRP sync-group {vrrp_group} not configured!')
+
+ return None
+
+def generate(conntrack):
+ if not conntrack:
+ if os.path.isfile(config_file):
+ os.unlink(config_file)
+ return None
+
+ render(config_file, 'conntrackd/conntrackd.conf.j2', conntrack)
+
+ return None
+
+def apply(conntrack):
+ systemd_service = 'conntrackd.service'
+ if not conntrack:
+ # Failover mechanism daemon should be indicated that it no longer needs
+ # to execute conntrackd actions on transition. This is only required
+ # once when conntrackd is stopped and taken out of service!
+ if process_named_running('conntrackd'):
+ resync_vrrp()
+
+ call(f'systemctl stop {systemd_service}')
+ return None
+
+ # Failover mechanism daemon should be indicated that it needs to execute
+ # conntrackd actions on transition. This is only required once when conntrackd
+ # is started the first time!
+ if not process_named_running('conntrackd'):
+ resync_vrrp()
+
+ call(f'systemctl reload-or-restart {systemd_service}')
+ return None
+
+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/service_console-server.py b/src/conf_mode/service_console-server.py
new file mode 100644
index 0000000..b112add
--- /dev/null
+++ b/src/conf_mode/service_console-server.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2021 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
+
+from sys import exit
+from psutil import process_iter
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+
+config_file = '/run/conserver/conserver.cf'
+dropbear_systemd_file = '/run/systemd/system/dropbear@{port}.service.d/override.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'console-server']
+
+ # Retrieve CLI representation as dictionary
+ proxy = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+ # The retrieved dictionary will look something like this:
+ #
+ # {'device': {'usb0b2.4p1.0': {'speed': '9600'},
+ # 'usb0b2.4p1.1': {'data_bits': '8',
+ # 'parity': 'none',
+ # 'speed': '115200',
+ # 'stop_bits': '2'}}}
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ proxy = conf.merge_defaults(proxy, recursive=True)
+
+ return proxy
+
+def verify(proxy):
+ if not proxy:
+ return None
+
+ aliases = []
+ processes = process_iter(['name', 'cmdline'])
+ if 'device' in proxy:
+ for device, device_config in proxy['device'].items():
+ for process in processes:
+ if 'agetty' in process.name() and device in process.cmdline():
+ raise ConfigError(f'Port "{device}" already provides a '\
+ 'console used by "system console"!')
+
+ if 'speed' not in device_config:
+ raise ConfigError(f'Port "{device}" requires speed to be set!')
+
+ if 'ssh' in device_config and 'port' not in device_config['ssh']:
+ raise ConfigError(f'Port "{device}" requires SSH port to be set!')
+
+ if 'alias' in device_config:
+ if device_config['alias'] in aliases:
+ raise ConfigError("Console aliases must be unique")
+ else:
+ aliases.append(device_config['alias'])
+
+ return None
+
+def generate(proxy):
+ if not proxy:
+ return None
+
+ render(config_file, 'conserver/conserver.conf.j2', proxy)
+ if 'device' in proxy:
+ for device, device_config in proxy['device'].items():
+ if 'ssh' not in device_config:
+ continue
+
+ tmp = {
+ 'device' : device,
+ 'port' : device_config['ssh']['port'],
+ }
+ render(dropbear_systemd_file.format(**tmp),
+ 'conserver/dropbear@.service.j2', tmp)
+
+ return None
+
+def apply(proxy):
+ call('systemctl daemon-reload')
+ call('systemctl stop dropbear@*.service conserver-server.service')
+
+ if not proxy:
+ if os.path.isfile(config_file):
+ os.unlink(config_file)
+ return None
+
+ call('systemctl restart conserver-server.service')
+
+ if 'device' in proxy:
+ for device, device_config in proxy['device'].items():
+ if 'ssh' not in device_config:
+ continue
+ port = device_config['ssh']['port']
+ call(f'systemctl restart dropbear@{port}.service')
+
+ return None
+
+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/service_dhcp-relay.py b/src/conf_mode/service_dhcp-relay.py
new file mode 100644
index 0000000..37d7088
--- /dev/null
+++ b/src/conf_mode/service_dhcp-relay.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2020 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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.template import render
+from vyos.base import Warning
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/dhcp-relay/dhcrelay.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'dhcp-relay']
+ if not conf.exists(base):
+ return None
+
+ relay = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return relay
+
+def verify(relay):
+ # bail out early - looks like removal from running config
+ if not relay or 'disable' in relay:
+ return None
+
+ if 'lo' in (dict_search('interface', relay) or []):
+ raise ConfigError('DHCP relay does not support the loopback interface.')
+
+ if 'server' not in relay :
+ raise ConfigError('No DHCP relay server(s) configured.\n' \
+ 'At least one DHCP relay server required.')
+
+ if 'interface' in relay:
+ Warning('DHCP relay interface is DEPRECATED - please use upstream-interface and listen-interface instead!')
+ if 'upstream_interface' in relay or 'listen_interface' in relay:
+ raise ConfigError('<interface> configuration is not compatible with upstream/listen interface')
+ else:
+ Warning('<interface> is going to be deprecated.\n' \
+ 'Please use <listen-interface> and <upstream-interface>')
+
+ if 'upstream_interface' in relay and 'listen_interface' not in relay:
+ raise ConfigError('No listen-interface configured')
+ if 'listen_interface' in relay and 'upstream_interface' not in relay:
+ raise ConfigError('No upstream-interface configured')
+
+ return None
+
+def generate(relay):
+ # bail out early - looks like removal from running config
+ if not relay or 'disable' in relay:
+ return None
+
+ render(config_file, 'dhcp-relay/dhcrelay.conf.j2', relay)
+ return None
+
+def apply(relay):
+ # bail out early - looks like removal from running config
+ service_name = 'isc-dhcp-relay.service'
+ if not relay or 'disable' in relay:
+ call(f'systemctl stop {service_name}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ return None
+
+ call(f'systemctl restart {service_name}')
+
+ return None
+
+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/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py
new file mode 100644
index 0000000..e89448e
--- /dev/null
+++ b/src/conf_mode/service_dhcp-server.py
@@ -0,0 +1,430 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from glob import glob
+from ipaddress import ip_address
+from ipaddress import ip_network
+from netaddr import IPRange
+from sys import exit
+
+from vyos.config import Config
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_private_key
+from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.file import chmod_775
+from vyos.utils.file import chown
+from vyos.utils.file import makedir
+from vyos.utils.file import write_file
+from vyos.utils.process import call
+from vyos.utils.network import interface_exists
+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'
+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?
+
+ Lets assume we have a DHCP range from '192.0.2.1' to '192.0.2.100'
+ but want to exclude address '192.0.2.74' and '192.0.2.75'. We will
+ pass an input 'range_dict' in the format:
+ {'start' : '192.0.2.1', 'stop' : '192.0.2.100' }
+ and we will receive an output list of:
+ [{'start' : '192.0.2.1' , 'stop' : '192.0.2.73' },
+ {'start' : '192.0.2.76', 'stop' : '192.0.2.100' }]
+ The resulting list can then be used in turn to build the proper dhcpd
+ configuration file.
+ """
+ output = []
+ # exclude list must be sorted for this to work
+ exclude_list = sorted(exclude_list)
+ range_start = range_dict['start']
+ range_stop = range_dict['stop']
+ range_last_exclude = ''
+
+ for e in exclude_list:
+ 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)):
+
+ # Build new address range ending one address before exclude address
+ r = {
+ 'start' : range_start,
+ 'stop' : str(ip_address(e) -1)
+ }
+ # On the next run our address range will start one address after
+ # the exclude address
+ range_start = str(ip_address(e) + 1)
+
+ # on subsequent exclude addresses we can not
+ # append them to our output
+ if not (ip_address(r['start']) > ip_address(r['stop'])):
+ # Everything is fine, add range to result
+ output.append(r)
+
+ # 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)
+ }
+ 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)
+
+ return output
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'dhcp-server']
+ 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)
+
+ if 'shared_network_name' in dhcp:
+ for network, network_config in dhcp['shared_network_name'].items():
+ if 'subnet' in network_config:
+ for subnet, subnet_config in network_config['subnet'].items():
+ # If exclude IP addresses are defined we need to slice them out of
+ # the defined ranges
+ if {'exclude', 'range'} <= set(subnet_config):
+ 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
+
+ dhcp['shared_network_name'][network]['subnet'][subnet].update(
+ {'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)
+
+ return dhcp
+
+def verify(dhcp):
+ # bail out early - looks like removal from running config
+ if not dhcp or 'disable' in dhcp:
+ return None
+
+ # 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.')
+
+ # Inspect shared-network/subnet
+ listen_ok = False
+ subnets = []
+ shared_networks = len(dhcp['shared_network_name'])
+ disabled_shared_networks = 0
+
+ subnet_ids = []
+
+ # A shared-network requires a subnet definition
+ for network, network_config in dhcp['shared_network_name'].items():
+ if 'disable' in network_config:
+ 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.')
+
+ 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}"')
+
+ if subnet_config['subnet_id'] in subnet_ids:
+ raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique')
+
+ subnet_ids.append(subnet_config['subnet_id'])
+
+ # All delivered static routes require a next-hop to be set
+ 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!')
+
+ # 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!')
+
+ # 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}"!')
+
+ # 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!')
+
+ 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!')
+ if stop in network:
+ raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!')
+
+ tmp = IPRange(range_config['start'], range_config['stop'])
+ networks.append(tmp)
+
+ # Exclude addresses must be in bound
+ 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}"!')
+
+ # 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}"!')
+
+ 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)
+ used_ips = []
+ used_mac = []
+ 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 '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')
+ 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')
+ 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')
+ used_duid.append(mapping_config['duid'])
+
+ # There must be one subnet connected to a listen interface.
+ # This only counts if the network itself is not disabled!
+ if 'disable' not in network_config:
+ if is_subnet_connected(subnet, primary=False):
+ listen_ok = True
+
+ # Subnets must be non overlapping
+ if subnet in subnets:
+ 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.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!')
+
+ 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!')
+
+ if len({'certificate', 'ca_certificate'} & set(dhcp['high_availability'])) == 1:
+ raise ConfigError(f'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')
+
+ 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')
+
+ if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'):
+ raise ConfigError(f'Invalid CA certificate specified for DHCP high-availability')
+
+ 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')
+
+ 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!')
+
+ if 'listen_address' in dhcp and 'listen_interface' in dhcp:
+ raise ConfigError(f'Cannot define listen-address and listen-interface at the same time')
+
+ 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:
+ return None
+
+ dhcp['lease_file'] = lease_file
+ dhcp['machine'] = os.uname().machine
+
+ # Create directory for lease file if necessary
+ lease_dir = os.path.dirname(lease_file)
+ if not os.path.isdir(lease_dir):
+ makedir(lease_dir, group='vyattacfg')
+ chmod_775(lease_dir)
+
+ # Ensure correct permissions on lease files + backups
+ for file in glob(lease_file_glob):
+ chown(file, user=user_group, group='vyattacfg')
+
+ # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way
+ if not os.path.exists(lease_file):
+ write_file(lease_file, '', user=user_group, group=user_group, mode=0o644)
+
+ for f in [cert_file, cert_key_file, ca_cert_file]:
+ if os.path.exists(f):
+ os.unlink(f)
+
+ if 'high_availability' in dhcp:
+ if 'certificate' in dhcp['high_availability']:
+ 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)
+
+ dhcp['high_availability']['cert_file'] = cert_file
+ dhcp['high_availability']['cert_key_file'] = cert_key_file
+
+ 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)
+
+ 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)
+
+ return None
+
+def apply(dhcp):
+ services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server']
+
+ if not dhcp or 'disable' in dhcp:
+ for service in services:
+ call(f'systemctl stop {service}.service')
+
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+
+ return None
+
+ for service in services:
+ action = 'restart'
+
+ if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp:
+ action = 'stop'
+
+ if service == 'kea-ctrl-agent' and 'high_availability' not in dhcp:
+ action = 'stop'
+
+ call(f'systemctl {action} {service}.service')
+
+ return None
+
+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/service_dhcpv6-relay.py b/src/conf_mode/service_dhcpv6-relay.py
new file mode 100644
index 0000000..6537ca3
--- /dev/null
+++ b/src/conf_mode/service_dhcpv6-relay.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2020 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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.ifconfig import Interface
+from vyos.template import render
+from vyos.template import is_ipv6
+from vyos.utils.process import call
+from vyos.utils.network import is_ipv6_link_local
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/run/dhcp-relay/dhcrelay6.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'dhcpv6-relay']
+ if not conf.exists(base):
+ return None
+
+ relay = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return relay
+
+def verify(relay):
+ # bail out early - looks like removal from running config
+ if not relay or 'disable' in relay:
+ return None
+
+ if 'upstream_interface' not in relay:
+ raise ConfigError('At least one upstream interface required!')
+ for interface, config in relay['upstream_interface'].items():
+ if 'address' not in config:
+ raise ConfigError('DHCPv6 server required for upstream ' \
+ f'interface {interface}!')
+
+ if 'listen_interface' not in relay:
+ raise ConfigError('At least one listen interface required!')
+
+ # DHCPv6 relay requires at least one global unicat address assigned to the
+ # interface
+ for interface in relay['listen_interface']:
+ has_global = False
+ for addr in Interface(interface).get_addr():
+ if is_ipv6(addr) and not is_ipv6_link_local(addr):
+ has_global = True
+ if not has_global:
+ raise ConfigError(f'Interface {interface} does not have global '\
+ 'IPv6 address assigned!')
+
+ return None
+
+def generate(relay):
+ # bail out early - looks like removal from running config
+ if not relay or 'disable' in relay:
+ return None
+
+ render(config_file, 'dhcp-relay/dhcrelay6.conf.j2', relay)
+ return None
+
+def apply(relay):
+ # bail out early - looks like removal from running config
+ service_name = 'isc-dhcp-relay6.service'
+ if not relay or 'disable' in relay:
+ # DHCPv6 relay support is removed in the commit
+ call(f'systemctl stop {service_name}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ return None
+
+ call(f'systemctl restart {service_name}')
+
+ return None
+
+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/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py
new file mode 100644
index 0000000..7af8800
--- /dev/null
+++ b/src/conf_mode/service_dhcpv6-server.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 os
+
+from glob import glob
+from ipaddress import ip_address
+from ipaddress import ip_network
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.file import chmod_775
+from vyos.utils.file import chown
+from vyos.utils.file import makedir
+from vyos.utils.file import write_file
+from vyos.utils.dict import dict_search
+from vyos.utils.network import is_subnet_connected
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/run/kea/kea-dhcp6.conf'
+ctrl_socket = '/run/kea/dhcp6-ctrl-socket'
+lease_file = '/config/dhcp/dhcp6-leases.csv'
+lease_file_glob = '/config/dhcp/dhcp6-leases*'
+user_group = '_kea'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'dhcpv6-server']
+ if not conf.exists(base):
+ return None
+
+ dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ return dhcpv6
+
+def verify(dhcpv6):
+ # bail out early - looks like removal from running config
+ if not dhcpv6 or 'disable' in dhcpv6:
+ return None
+
+ # If DHCP is enabled we need one share-network
+ if 'shared_network_name' not in dhcpv6:
+ raise ConfigError('No DHCPv6 shared networks configured. At least '\
+ 'one DHCPv6 shared network must be configured.')
+
+ # Inspect shared-network/subnet
+ subnets = []
+ subnet_ids = []
+ listen_ok = False
+ for network, network_config in dhcpv6['shared_network_name'].items():
+ # A shared-network requires a subnet definition
+ if 'subnet' not in network_config:
+ raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\
+ 'At least one lease subnet must be configured for '\
+ 'each shared network!')
+
+ 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}"')
+
+ if subnet_config['subnet_id'] in subnet_ids:
+ raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique')
+
+ subnet_ids.append(subnet_config['subnet_id'])
+
+ if 'range' in subnet_config:
+ range6_start = []
+ range6_stop = []
+
+ for num, range_config in subnet_config['range'].items():
+ if 'start' in range_config:
+ start = range_config['start']
+
+ if 'stop' not in range_config:
+ raise ConfigError(f'Range stop address for start "{start}" is not defined!')
+ stop = range_config['stop']
+
+ # Start address must be inside network
+ if not ip_address(start) in ip_network(subnet):
+ raise ConfigError(f'Range start address "{start}" is not in subnet "{subnet}"!')
+
+ # Stop address must be inside network
+ if not ip_address(stop) in ip_network(subnet):
+ raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!')
+
+ # Stop address must be greater or equal to start address
+ if not ip_address(stop) >= ip_address(start):
+ raise ConfigError(f'Range stop address "{stop}" must be greater than or equal ' \
+ f'to the range start address "{start}"!')
+
+ # DHCPv6 range start address must be unique - two ranges can't
+ # start with the same address - makes no sense
+ if start in range6_start:
+ raise ConfigError(f'Conflicting DHCPv6 lease range: '\
+ f'Pool start address "{start}" defined multiple times!')
+
+ range6_start.append(start)
+
+ # DHCPv6 range stop address must be unique - two ranges can't
+ # end with the same address - makes no sense
+ if stop in range6_stop:
+ raise ConfigError(f'Conflicting DHCPv6 lease range: '\
+ f'Pool stop address "{stop}" defined multiple times!')
+
+ range6_stop.append(stop)
+
+ if 'prefix' in range_config:
+ prefix = range_config['prefix']
+
+ if not ip_network(prefix).subnet_of(ip_network(subnet)):
+ raise ConfigError(f'Range prefix "{prefix}" is not in subnet "{subnet}"')
+
+ # Prefix delegation sanity checks
+ if 'prefix_delegation' in subnet_config:
+ if 'prefix' not in subnet_config['prefix_delegation']:
+ raise ConfigError('prefix-delegation prefix not defined!')
+
+ for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items():
+ if 'delegated_length' not in prefix_config:
+ raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\
+ f'must be configured')
+
+ if 'prefix_length' not in prefix_config:
+ raise ConfigError('Length of delegated IPv6 prefix must be configured')
+
+ if prefix_config['prefix_length'] > prefix_config['delegated_length']:
+ raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix')
+
+ if 'excluded_prefix' in prefix_config:
+ if 'excluded_prefix_length' not in prefix_config:
+ raise ConfigError('Length of excluded IPv6 prefix must be configured')
+
+ prefix_len = prefix_config['prefix_length']
+ prefix_obj = ip_network(f'{prefix}/{prefix_len}')
+
+ excluded_prefix = prefix_config['excluded_prefix']
+ excluded_len = prefix_config['excluded_prefix_length']
+ excluded_obj = ip_network(f'{excluded_prefix}/{excluded_len}')
+
+ if excluded_len <= prefix_config['delegated_length']:
+ raise ConfigError('Excluded IPv6 prefix must be smaller than delegated prefix')
+
+ if not excluded_obj.subnet_of(prefix_obj):
+ raise ConfigError(f'Excluded prefix "{excluded_prefix}" does not exist in the prefix')
+
+ # Static mappings don't require anything (but check if IP is in subnet if it's set)
+ if 'static_mapping' in subnet_config:
+ for mapping, mapping_config in subnet_config['static_mapping'].items():
+ if 'ipv6_address' in mapping_config:
+ # Static address must be in subnet
+ if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet):
+ raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{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 'option' in subnet_config:
+ if 'vendor_option' in subnet_config['option']:
+ if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2:
+ raise ConfigError(f'No more than two Cisco tftp-servers should be defined for subnet "{subnet}"!')
+
+ # Subnets must be unique
+ if subnet in subnets:
+ raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!')
+
+ subnets.append(subnet)
+
+ # DHCPv6 requires at least one configured address range or one static mapping
+ # (FIXME: is not actually checked right now?)
+
+ # There must be one subnet connected to a listen interface if network is not disabled.
+ if 'disable' not in network_config:
+ if is_subnet_connected(subnet):
+ listen_ok = True
+
+ # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping
+ # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32"
+ net = ip_network(subnet)
+ for n in subnets:
+ net2 = ip_network(n)
+ if (net != net2):
+ if net.overlaps(net2):
+ raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2))
+
+ if not listen_ok:
+ raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\
+ 'this machine. At least one subnet6 must be connected such that '\
+ 'DHCPv6 listens on an interface!')
+
+
+ return None
+
+def generate(dhcpv6):
+ # bail out early - looks like removal from running config
+ if not dhcpv6 or 'disable' in dhcpv6:
+ return None
+
+ dhcpv6['lease_file'] = lease_file
+ dhcpv6['machine'] = os.uname().machine
+
+ # Create directory for lease file if necessary
+ lease_dir = os.path.dirname(lease_file)
+ if not os.path.isdir(lease_dir):
+ makedir(lease_dir, group='vyattacfg')
+ chmod_775(lease_dir)
+
+ # Ensure correct permissions on lease files + backups
+ for file in glob(lease_file_glob):
+ chown(file, user=user_group, group='vyattacfg')
+
+ # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way
+ if not os.path.exists(lease_file):
+ write_file(lease_file, '', user=user_group, group=user_group, mode=0o644)
+
+ render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group)
+ return None
+
+def apply(dhcpv6):
+ # bail out early - looks like removal from running config
+ service_name = 'kea-dhcp6-server.service'
+ if not dhcpv6 or 'disable' in dhcpv6:
+ # DHCP server is removed in the commit
+ call(f'systemctl stop {service_name}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ return None
+
+ call(f'systemctl restart {service_name}')
+
+ return None
+
+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/service_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py
new file mode 100644
index 0000000..5f53038
--- /dev/null
+++ b/src/conf_mode/service_dns_dynamic.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 re
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.configverify import dynamic_interface_pattern
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/ddclient/ddclient.conf'
+systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf'
+
+# Protocols that require zone
+zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi',
+ 'nfsn', 'nsupdate']
+zone_supported = zone_necessary + ['dnsexit2', 'zoneedit1']
+
+# Protocols that do not require username
+username_unnecessary = ['1984', 'cloudflare', 'cloudns', 'digitalocean', 'dnsexit2',
+ 'duckdns', 'freemyip', 'hetzner', 'keysystems', 'njalla',
+ 'nsupdate', 'regfishde']
+
+# Protocols that support TTL
+ttl_supported = ['cloudflare', 'dnsexit2', 'gandi', 'hetzner', 'godaddy', 'nfsn',
+ 'nsupdate']
+
+# Protocols that support both IPv4 and IPv6
+dualstack_supported = ['cloudflare', 'digitalocean', 'dnsexit2', 'duckdns',
+ 'dyndns2', 'easydns', 'freedns', 'hetzner', 'infomaniak',
+ 'njalla']
+
+# dyndns2 protocol in ddclient honors dual stack for selective servers
+# because of the way it is implemented in ddclient
+dyndns_dualstack_servers = ['members.dyndns.org', 'dynv6.com']
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'dns', 'dynamic']
+ if not conf.exists(base):
+ return None
+
+ dyndns = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ dyndns['config_file'] = config_file
+ return dyndns
+
+def verify(dyndns):
+ # bail out early - looks like removal from running config
+ if not dyndns or 'name' not in dyndns:
+ return None
+
+ # Dynamic DNS service provider - configuration validation
+ for service, config in dyndns['name'].items():
+ error_msg_req = f'is required for Dynamic DNS service "{service}"'
+ error_msg_uns = f'is not supported for Dynamic DNS service "{service}"'
+
+ for field in ['protocol', 'address', 'host_name']:
+ if field not in config:
+ raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
+
+ if not any(x in config['address'] for x in ['interface', 'web']):
+ raise ConfigError(f'Either "interface" or "web" {error_msg_req} '
+ f'with protocol "{config["protocol"]}"')
+ if all(x in config['address'] for x in ['interface', 'web']):
+ raise ConfigError(f'Both "interface" and "web" at the same time {error_msg_uns} '
+ f'with protocol "{config["protocol"]}"')
+
+ # If dyndns address is an interface, ensure that the interface exists
+ # and warn if a non-active dynamic interface is used
+ if 'interface' in config['address']:
+ tmp = re.compile(dynamic_interface_pattern)
+ # exclude check interface for dynamic interfaces
+ if tmp.match(config['address']['interface']):
+ if not interface_exists(config['address']['interface']):
+ Warning(f'Interface "{config["address"]["interface"]}" does not exist yet and '
+ f'cannot be used for Dynamic DNS service "{service}" until it is up!')
+ else:
+ verify_interface_exists(dyndns, config['address']['interface'])
+
+ if 'web' in config['address']:
+ # If 'skip' is specified, 'url' is required as well
+ if 'skip' in config['address']['web'] and 'url' not in config['address']['web']:
+ raise ConfigError(f'"url" along with "skip" {error_msg_req} '
+ f'with protocol "{config["protocol"]}"')
+ if 'url' in config['address']['web']:
+ # Warn if using checkip.dyndns.org, as it does not support HTTPS
+ # See: https://github.com/ddclient/ddclient/issues/597
+ if re.search("^(https?://)?checkip\.dyndns\.org", config['address']['web']['url']):
+ Warning(f'"checkip.dyndns.org" does not support HTTPS requests for IP address '
+ f'lookup. Please use a different IP address lookup service.')
+
+ # RFC2136 uses 'key' instead of 'password'
+ if config['protocol'] != 'nsupdate' and 'password' not in config:
+ raise ConfigError(f'"password" {error_msg_req}')
+
+ # Other RFC2136 specific configuration validation
+ if config['protocol'] == 'nsupdate':
+ if 'password' in config:
+ raise ConfigError(f'"password" {error_msg_uns} with protocol "{config["protocol"]}"')
+ for field in ['server', 'key']:
+ if field not in config:
+ raise ConfigError(f'"{field}" {error_msg_req} with protocol "{config["protocol"]}"')
+
+ if config['protocol'] in zone_necessary and 'zone' not in config:
+ raise ConfigError(f'"zone" {error_msg_req} with protocol "{config["protocol"]}"')
+
+ if config['protocol'] not in zone_supported and 'zone' in config:
+ raise ConfigError(f'"zone" {error_msg_uns} with protocol "{config["protocol"]}"')
+
+ if config['protocol'] not in username_unnecessary and 'username' not in config:
+ raise ConfigError(f'"username" {error_msg_req} with protocol "{config["protocol"]}"')
+
+ if config['protocol'] not in ttl_supported and 'ttl' in config:
+ raise ConfigError(f'"ttl" {error_msg_uns} with protocol "{config["protocol"]}"')
+
+ if config['ip_version'] == 'both':
+ if config['protocol'] not in dualstack_supported:
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} '
+ f'with protocol "{config["protocol"]}"')
+ # dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org)
+ if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers:
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} '
+ f'for "{config["server"]}" with protocol "{config["protocol"]}"')
+
+ if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']):
+ raise ConfigError(f'"expiry-time" must be greater than "wait-time" for '
+ f'Dynamic DNS service "{service}"')
+
+ return None
+
+def generate(dyndns):
+ # bail out early - looks like removal from running config
+ if not dyndns or 'name' not in dyndns:
+ return None
+
+ render(config_file, 'dns-dynamic/ddclient.conf.j2', dyndns, permission=0o600)
+ render(systemd_override, 'dns-dynamic/override.conf.j2', dyndns)
+ return None
+
+def apply(dyndns):
+ systemd_service = 'ddclient.service'
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ # bail out early - looks like removal from running config
+ if not dyndns or 'name' not in dyndns:
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ else:
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ return None
+
+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/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py
new file mode 100644
index 0000000..e3bdbc9
--- /dev/null
+++ b/src/conf_mode/service_dns_forwarding.py
@@ -0,0 +1,402 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+from glob import glob
+
+from vyos.config import Config
+from vyos.hostsd_client import Client as hostsd_client
+from vyos.template import render
+from vyos.template import bracketize_ipv6
+from vyos.utils.network import interface_exists
+from vyos.utils.process import call
+from vyos.utils.permission import chown
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+pdns_rec_user_group = 'pdns'
+pdns_rec_run_dir = '/run/pdns-recursor'
+pdns_rec_lua_conf_file = f'{pdns_rec_run_dir}/recursor.conf.lua'
+pdns_rec_hostsd_lua_conf_file = f'{pdns_rec_run_dir}/recursor.vyos-hostsd.conf.lua'
+pdns_rec_hostsd_zones_file = f'{pdns_rec_run_dir}/recursor.forward-zones.conf'
+pdns_rec_config_file = f'{pdns_rec_run_dir}/recursor.conf'
+pdns_rec_systemd_override = '/run/systemd/system/pdns-recursor.service.d/override.conf'
+
+hostsd_tag = 'static'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'dns', 'forwarding']
+ if not conf.exists(base):
+ return None
+
+ dns = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ dns['config_file'] = pdns_rec_config_file
+ dns['config_dir'] = os.path.dirname(pdns_rec_config_file)
+
+ # some additions to the default dictionary
+ if 'system' in dns:
+ base_nameservers = ['system', 'name-server']
+ if conf.exists(base_nameservers):
+ dns.update({'system_name_server': conf.return_values(base_nameservers)})
+
+ if 'authoritative_domain' in dns:
+ dns['authoritative_zones'] = []
+ dns['authoritative_zone_errors'] = []
+ for node in dns['authoritative_domain']:
+ zonedata = dns['authoritative_domain'][node]
+ if ('disable' in zonedata) or (not 'records' in zonedata):
+ continue
+ zone = {
+ 'name': node,
+ 'file': "{}/zone.{}.conf".format(pdns_rec_run_dir, node),
+ 'records': [],
+ }
+
+ recorddata = zonedata['records']
+
+ for rtype in [ 'a', 'aaaa', 'cname', 'mx', 'ns', 'ptr', 'txt', 'spf', 'srv', 'naptr' ]:
+ if rtype not in recorddata:
+ continue
+ for subnode in recorddata[rtype]:
+ if 'disable' in recorddata[rtype][subnode]:
+ continue
+
+ rdata = recorddata[rtype][subnode]
+
+ if rtype in [ 'a', 'aaaa' ]:
+ if not 'address' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one address is required')
+ continue
+
+ if subnode == 'any':
+ subnode = '*'
+
+ for address in rdata['address']:
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': address
+ })
+ elif rtype in ['cname', 'ptr']:
+ if not 'target' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: target is required')
+ continue
+
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': '{}.'.format(rdata['target'])
+ })
+ elif rtype == 'ns':
+ if not 'target' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one target is required')
+ continue
+
+ for target in rdata['target']:
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': f'{target}.'
+ })
+
+ elif rtype == 'mx':
+ if not 'server' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one server is required')
+ continue
+
+ for servername in rdata['server']:
+ serverdata = rdata['server'][servername]
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': '{} {}.'.format(serverdata['priority'], servername)
+ })
+ elif rtype == 'txt':
+ if not 'value' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one value is required')
+ continue
+
+ for value in rdata['value']:
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': "\"{}\"".format(value.replace("\"", "\\\""))
+ })
+ elif rtype == 'spf':
+ if not 'value' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: value is required')
+ continue
+
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': '"{}"'.format(rdata['value'].replace("\"", "\\\""))
+ })
+ elif rtype == 'srv':
+ if not 'entry' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one entry is required')
+ continue
+
+ for entryno in rdata['entry']:
+ entrydata = rdata['entry'][entryno]
+ if not 'hostname' in entrydata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: hostname is required for entry {entryno}')
+ continue
+
+ if not 'port' in entrydata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: port is required for entry {entryno}')
+ continue
+
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': '{} {} {} {}.'.format(entrydata['priority'], entrydata['weight'], entrydata['port'], entrydata['hostname'])
+ })
+ elif rtype == 'naptr':
+ if not 'rule' in rdata:
+ dns['authoritative_zone_errors'].append(f'{subnode}.{node}: at least one rule is required')
+ continue
+
+ for ruleno in rdata['rule']:
+ ruledata = rdata['rule'][ruleno]
+ flags = ""
+ if 'lookup-srv' in ruledata:
+ flags += "S"
+ if 'lookup-a' in ruledata:
+ flags += "A"
+ if 'resolve-uri' in ruledata:
+ flags += "U"
+ if 'protocol-specific' in ruledata:
+ flags += "P"
+
+ if 'order' in ruledata:
+ order = ruledata['order']
+ else:
+ order = ruleno
+
+ if 'regexp' in ruledata:
+ regexp= ruledata['regexp'].replace("\"", "\\\"")
+ else:
+ regexp = ''
+
+ if ruledata['replacement']:
+ replacement = '{}.'.format(ruledata['replacement'])
+ else:
+ replacement = ''
+
+ zone['records'].append({
+ 'name': subnode,
+ 'type': rtype.upper(),
+ 'ttl': rdata['ttl'],
+ 'value': '{} {} "{}" "{}" "{}" {}'.format(order, ruledata['preference'], flags, ruledata['service'], regexp, replacement)
+ })
+
+ dns['authoritative_zones'].append(zone)
+
+ if 'zone_cache' in dns:
+ # convert refresh interval to sec:
+ for _, zone_conf in dns['zone_cache'].items():
+ if 'options' in zone_conf \
+ and 'refresh' in zone_conf['options']:
+
+ if 'on_reload' in zone_conf['options']['refresh']:
+ interval = 0
+ else:
+ interval = zone_conf['options']['refresh']['interval']
+ zone_conf['options']['refresh']['interval'] = interval
+
+ return dns
+
+def verify(dns):
+ # bail out early - looks like removal from running config
+ if not dns:
+ return None
+
+ if 'listen_address' not in dns:
+ raise ConfigError('DNS forwarding requires a listen-address')
+
+ if 'allow_from' not in dns:
+ raise ConfigError('DNS forwarding requires an allow-from network')
+
+ # we can not use dict_search() when testing for domain servers
+ # as a domain will contains dot's which is out dictionary delimiter.
+ if 'domain' in dns:
+ for domain in dns['domain']:
+ if 'name_server' not in dns['domain'][domain]:
+ raise ConfigError(f'No server configured for domain {domain}!')
+
+ if 'dns64_prefix' in dns:
+ dns_prefix = dns['dns64_prefix'].split('/')[1]
+ # RFC 6147 requires prefix /96
+ if int(dns_prefix) != 96:
+ raise ConfigError('DNS 6to4 prefix must be of length /96')
+
+ if ('authoritative_zone_errors' in dns) and dns['authoritative_zone_errors']:
+ for error in dns['authoritative_zone_errors']:
+ print(error)
+ raise ConfigError('Invalid authoritative records have been defined')
+
+ if 'system' in dns:
+ if not 'system_name_server' in dns:
+ print('Warning: No "system name-server" configured')
+
+ if 'zone_cache' in dns:
+ for name, conf in dns['zone_cache'].items():
+ if ('source' not in conf) \
+ or ('url' in conf['source'] and 'axfr' in conf['source']):
+ raise ConfigError(f'Invalid configuration for zone "{name}": '
+ f'Please select one source type "url" or "axfr".')
+
+ return None
+
+
+def generate(dns):
+ # bail out early - looks like removal from running config
+ if not dns:
+ return None
+
+ render(pdns_rec_systemd_override, 'dns-forwarding/override.conf.j2', dns)
+
+ render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns,
+ user=pdns_rec_user_group, group=pdns_rec_user_group)
+
+ render(pdns_rec_config_file, 'dns-forwarding/recursor.conf.j2', dns,
+ user=pdns_rec_user_group, group=pdns_rec_user_group)
+
+ render(pdns_rec_lua_conf_file, 'dns-forwarding/recursor.conf.lua.j2', dns,
+ user=pdns_rec_user_group, group=pdns_rec_user_group)
+
+ for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'):
+ os.unlink(zone_filename)
+
+ if 'authoritative_zones' in dns:
+ for zone in dns['authoritative_zones']:
+ render(zone['file'], 'dns-forwarding/recursor.zone.conf.j2',
+ zone, user=pdns_rec_user_group, group=pdns_rec_user_group)
+
+
+ # if vyos-hostsd didn't create its files yet, create them (empty)
+ for file in [pdns_rec_hostsd_lua_conf_file, pdns_rec_hostsd_zones_file]:
+ with open(file, 'a'):
+ pass
+ chown(file, user=pdns_rec_user_group, group=pdns_rec_user_group)
+
+ return None
+
+def apply(dns):
+ systemd_service = 'pdns-recursor.service'
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ if not dns:
+ # DNS forwarding is removed in the commit
+ call(f'systemctl stop {systemd_service}')
+
+ if os.path.isfile(pdns_rec_config_file):
+ os.unlink(pdns_rec_config_file)
+
+ for zone_filename in glob(f'{pdns_rec_run_dir}/zone.*.conf'):
+ os.unlink(zone_filename)
+ else:
+ ### first apply vyos-hostsd config
+ hc = hostsd_client()
+
+ # add static nameservers to hostsd so they can be joined with other
+ # sources
+ hc.delete_name_servers([hostsd_tag])
+ if 'name_server' in dns:
+ # 'name_server' is of the form
+ # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...}
+ # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...]
+ nslist = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p)
+ for (h, p) in dns['name_server'].items()]
+ hc.add_name_servers({hostsd_tag: nslist})
+
+ # delete all nameserver tags
+ hc.delete_name_server_tags_recursor(hc.get_name_server_tags_recursor())
+
+ ## add nameserver tags - the order determines the nameserver order!
+ # our own tag (static)
+ hc.add_name_server_tags_recursor([hostsd_tag])
+
+ if 'system' in dns:
+ hc.add_name_server_tags_recursor(['system'])
+ else:
+ hc.delete_name_server_tags_recursor(['system'])
+
+ # add dhcp nameserver tags for configured interfaces
+ if 'system_name_server' in dns:
+ for interface in dns['system_name_server']:
+ # system_name_server key contains both IP addresses and interface
+ # names (DHCP) to use DNS servers. We need to check if the
+ # value is an interface name - only if this is the case, add the
+ # interface based DNS forwarder.
+ if interface_exists(interface):
+ hc.add_name_server_tags_recursor(['dhcp-' + interface,
+ 'dhcpv6-' + interface ])
+
+ # hostsd will generate the forward-zones file
+ # the list and keys() are required as get returns a dict, not list
+ hc.delete_forward_zones(list(hc.get_forward_zones().keys()))
+ if 'domain' in dns:
+ zones = dns['domain']
+ for domain in zones.keys():
+ # 'name_server' is of the form
+ # {'192.0.2.1': {'port': 53}, '2001:db8::1': {'port': 853}, ...}
+ # canonicalize them as ['192.0.2.1:53', '[2001:db8::1]:853', ...]
+ zones[domain]['name_server'] = [(lambda h, p: f"{bracketize_ipv6(h)}:{p['port']}")(h, p)
+ for (h, p) in zones[domain]['name_server'].items()]
+ hc.add_forward_zones(zones)
+
+ # hostsd generates NTAs for the authoritative zones
+ # the list and keys() are required as get returns a dict, not list
+ hc.delete_authoritative_zones(list(hc.get_authoritative_zones()))
+ if 'authoritative_zones' in dns:
+ hc.add_authoritative_zones(list(map(lambda zone: zone['name'], dns['authoritative_zones'])))
+
+ # call hostsd to generate forward-zones and its lua-config-file
+ hc.apply()
+
+ ### finally (re)start pdns-recursor
+ call(f'systemctl reload-or-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/service_event-handler.py b/src/conf_mode/service_event-handler.py
new file mode 100644
index 0000000..5028ef5
--- /dev/null
+++ b/src/conf_mode/service_event-handler.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 json
+from pathlib import Path
+
+from vyos.config import Config
+from vyos.utils.dict import dict_search
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+service_name = 'vyos-event-handler'
+service_conf = Path(f'/run/{service_name}.conf')
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'event-handler', 'event']
+ config = conf.get_config_dict(base,
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ return config
+
+
+def verify(config):
+ # bail out early - looks like removal from running config
+ if not config:
+ return None
+
+ for name, event_config in config.items():
+ if not dict_search('filter.pattern', event_config) or not dict_search(
+ 'script.path', event_config):
+ raise ConfigError(
+ 'Event-handler: both pattern and script path items are mandatory'
+ )
+
+ if dict_search('script.environment.message', event_config):
+ raise ConfigError(
+ 'Event-handler: "message" environment variable is reserved for log message text'
+ )
+
+
+def generate(config):
+ if not config:
+ # Remove old config and return
+ service_conf.unlink(missing_ok=True)
+ return None
+
+ # Write configuration file
+ conf_json = json.dumps(config, indent=4)
+ service_conf.write_text(conf_json)
+
+ return None
+
+
+def apply(config):
+ if config:
+ call(f'systemctl restart {service_name}.service')
+ else:
+ call(f'systemctl stop {service_name}.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/service_https.py b/src/conf_mode/service_https.py
new file mode 100644
index 0000000..9e58b4c
--- /dev/null
+++ b/src/conf_mode/service_https.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 socket
+import sys
+import json
+
+from time import sleep
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_pki_certificate
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.configverify import verify_pki_dh_parameters
+from vyos.defaults import api_config_state
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_private_key
+from vyos.pki import wrap_dh_parameters
+from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.process import call
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.network import check_port_availability
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/etc/nginx/sites-enabled/default'
+systemd_override = r'/run/systemd/system/nginx.service.d/override.conf'
+cert_dir = '/run/nginx/certs'
+
+user = 'www-data'
+group = 'www-data'
+
+systemd_service_api = '/run/systemd/system/vyos-http-api.service'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'https']
+ if not conf.exists(base):
+ return None
+
+ https = conf.get_config_dict(base, get_first_key=True,
+ key_mangling=('-', '_'),
+ with_pki=True)
+
+ # store path to API config file for later use in templates
+ https['api_config_state'] = api_config_state
+ # get fully qualified system hsotname
+ https['hostname'] = socket.getfqdn()
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**https.kwargs, recursive=True)
+ if 'api' not in https or 'graphql' not in https['api']:
+ del default_values['api']
+
+ # merge CLI and default dictionary
+ https = config_dict_merge(default_values, https)
+ return https
+
+def verify(https):
+ if https is None:
+ return None
+
+ if dict_search('certificates.certificate', https) != None:
+ verify_pki_certificate(https, https['certificates']['certificate'])
+
+ tmp = dict_search('certificates.ca_certificate', https)
+ if tmp != None: verify_pki_ca_certificate(https, tmp)
+
+ tmp = dict_search('certificates.dh_params', https)
+ if tmp != None: verify_pki_dh_parameters(https, tmp, 2048)
+
+ else:
+ Warning('No certificate specified, using build-in self-signed certificates. '\
+ 'Do not use them in a production environment!')
+
+ # Check if server port is already in use by a different appliaction
+ listen_address = ['0.0.0.0']
+ port = int(https['port'])
+ if 'listen_address' in https:
+ listen_address = https['listen_address']
+
+ for address in listen_address:
+ if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'):
+ raise ConfigError(f'TCP port "{port}" is used by another service!')
+
+ verify_vrf(https)
+
+ # Verify API server settings, if present
+ if 'api' in https:
+ keys = dict_search('api.keys.id', https)
+ gql_auth_type = dict_search('api.graphql.authentication.type', https)
+
+ # If "api graphql" is not defined and `gql_auth_type` is None,
+ # there's certainly no JWT auth option, and keys are required
+ jwt_auth = (gql_auth_type == "token")
+
+ # Check for incomplete key configurations in every case
+ valid_keys_exist = False
+ if keys:
+ for k in keys:
+ if 'key' not in keys[k]:
+ raise ConfigError(f'Missing HTTPS API key string for key id "{k}"')
+ else:
+ valid_keys_exist = True
+
+ # If only key-based methods are enabled,
+ # fail the commit if no valid key configurations are found
+ if (not valid_keys_exist) and (not jwt_auth):
+ raise ConfigError('At least one HTTPS API key is required unless GraphQL token authentication is enabled!')
+
+ if (not valid_keys_exist) and jwt_auth:
+ Warning(f'API keys are not configured: classic (non-GraphQL) API will be unavailable!')
+
+ return None
+
+def generate(https):
+ if https is None:
+ for file in [systemd_service_api, config_file, systemd_override]:
+ if os.path.exists(file):
+ os.unlink(file)
+ return None
+
+ if 'api' in https:
+ render(systemd_service_api, 'https/vyos-http-api.service.j2', https)
+ with open(api_config_state, 'w') as f:
+ json.dump(https['api'], f, indent=2)
+ else:
+ if os.path.exists(systemd_service_api):
+ os.unlink(systemd_service_api)
+
+ # get certificate data
+ if 'certificates' in https and 'certificate' in https['certificates']:
+ cert_name = https['certificates']['certificate']
+ pki_cert = https['pki']['certificate'][cert_name]
+
+ cert_path = os.path.join(cert_dir, f'{cert_name}_cert.pem')
+ key_path = os.path.join(cert_dir, f'{cert_name}_key.pem')
+
+ server_cert = str(wrap_certificate(pki_cert['certificate']))
+
+ # Append CA certificate if specified to form a full chain
+ if 'ca_certificate' in https['certificates']:
+ ca_cert = https['certificates']['ca_certificate']
+ server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate']))
+
+ write_file(cert_path, server_cert, user=user, group=group, mode=0o644)
+ write_file(key_path, wrap_private_key(pki_cert['private']['key']),
+ user=user, group=group, mode=0o600)
+
+ tmp_path = {'cert_path': cert_path, 'key_path': key_path}
+
+ if 'dh_params' in https['certificates']:
+ dh_name = https['certificates']['dh_params']
+ pki_dh = https['pki']['dh'][dh_name]
+ if 'parameters' in pki_dh:
+ dh_path = os.path.join(cert_dir, f'{dh_name}_dh.pem')
+ write_file(dh_path, wrap_dh_parameters(pki_dh['parameters']),
+ user=user, group=group, mode=0o600)
+ tmp_path.update({'dh_file' : dh_path})
+
+ https['certificates'].update(tmp_path)
+
+ render(config_file, 'https/nginx.default.j2', https)
+ render(systemd_override, 'https/override.conf.j2', https)
+ return None
+
+def apply(https):
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+ http_api_service_name = 'vyos-http-api.service'
+ https_service_name = 'nginx.service'
+
+ if https is None:
+ if is_systemd_service_active(http_api_service_name):
+ call(f'systemctl stop {http_api_service_name}')
+ call(f'systemctl stop {https_service_name}')
+ return
+
+ if 'api' in https:
+ call(f'systemctl reload-or-restart {http_api_service_name}')
+ # Let uvicorn settle before (possibly) restarting nginx
+ sleep(1)
+ elif is_systemd_service_active(http_api_service_name):
+ call(f'systemctl stop {http_api_service_name}')
+
+ call(f'systemctl reload-or-restart {https_service_name}')
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/conf_mode/service_ids_ddos-protection.py b/src/conf_mode/service_ids_ddos-protection.py
new file mode 100644
index 0000000..276a71f
--- /dev/null
+++ b/src/conf_mode/service_ids_ddos-protection.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 os
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/fastnetmon/fastnetmon.conf'
+networks_list = r'/run/fastnetmon/networks_list'
+excluded_networks_list = r'/run/fastnetmon/excluded_networks_list'
+attack_dir = '/var/log/fastnetmon_attacks'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'ids', 'ddos-protection']
+ if not conf.exists(base):
+ return None
+
+ fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return fastnetmon
+
+def verify(fastnetmon):
+ if not fastnetmon:
+ return None
+
+ if 'mode' not in fastnetmon:
+ raise ConfigError('Specify operating mode!')
+
+ if fastnetmon.get('mode') == 'mirror' and 'listen_interface' not in fastnetmon:
+ raise ConfigError("Incorrect settings for 'mode mirror': must specify interface(s) for traffic mirroring")
+
+ if fastnetmon.get('mode') == 'sflow' and 'listen_address' not in fastnetmon.get('sflow', {}):
+ raise ConfigError("Incorrect settings for 'mode sflow': must specify sFlow 'listen-address'")
+
+ if 'alert_script' in fastnetmon:
+ if os.path.isfile(fastnetmon['alert_script']):
+ # Check script permissions
+ if not os.access(fastnetmon['alert_script'], os.X_OK):
+ raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script']))
+ else:
+ raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon))
+
+def generate(fastnetmon):
+ if not fastnetmon:
+ for file in [config_file, networks_list]:
+ if os.path.isfile(file):
+ os.unlink(file)
+
+ return None
+
+ # Create dir for log attack details
+ if not os.path.exists(attack_dir):
+ os.mkdir(attack_dir)
+
+ render(config_file, 'ids/fastnetmon.j2', fastnetmon)
+ render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon)
+ render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon)
+ return None
+
+def apply(fastnetmon):
+ systemd_service = 'fastnetmon.service'
+ if not fastnetmon:
+ # Stop fastnetmon service if removed
+ call(f'systemctl stop {systemd_service}')
+ else:
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ return None
+
+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/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py
new file mode 100644
index 0000000..c7e3ef0
--- /dev/null
+++ b/src/conf_mode/service_ipoe-server.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_accel_dict
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.accel_ppp_util import get_pools_in_order
+from vyos.accel_ppp_util import verify_accel_ppp_name_servers
+from vyos.accel_ppp_util import verify_accel_ppp_wins_servers
+from vyos.accel_ppp_util import verify_accel_ppp_ip_pool
+from vyos.accel_ppp_util import verify_accel_ppp_authentication
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+ipoe_conf = '/run/accel-pppd/ipoe.conf'
+ipoe_chap_secrets = '/run/accel-pppd/ipoe.chap-secrets'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'ipoe-server']
+ if not conf.exists(base):
+ return None
+
+ # retrieve common dictionary keys
+ ipoe = get_accel_dict(conf, base, ipoe_chap_secrets)
+
+ if dict_search('client_ip_pool', ipoe):
+ # Multiple named pools require ordered values T5099
+ ipoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', ipoe))
+
+ ipoe['server_type'] = 'ipoe'
+ return ipoe
+
+
+def verify(ipoe):
+ if not ipoe:
+ return None
+
+ if 'interface' not in ipoe:
+ raise ConfigError('No IPoE interface configured')
+
+ for interface, iface_config in ipoe['interface'].items():
+ verify_interface_exists(ipoe, interface, warning_only=True)
+ if 'client_subnet' in iface_config and 'vlan' in iface_config:
+ raise ConfigError('Option "client-subnet" and "vlan" are mutually exclusive, '
+ 'use "client-ip-pool" instead!')
+ if 'vlan_mon' in iface_config and not 'vlan' in iface_config:
+ raise ConfigError('Option "vlan-mon" requires "vlan" to be set!')
+
+ verify_accel_ppp_authentication(ipoe, local_users=False)
+ verify_accel_ppp_ip_pool(ipoe)
+ verify_accel_ppp_name_servers(ipoe)
+ verify_accel_ppp_wins_servers(ipoe)
+
+ return None
+
+
+def generate(ipoe):
+ if not ipoe:
+ return None
+
+ render(ipoe_conf, 'accel-ppp/ipoe.config.j2', ipoe)
+
+ if dict_search('authentication.mode', ipoe) == 'local':
+ render(ipoe_chap_secrets, 'accel-ppp/chap-secrets.ipoe.j2',
+ ipoe, permission=0o640)
+ return None
+
+
+def apply(ipoe):
+ systemd_service = 'accel-ppp@ipoe.service'
+ if ipoe == None:
+ call(f'systemctl stop {systemd_service}')
+ for file in [ipoe_conf, ipoe_chap_secrets]:
+ if os.path.exists(file):
+ os.unlink(file)
+
+ return None
+
+ call(f'systemctl reload-or-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/service_lldp.py b/src/conf_mode/service_lldp.py
new file mode 100644
index 0000000..04b1db8
--- /dev/null
+++ b/src/conf_mode/service_lldp.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2017-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.network import is_loopback_addr
+from vyos.version import get_version_data
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = "/etc/default/lldpd"
+vyos_config_file = "/etc/lldpd.d/01-vyos.conf"
+base = ['service', 'lldp']
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ if not conf.exists(base):
+ return {}
+
+ lldp = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ if conf.exists(['service', 'snmp']):
+ lldp['system_snmp_enabled'] = ''
+
+ version_data = get_version_data()
+ lldp['version'] = version_data['version']
+
+ # prune location information if not set by user
+ for interface in lldp.get('interface', []):
+ if lldp.from_defaults(['interface', interface, 'location']):
+ del lldp['interface'][interface]['location']
+ elif lldp.from_defaults(['interface', interface, 'location','coordinate_based']):
+ del lldp['interface'][interface]['location']['coordinate_based']
+
+ return lldp
+
+def verify(lldp):
+ # bail out early - looks like removal from running config
+ if lldp is None:
+ return
+
+ if 'management_address' in lldp:
+ for address in lldp['management_address']:
+ message = f'LLDP management address "{address}" is invalid'
+ if is_loopback_addr(address):
+ Warning(f'{message} - loopback address')
+ elif not is_addr_assigned(address):
+ Warning(f'{message} - not assigned to any interface')
+
+ if 'interface' in lldp:
+ for interface, interface_config in lldp['interface'].items():
+ # bail out early if no location info present in interface config
+ if 'location' not in interface_config:
+ continue
+ if 'coordinate_based' in interface_config['location']:
+ if not {'latitude', 'latitude'} <= set(interface_config['location']['coordinate_based']):
+ raise ConfigError(f'Must define both longitude and latitude for "{interface}" location!')
+
+ # check options
+ if 'snmp' in lldp:
+ if 'system_snmp_enabled' not in lldp:
+ raise ConfigError('SNMP must be configured to enable LLDP SNMP!')
+
+
+def generate(lldp):
+ # bail out early - looks like removal from running config
+ if lldp is None:
+ return
+
+ render(config_file, 'lldp/lldpd.j2', lldp)
+ render(vyos_config_file, 'lldp/vyos.conf.j2', lldp)
+
+def apply(lldp):
+ systemd_service = 'lldpd.service'
+ if lldp:
+ # start/restart lldp service
+ call(f'systemctl restart {systemd_service}')
+ else:
+ # LLDP service has been terminated
+ call(f'systemctl stop {systemd_service}')
+ if os.path.isfile(config_file):
+ os.unlink(config_file)
+ if os.path.isfile(vyos_config_file):
+ os.unlink(vyos_config_file)
+
+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/service_mdns_repeater.py b/src/conf_mode/service_mdns_repeater.py
new file mode 100644
index 0000000..b0ece03
--- /dev/null
+++ b/src/conf_mode/service_mdns_repeater.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2017-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
+
+from json import loads
+from sys import exit
+from netifaces import ifaddresses, AF_INET, AF_INET6
+
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.ifconfig.vrrp import VRRP
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/run/avahi-daemon/avahi-daemon.conf'
+systemd_override = r'/run/systemd/system/avahi-daemon.service.d/override.conf'
+vrrp_running_file = '/run/mdns_vrrp_active'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'mdns', 'repeater']
+ if not conf.exists(base):
+ return None
+
+ mdns = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ if mdns:
+ mdns['vrrp_exists'] = conf.exists('high-availability vrrp')
+ mdns['config_file'] = config_file
+
+ return mdns
+
+def verify(mdns):
+ if not mdns or 'disable' in mdns:
+ return None
+
+ # We need at least two interfaces to repeat mDNS advertisments
+ if 'interface' not in mdns or len(mdns['interface']) < 2:
+ raise ConfigError('mDNS repeater requires at least 2 configured interfaces!')
+
+ # For mdns-repeater to work it is essential that the interfaces has
+ # an IPv4 address assigned
+ for interface in mdns['interface']:
+ verify_interface_exists(mdns, interface)
+
+ if mdns['ip_version'] in ['ipv4', 'both'] and AF_INET not in ifaddresses(interface):
+ raise ConfigError('mDNS repeater requires an IPv4 address to be '
+ f'configured on interface "{interface}"')
+
+ if mdns['ip_version'] in ['ipv6', 'both'] and AF_INET6 not in ifaddresses(interface):
+ raise ConfigError('mDNS repeater requires an IPv6 address to be '
+ f'configured on interface "{interface}"')
+
+ return None
+
+# Get VRRP states from interfaces, returns only interfaces where state is MASTER
+def get_vrrp_master(interfaces):
+ json_data = loads(VRRP.collect('json'))
+ for group in json_data:
+ if 'data' in group:
+ if 'ifp_ifname' in group['data']:
+ iface = group['data']['ifp_ifname']
+ state = group['data']['state'] # 2 = Master
+ if iface in interfaces and state != 2:
+ interfaces.remove(iface)
+ return interfaces
+
+def generate(mdns):
+ if not mdns:
+ return None
+
+ if 'disable' in mdns:
+ print('Warning: mDNS repeater will be deactivated because it is disabled')
+ return None
+
+ if mdns['vrrp_exists'] and 'vrrp_disable' in mdns:
+ mdns['interface'] = get_vrrp_master(mdns['interface'])
+
+ if len(mdns['interface']) < 2:
+ return None
+
+ render(config_file, 'mdns-repeater/avahi-daemon.conf.j2', mdns)
+ render(systemd_override, 'mdns-repeater/override.conf.j2', mdns)
+ return None
+
+def apply(mdns):
+ systemd_service = 'avahi-daemon.service'
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ if not mdns or 'disable' in mdns:
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+
+ if os.path.exists(vrrp_running_file):
+ os.unlink(vrrp_running_file)
+ else:
+ if 'vrrp_disable' not in mdns and os.path.exists(vrrp_running_file):
+ os.unlink(vrrp_running_file)
+
+ if mdns['vrrp_exists'] and 'vrrp_disable' in mdns:
+ if not os.path.exists(vrrp_running_file):
+ os.mknod(vrrp_running_file) # vrrp script looks for this file to update mdns repeater
+
+ if len(mdns['interface']) < 2:
+ call(f'systemctl stop {systemd_service}')
+ return None
+
+ call(f'systemctl restart {systemd_service}')
+
+ return None
+
+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/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py
new file mode 100644
index 0000000..db870aa
--- /dev/null
+++ b/src/conf_mode/service_monitoring_telegraf.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 socket
+import json
+
+from sys import exit
+from shutil import rmtree
+
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.ifconfig import Section
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.permission import chown
+from vyos.utils.process import cmd
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+cache_dir = f'/etc/telegraf/.cache'
+config_telegraf = f'/run/telegraf/telegraf.conf'
+custom_scripts_dir = '/etc/telegraf/custom_scripts'
+syslog_telegraf = '/etc/rsyslog.d/50-telegraf.conf'
+systemd_override = '/run/systemd/system/telegraf.service.d/10-override.conf'
+
+def get_nft_filter_chains():
+ """ Get nft chains for table filter """
+ try:
+ nft = cmd('nft --json list table ip vyos_filter')
+ except Exception:
+ print('nft table ip vyos_filter not found')
+ return []
+ nft = json.loads(nft)
+ chain_list = []
+
+ for output in nft['nftables']:
+ if 'chain' in output:
+ chain = output['chain']['name']
+ chain_list.append(chain)
+
+ return chain_list
+
+def get_hostname() -> str:
+ try:
+ hostname = socket.getfqdn()
+ except socket.gaierror:
+ hostname = socket.gethostname()
+ return hostname
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'monitoring', 'telegraf']
+ 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)
+
+ tmp = is_node_changed(conf, base + ['vrf'])
+ if tmp: monitoring.update({'restart_required': {}})
+
+ # 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)
+
+ monitoring['custom_scripts_dir'] = custom_scripts_dir
+ monitoring['hostname'] = get_hostname()
+ monitoring['interfaces_ethernet'] = Section.interfaces('ethernet', vlan=False)
+ if conf.exists('firewall'):
+ monitoring['nft_chains'] = get_nft_filter_chains()
+
+ # Redefine azure group-metrics 'single-table' and 'table-per-metric'
+ if 'azure_data_explorer' in monitoring:
+ if 'single-table' in monitoring['azure_data_explorer']['group_metrics']:
+ monitoring['azure_data_explorer']['group_metrics'] = 'SingleTable'
+ else:
+ monitoring['azure_data_explorer']['group_metrics'] = 'TablePerMetric'
+ # Set azure env
+ if 'authentication' in monitoring['azure_data_explorer']:
+ auth_config = monitoring['azure_data_explorer']['authentication']
+ if {'client_id', 'client_secret', 'tenant_id'} <= set(auth_config):
+ os.environ['AZURE_CLIENT_ID'] = auth_config['client_id']
+ os.environ['AZURE_CLIENT_SECRET'] = auth_config['client_secret']
+ os.environ['AZURE_TENANT_ID'] = auth_config['tenant_id']
+
+ # Ignore default XML values if config doesn't exists
+ # Delete key from dict
+ if not conf.exists(base + ['influxdb']):
+ del monitoring['influxdb']
+
+ if not conf.exists(base + ['prometheus-client']):
+ del monitoring['prometheus_client']
+
+ if not conf.exists(base + ['azure-data-explorer']):
+ del monitoring['azure_data_explorer']
+
+ if not conf.exists(base + ['loki']):
+ del monitoring['loki']
+
+ return monitoring
+
+def verify(monitoring):
+ # bail out early - looks like removal from running config
+ if not monitoring:
+ return None
+
+ verify_vrf(monitoring)
+
+ # Verify influxdb
+ if 'influxdb' in monitoring:
+ if 'authentication' not in monitoring['influxdb'] or \
+ 'organization' not in monitoring['influxdb']['authentication'] or \
+ 'token' not in monitoring['influxdb']['authentication']:
+ raise ConfigError(f'influxdb authentication "organization and token" are mandatory!')
+
+ if 'url' not in monitoring['influxdb']:
+ raise ConfigError(f'Monitoring influxdb "url" is mandatory!')
+
+ # Verify azure-data-explorer
+ if 'azure_data_explorer' in monitoring:
+ if 'authentication' not in monitoring['azure_data_explorer'] or \
+ 'client_id' not in monitoring['azure_data_explorer']['authentication'] or \
+ 'client_secret' not in monitoring['azure_data_explorer']['authentication'] or \
+ 'tenant_id' not in monitoring['azure_data_explorer']['authentication']:
+ raise ConfigError(f'Authentication "client-id, client-secret and tenant-id" are mandatory!')
+
+ if 'database' not in monitoring['azure_data_explorer']:
+ raise ConfigError(f'Monitoring "database" is mandatory!')
+
+ if 'url' not in monitoring['azure_data_explorer']:
+ raise ConfigError(f'Monitoring "url" is mandatory!')
+
+ if monitoring['azure_data_explorer']['group_metrics'] == 'SingleTable' and \
+ 'table' not in monitoring['azure_data_explorer']:
+ raise ConfigError(f'Monitoring "table" name for single-table mode is mandatory!')
+
+ # Verify Splunk
+ if 'splunk' in monitoring:
+ if 'authentication' not in monitoring['splunk'] or \
+ 'token' not in monitoring['splunk']['authentication']:
+ raise ConfigError(f'Authentication "organization and token" are mandatory!')
+
+ if 'url' not in monitoring['splunk']:
+ raise ConfigError(f'Monitoring splunk "url" is mandatory!')
+
+ # Verify Loki
+ if 'loki' in monitoring:
+ if 'url' not in monitoring['loki']:
+ raise ConfigError(f'Monitoring loki "url" is mandatory!')
+ if 'authentication' in monitoring['loki']:
+ if (
+ 'username' not in monitoring['loki']['authentication']
+ or 'password' not in monitoring['loki']['authentication']
+ ):
+ raise ConfigError(
+ f'Authentication "username" and "password" are mandatory!'
+ )
+
+ return None
+
+def generate(monitoring):
+ if not monitoring:
+ # Delete config and systemd files
+ config_files = [config_telegraf, systemd_override, syslog_telegraf]
+ for file in config_files:
+ if os.path.isfile(file):
+ os.unlink(file)
+
+ # Delete old directories
+ if os.path.isdir(cache_dir):
+ rmtree(cache_dir, ignore_errors=True)
+
+ return None
+
+ # Create telegraf cache dir
+ if not os.path.exists(cache_dir):
+ os.makedirs(cache_dir)
+
+ chown(cache_dir, 'telegraf', 'telegraf')
+
+ # Create custome scripts dir
+ if not os.path.exists(custom_scripts_dir):
+ os.mkdir(custom_scripts_dir)
+
+ # Render telegraf configuration and systemd override
+ render(config_telegraf, 'telegraf/telegraf.j2', monitoring, user='telegraf', group='telegraf')
+ render(systemd_override, 'telegraf/override.conf.j2', monitoring)
+ render(syslog_telegraf, 'telegraf/syslog_telegraf.j2', monitoring)
+
+ return None
+
+def apply(monitoring):
+ # Reload systemd manager configuration
+ systemd_service = 'telegraf.service'
+ call('systemctl daemon-reload')
+ if not monitoring:
+ call(f'systemctl stop {systemd_service}')
+ return
+
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'restart_required' in monitoring:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {systemd_service}')
+
+ # Telegraf include custom rsyslog config changes
+ call('systemctl reload-or-restart rsyslog')
+
+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/service_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py
new file mode 100644
index 0000000..98d8a32
--- /dev/null
+++ b/src/conf_mode/service_monitoring_zabbix-agent.py
@@ -0,0 +1,98 @@
+#!/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 os
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+service_name = 'zabbix-agent2'
+service_conf = f'/run/zabbix/{service_name}.conf'
+systemd_override = r'/run/systemd/system/zabbix-agent2.service.d/10-override.conf'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'monitoring', 'zabbix-agent']
+
+ if not conf.exists(base):
+ return None
+
+ config = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+
+ # Cut the / from the end, /tmp/ => /tmp
+ if 'directory' in config and config['directory'].endswith('/'):
+ config['directory'] = config['directory'][:-1]
+
+ return config
+
+
+def verify(config):
+ # bail out early - looks like removal from running config
+ if config is None:
+ return
+
+ if 'server' not in config:
+ raise ConfigError('Server is required!')
+
+
+def generate(config):
+ # bail out early - looks like removal from running config
+ if config is None:
+ # Remove old config and return
+ config_files = [service_conf, systemd_override]
+ for file in config_files:
+ if os.path.isfile(file):
+ os.unlink(file)
+
+ return None
+
+ # Write configuration file
+ render(service_conf, 'zabbix-agent/zabbix-agent.conf.j2', config)
+ render(systemd_override, 'zabbix-agent/10-override.conf.j2', config)
+
+ return None
+
+
+def apply(config):
+ call('systemctl daemon-reload')
+ if config:
+ call(f'systemctl restart {service_name}.service')
+ else:
+ call(f'systemctl stop {service_name}.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/service_ndp-proxy.py b/src/conf_mode/service_ndp-proxy.py
new file mode 100644
index 0000000..024ad79
--- /dev/null
+++ b/src/conf_mode/service_ndp-proxy.py
@@ -0,0 +1,91 @@
+#!/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 os
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+systemd_service = 'ndppd.service'
+ndppd_config = '/run/ndppd/ndppd.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'ndp-proxy']
+ if not conf.exists(base):
+ return None
+
+ ndpp = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return ndpp
+
+def verify(ndpp):
+ if not ndpp:
+ return None
+
+ if 'interface' in ndpp:
+ for interface, interface_config in ndpp['interface'].items():
+ verify_interface_exists(ndpp, interface)
+
+ if 'rule' in interface_config:
+ for rule, rule_config in interface_config['rule'].items():
+ if rule_config['mode'] == 'interface' and 'interface' not in rule_config:
+ raise ConfigError(f'Rule "{rule}" uses interface mode but no interface defined!')
+
+ if rule_config['mode'] != 'interface' and 'interface' in rule_config:
+ if interface_config['mode'] != 'interface' and 'interface' in interface_config:
+ raise ConfigError(f'Rule "{rule}" does not use interface mode, thus interface can not be defined!')
+
+ return None
+
+def generate(ndpp):
+ if not ndpp:
+ return None
+
+ render(ndppd_config, 'ndppd/ndppd.conf.j2', ndpp)
+ return None
+
+def apply(ndpp):
+ if not ndpp:
+ call(f'systemctl stop {systemd_service}')
+ if os.path.isfile(ndppd_config):
+ os.unlink(ndppd_config)
+ return None
+
+ call(f'systemctl reload-or-restart {systemd_service}')
+ return None
+
+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/service_ntp.py b/src/conf_mode/service_ntp.py
new file mode 100644
index 0000000..32563aa
--- /dev/null
+++ b/src/conf_mode/service_ntp.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_interface_exists
+from vyos.utils.process import call
+from vyos.utils.permission import chmod_750
+from vyos.utils.network import get_interface_config
+from vyos.template import render
+from vyos.template import is_ipv4
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/chrony/chrony.conf'
+systemd_override = r'/run/systemd/system/chrony.service.d/override.conf'
+user_group = '_chrony'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'ntp']
+ if not conf.exists(base):
+ return None
+
+ ntp = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+ ntp['config_file'] = config_file
+ ntp['user'] = user_group
+
+ tmp = is_node_changed(conf, base + ['vrf'])
+ if tmp: ntp.update({'restart_required': {}})
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**ntp.kwargs, recursive=True)
+ # Only defined PTP default port, if PTP feature is in use
+ if 'ptp' not in ntp:
+ del default_values['ptp']
+
+ ntp = config_dict_merge(default_values, ntp)
+ return ntp
+
+def verify(ntp):
+ # bail out early - looks like removal from running config
+ if not ntp:
+ return None
+
+ if 'server' not in ntp:
+ raise ConfigError('NTP server not configured')
+
+ verify_vrf(ntp)
+
+ if 'interface' in ntp:
+ # If ntpd should listen on a given interface, ensure it exists
+ interface = ntp['interface']
+ verify_interface_exists(ntp, interface)
+
+ # If we run in a VRF, our interface must belong to this VRF, too
+ if 'vrf' in ntp:
+ tmp = get_interface_config(interface)
+ vrf_name = ntp['vrf']
+ if 'master' not in tmp or tmp['master'] != vrf_name:
+ raise ConfigError(f'NTP runs in VRF "{vrf_name}" - "{interface}" '\
+ f'does not belong to this VRF!')
+
+ if 'listen_address' in ntp:
+ ipv4_addresses = 0
+ ipv6_addresses = 0
+ for address in ntp['listen_address']:
+ if is_ipv4(address):
+ ipv4_addresses += 1
+ else:
+ ipv6_addresses += 1
+ if ipv4_addresses > 1:
+ raise ConfigError(f'NTP Only admits one ipv4 value for listen-address parameter ')
+ if ipv6_addresses > 1:
+ raise ConfigError(f'NTP Only admits one ipv6 value for listen-address parameter ')
+
+ if 'server' in ntp:
+ for host, server in ntp['server'].items():
+ if 'ptp' in server:
+ if 'ptp' not in ntp:
+ raise ConfigError('PTP must be enabled for the NTP service '\
+ f'before it can be used for server "{host}"')
+ else:
+ break
+
+ return None
+
+def generate(ntp):
+ # bail out early - looks like removal from running config
+ if not ntp:
+ return None
+
+ render(config_file, 'chrony/chrony.conf.j2', ntp, user=user_group, group=user_group)
+ render(systemd_override, 'chrony/override.conf.j2', ntp, user=user_group, group=user_group)
+
+ # Ensure proper permission for chrony command socket
+ config_dir = os.path.dirname(config_file)
+ chmod_750(config_dir)
+
+ return None
+
+def apply(ntp):
+ systemd_service = 'chrony.service'
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ if not ntp:
+ # NTP support is removed in the commit
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ if os.path.isfile(systemd_override):
+ os.unlink(systemd_override)
+ return
+
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'restart_required' in ntp:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {systemd_service}')
+ return None
+
+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/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py
new file mode 100644
index 0000000..ac697c5
--- /dev/null
+++ b/src/conf_mode/service_pppoe-server.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_accel_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.accel_ppp_util import verify_accel_ppp_name_servers
+from vyos.accel_ppp_util import verify_accel_ppp_wins_servers
+from vyos.accel_ppp_util import verify_accel_ppp_authentication
+from vyos.accel_ppp_util import verify_accel_ppp_ip_pool
+from vyos.accel_ppp_util import get_pools_in_order
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+pppoe_conf = r'/run/accel-pppd/pppoe.conf'
+pppoe_chap_secrets = r'/run/accel-pppd/pppoe.chap-secrets'
+
+def convert_pado_delay(pado_delay):
+ new_pado_delay = {'delays_without_sessions': [],
+ 'delays_with_sessions': []}
+ for delay, sessions in pado_delay.items():
+ if not sessions:
+ new_pado_delay['delays_without_sessions'].append(delay)
+ else:
+ new_pado_delay['delays_with_sessions'].append((delay, int(sessions['sessions'])))
+ return new_pado_delay
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'pppoe-server']
+ if not conf.exists(base):
+ return None
+
+ # retrieve common dictionary keys
+ pppoe = get_accel_dict(conf, base, pppoe_chap_secrets)
+
+ if dict_search('client_ip_pool', pppoe):
+ # Multiple named pools require ordered values T5099
+ pppoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', pppoe))
+
+ if dict_search('pado_delay', pppoe):
+ pado_delay = dict_search('pado_delay', pppoe)
+ pppoe['pado_delay'] = convert_pado_delay(pado_delay)
+
+ # reload-or-restart does not implemented in accel-ppp
+ # use this workaround until it will be implemented
+ # https://phabricator.accel-ppp.org/T3
+ conditions = [is_node_changed(conf, base + ['client-ip-pool']),
+ is_node_changed(conf, base + ['client-ipv6-pool']),
+ is_node_changed(conf, base + ['interface'])]
+ if any(conditions):
+ pppoe.update({'restart_required': {}})
+ pppoe['server_type'] = 'pppoe'
+ return pppoe
+
+def verify_pado_delay(pppoe):
+ if 'pado_delay' in pppoe:
+ pado_delay = pppoe['pado_delay']
+
+ delays_without_sessions = pado_delay['delays_without_sessions']
+ if 'disable' in delays_without_sessions:
+ raise ConfigError(
+ 'Number of sessions must be specified for "pado-delay disable"'
+ )
+
+ if len(delays_without_sessions) > 1:
+ raise ConfigError(
+ f'Cannot add more then ONE pado-delay without sessions, '
+ f'but {len(delays_without_sessions)} were set'
+ )
+
+ if 'disable' in [delay[0] for delay in pado_delay['delays_with_sessions']]:
+ # need to sort delays by sessions to verify if there is no delay
+ # for sessions after disabling
+ sorted_pado_delay = sorted(pado_delay['delays_with_sessions'], key=lambda k_v: k_v[1])
+ last_delay = sorted_pado_delay[-1]
+
+ if last_delay[0] != 'disable':
+ raise ConfigError(
+ f'Cannot add pado-delay after disabled sessions, but '
+ f'"pado-delay {last_delay[0]} sessions {last_delay[1]}" was set'
+ )
+
+def verify(pppoe):
+ if not pppoe:
+ return None
+
+ verify_accel_ppp_authentication(pppoe)
+ verify_accel_ppp_ip_pool(pppoe)
+ verify_accel_ppp_name_servers(pppoe)
+ verify_accel_ppp_wins_servers(pppoe)
+ verify_pado_delay(pppoe)
+
+ if 'interface' not in pppoe:
+ raise ConfigError('At least one listen interface must be defined!')
+
+ # Check is interface exists in the system
+ for interface, interface_config in pppoe['interface'].items():
+ verify_interface_exists(pppoe, interface, warning_only=True)
+
+ if 'vlan_mon' in interface_config and not 'vlan' in interface_config:
+ raise ConfigError('Option "vlan-mon" requires "vlan" to be set!')
+
+ return None
+
+
+def generate(pppoe):
+ if not pppoe:
+ return None
+
+ render(pppoe_conf, 'accel-ppp/pppoe.config.j2', pppoe)
+
+ if dict_search('authentication.mode', pppoe) == 'local':
+ render(pppoe_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',
+ pppoe, permission=0o640)
+ return None
+
+
+def apply(pppoe):
+ systemd_service = 'accel-ppp@pppoe.service'
+ if not pppoe:
+ call(f'systemctl stop {systemd_service}')
+ for file in [pppoe_conf, pppoe_chap_secrets]:
+ if os.path.exists(file):
+ os.unlink(file)
+ return None
+
+ if 'restart_required' in pppoe:
+ call(f'systemctl restart {systemd_service}')
+ else:
+ call(f'systemctl reload-or-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/service_router-advert.py b/src/conf_mode/service_router-advert.py
new file mode 100644
index 0000000..88d767b
--- /dev/null
+++ b/src/conf_mode/service_router-advert.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+from ipaddress import IPv6Network
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/radvd/radvd.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'router-advert']
+ rtradv = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return rtradv
+
+def verify(rtradv):
+ if not rtradv:
+ return None
+
+ if 'interface' not in rtradv:
+ return None
+
+ for interface, interface_config in rtradv['interface'].items():
+ interval_max = int(interface_config['interval']['max'])
+
+ if 'prefix' in interface_config:
+ for prefix, prefix_config in interface_config['prefix'].items():
+ valid_lifetime = prefix_config['valid_lifetime']
+ if valid_lifetime == 'infinity':
+ valid_lifetime = 4294967295
+
+ preferred_lifetime = prefix_config['preferred_lifetime']
+ if preferred_lifetime == 'infinity':
+ preferred_lifetime = 4294967295
+
+ if not (int(valid_lifetime) >= int(preferred_lifetime)):
+ raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime')
+
+ if 'nat64prefix' in interface_config:
+ nat64_supported_lengths = [32, 40, 48, 56, 64, 96]
+ for prefix, prefix_config in interface_config['nat64prefix'].items():
+ if IPv6Network(prefix).prefixlen not in nat64_supported_lengths:
+ raise ConfigError(f'Invalid NAT64 prefix length for "{prefix}", can only be one of: /' + ', /'.join(nat64_supported_lengths))
+
+ if int(prefix_config['valid_lifetime']) < interval_max:
+ raise ConfigError(f'NAT64 valid-lifetime must not be smaller then "interval max" which is "{interval_max}"!')
+
+ if 'name_server' in interface_config:
+ if len(interface_config['name_server']) > 3:
+ raise ConfigError('No more then 3 IPv6 name-servers supported!')
+
+ if 'name_server_lifetime' in interface_config:
+ # man page states:
+ # The maximum duration how long the RDNSS entries are used for name
+ # resolution. A value of 0 means the nameserver must no longer be
+ # used. The value, if not 0, must be at least MaxRtrAdvInterval. To
+ # ensure stale RDNSS info gets removed in a timely fashion, this
+ # should not be greater than 2*MaxRtrAdvInterval.
+ lifetime = int(interface_config['name_server_lifetime'])
+ if lifetime > 0:
+ if lifetime < int(interval_max):
+ raise ConfigError(f'RDNSS lifetime must be at least "{interval_max}" seconds!')
+ if lifetime > 2* interval_max:
+ Warning(f'RDNSS lifetime should not exceed "{2 * interval_max}" which is two times "interval max"!')
+
+ return None
+
+def generate(rtradv):
+ if not rtradv:
+ return None
+
+ render(config_file, 'router-advert/radvd.conf.j2', rtradv, permission=0o644)
+ return None
+
+def apply(rtradv):
+ systemd_service = 'radvd.service'
+ if not rtradv:
+ # bail out early - looks like removal from running config
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+
+ return None
+
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ return None
+
+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/service_salt-minion.py b/src/conf_mode/service_salt-minion.py
new file mode 100644
index 0000000..edf74b0
--- /dev/null
+++ b/src/conf_mode/service_salt-minion.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2022 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
+
+from socket import gethostname
+from sys import exit
+from urllib3 import PoolManager
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.permission import chown
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/etc/salt/minion'
+master_keyfile = r'/opt/vyatta/etc/config/salt/pki/minion/master_sign.pub'
+
+user='minion'
+group='vyattacfg'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'salt-minion']
+
+ if not conf.exists(base):
+ return None
+
+ salt = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+ # ID default is dynamic thus we can not use defaults()
+ if 'id' not in salt:
+ salt['id'] = gethostname()
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ salt = conf.merge_defaults(salt, recursive=True)
+
+ if not conf.exists(base):
+ return None
+ else:
+ conf.set_level(base)
+
+ return salt
+
+def verify(salt):
+ if not salt:
+ return None
+
+ if 'hash' in salt and salt['hash'] == 'sha1':
+ Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!')
+
+ if 'source_interface' in salt:
+ verify_interface_exists(salt, salt['source_interface'])
+
+ return None
+
+def generate(salt):
+ if not salt:
+ return None
+
+ render(config_file, 'salt-minion/minion.j2', salt, user=user, group=group)
+
+ if not os.path.exists(master_keyfile):
+ if 'master_key' in salt:
+ req = PoolManager().request('GET', salt['master_key'], preload_content=False)
+ with open(master_keyfile, 'wb') as f:
+ while True:
+ data = req.read(1024)
+ if not data:
+ break
+ f.write(data)
+
+ req.release_conn()
+ chown(master_keyfile, user, group)
+
+ return None
+
+def apply(salt):
+ service_name = 'salt-minion.service'
+ if not salt:
+ # Salt removed from running config
+ call(f'systemctl stop {service_name}')
+ if os.path.exists(config_file):
+ os.unlink(config_file)
+ else:
+ call(f'systemctl restart {service_name}')
+
+ return None
+
+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/service_sla.py b/src/conf_mode/service_sla.py
new file mode 100644
index 0000000..ba5e645
--- /dev/null
+++ b/src/conf_mode/service_sla.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+owamp_config_dir = '/etc/owamp-server'
+owamp_config_file = f'{owamp_config_dir}/owamp-server.conf'
+systemd_override_owamp = r'/run/systemd/system/owamp-server.d/20-override.conf'
+
+twamp_config_dir = '/etc/twamp-server'
+twamp_config_file = f'{twamp_config_dir}/twamp-server.conf'
+systemd_override_twamp = r'/run/systemd/system/twamp-server.d/20-override.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'sla']
+ if not conf.exists(base):
+ return None
+
+ sla = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ # Ignore default XML values if config doesn't exists
+ # Delete key from dict
+ if not conf.exists(base + ['owamp-server']):
+ del sla['owamp_server']
+ if not conf.exists(base + ['twamp-server']):
+ del sla['twamp_server']
+
+ return sla
+
+def verify(sla):
+ if not sla:
+ return None
+
+def generate(sla):
+ if not sla:
+ return None
+
+ render(owamp_config_file, 'sla/owamp-server.conf.j2', sla)
+ render(systemd_override_owamp, 'sla/owamp-override.conf.j2', sla)
+
+ render(twamp_config_file, 'sla/twamp-server.conf.j2', sla)
+ render(systemd_override_twamp, 'sla/twamp-override.conf.j2', sla)
+
+ return None
+
+def apply(sla):
+ owamp_service = 'owamp-server.service'
+ twamp_service = 'twamp-server.service'
+
+ call('systemctl daemon-reload')
+
+ if not sla or 'owamp_server' not in sla:
+ call(f'systemctl stop {owamp_service}')
+
+ if os.path.exists(owamp_config_file):
+ os.unlink(owamp_config_file)
+
+ if not sla or 'twamp_server' not in sla:
+ call(f'systemctl stop {twamp_service}')
+ if os.path.exists(twamp_config_file):
+ os.unlink(twamp_config_file)
+
+ if sla and 'owamp_server' in sla:
+ call(f'systemctl reload-or-restart {owamp_service}')
+
+ if sla and 'twamp_server' in sla:
+ call(f'systemctl reload-or-restart {twamp_service}')
+
+ return None
+
+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/service_snmp.py b/src/conf_mode/service_snmp.py
new file mode 100644
index 0000000..c9c0ed9
--- /dev/null
+++ b/src/conf_mode/service_snmp.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_vrf
+from vyos.snmpv3_hashgen import plaintext_to_md5
+from vyos.snmpv3_hashgen import plaintext_to_sha1
+from vyos.snmpv3_hashgen import random
+from vyos.template import render
+from vyos.utils.configfs import delete_cli_node
+from vyos.utils.configfs import add_cli_node
+from vyos.utils.dict import dict_search
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.process import call
+from vyos.utils.permission import chmod_755
+from vyos.version import get_version_data
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file_client = r'/etc/snmp/snmp.conf'
+config_file_daemon = r'/etc/snmp/snmpd.conf'
+config_file_access = r'/usr/share/snmp/snmpd.conf'
+config_file_user = r'/var/lib/snmp/snmpd.conf'
+default_script_dir = r'/config/user-data/'
+systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf'
+systemd_service = 'snmpd.service'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'snmp']
+
+ snmp = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+ if not conf.exists(base):
+ snmp.update({'deleted' : ''})
+
+ if conf.exists(['service', 'lldp', 'snmp']):
+ snmp.update({'lldp_snmp' : ''})
+
+ if 'deleted' in snmp:
+ return snmp
+
+ version_data = get_version_data()
+ snmp['version'] = version_data['version']
+
+ # create an internal snmpv3 user of the form 'vyosxxxxxxxxxxxxxxxx'
+ snmp['vyos_user'] = 'vyos' + random(8)
+ snmp['vyos_user_pass'] = random(16)
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ snmp = conf.merge_defaults(snmp, recursive=True)
+
+ if 'listen_address' in snmp:
+ # Always listen on localhost if an explicit address has been configured
+ # This is a safety measure to not end up with invalid listen addresses
+ # that are not configured on this system. See https://vyos.dev/T850
+ if '127.0.0.1' not in snmp['listen_address']:
+ tmp = {'127.0.0.1': {'port': '161'}}
+ snmp['listen_address'] = dict_merge(tmp, snmp['listen_address'])
+
+ if '::1' not in snmp['listen_address']:
+ tmp = {'::1': {'port': '161'}}
+ snmp['listen_address'] = dict_merge(tmp, snmp['listen_address'])
+
+ if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']:
+ for key, val in snmp['script_extensions']['extension_name'].items():
+ if 'script' not in val:
+ continue
+ script_path = val['script']
+ # if script has not absolute path, use pre configured path
+ if not os.path.isabs(script_path):
+ script_path = os.path.join(default_script_dir, script_path)
+
+ snmp['script_extensions']['extension_name'][key]['script'] = script_path
+
+ return snmp
+
+
+def verify(snmp):
+ if 'deleted' in snmp:
+ return None
+
+ if {'deleted', 'lldp_snmp'} <= set(snmp):
+ raise ConfigError('Can not delete SNMP service, as LLDP still uses SNMP!')
+
+ ### check if the configured script actually exist
+ if 'script_extensions' in snmp and 'extension_name' in snmp['script_extensions']:
+ for extension, extension_opt in snmp['script_extensions']['extension_name'].items():
+ if 'script' not in extension_opt:
+ raise ConfigError(f'Script extension "{extension}" requires an actual script to be configured!')
+
+ tmp = extension_opt['script']
+ if not os.path.isfile(tmp):
+ Warning(f'script "{tmp}" does not exist!')
+ else:
+ chmod_755(extension_opt['script'])
+
+ if 'listen_address' in snmp:
+ for address in snmp['listen_address']:
+ # We only wan't to configure addresses that exist on the system.
+ # Hint the user if they don't exist
+ if 'vrf' in snmp:
+ vrf_name = snmp['vrf']
+ if not is_addr_assigned(address, vrf_name) and address not in ['::1','127.0.0.1']:
+ raise ConfigError(f'SNMP listen address "{address}" not configured in vrf "{vrf_name}"!')
+ elif not is_addr_assigned(address):
+ raise ConfigError(f'SNMP listen address "{address}" not configured in default vrf!')
+
+ if 'trap_target' in snmp:
+ for trap, trap_config in snmp['trap_target'].items():
+ if 'community' not in trap_config:
+ raise ConfigError(f'Trap target "{trap}" requires a community to be set!')
+
+ if 'oid_enable' in snmp:
+ Warning(f'Custom OIDs are enabled and may lead to system instability and high resource consumption')
+
+
+ verify_vrf(snmp)
+
+ # bail out early if SNMP v3 is not configured
+ if 'v3' not in snmp:
+ return None
+
+ if 'user' in snmp['v3']:
+ for user, user_config in snmp['v3']['user'].items():
+ if 'group' not in user_config:
+ raise ConfigError(f'Group membership required for user "{user}"!')
+
+ if 'plaintext_password' not in user_config['auth'] and 'encrypted_password' not in user_config['auth']:
+ raise ConfigError(f'Must specify authentication encrypted-password or plaintext-password for user "{user}"!')
+
+ if 'plaintext_password' not in user_config['privacy'] and 'encrypted_password' not in user_config['privacy']:
+ raise ConfigError(f'Must specify privacy encrypted-password or plaintext-password for user "{user}"!')
+
+ if 'group' in snmp['v3']:
+ for group, group_config in snmp['v3']['group'].items():
+ if 'seclevel' not in group_config:
+ raise ConfigError(f'Must configure "seclevel" for group "{group}"!')
+ if 'view' not in group_config:
+ raise ConfigError(f'Must configure "view" for group "{group}"!')
+
+ # Check if 'view' exists
+ view = group_config['view']
+ if 'view' not in snmp['v3'] or view not in snmp['v3']['view']:
+ raise ConfigError(f'You must create view "{view}" first!')
+
+ if 'view' in snmp['v3']:
+ for view, view_config in snmp['v3']['view'].items():
+ if 'oid' not in view_config:
+ raise ConfigError(f'Must configure an "oid" for view "{view}"!')
+
+ if 'trap_target' in snmp['v3']:
+ for trap, trap_config in snmp['v3']['trap_target'].items():
+ if 'plaintext_password' not in trap_config['auth'] and 'encrypted_password' not in trap_config['auth']:
+ raise ConfigError(f'Must specify one of authentication encrypted-password or plaintext-password for trap "{trap}"!')
+
+ if {'plaintext_password', 'encrypted_password'} <= set(trap_config['auth']):
+ raise ConfigError(f'Can not specify both authentication encrypted-password and plaintext-password for trap "{trap}"!')
+
+ if 'plaintext_password' not in trap_config['privacy'] and 'encrypted_password' not in trap_config['privacy']:
+ raise ConfigError(f'Must specify one of privacy encrypted-password or plaintext-password for trap "{trap}"!')
+
+ if {'plaintext_password', 'encrypted_password'} <= set(trap_config['privacy']):
+ raise ConfigError(f'Can not specify both privacy encrypted-password and plaintext-password for trap "{trap}"!')
+
+ if 'type' not in trap_config:
+ raise ConfigError('SNMP v3 trap "type" must be specified!')
+
+ return None
+
+def generate(snmp):
+ # As we are manipulating the snmpd user database we have to stop it first!
+ # This is even save if service is going to be removed
+ call(f'systemctl stop {systemd_service}')
+ # Clean config files
+ config_files = [config_file_client, config_file_daemon,
+ config_file_access, config_file_user, systemd_override]
+ for file in config_files:
+ if os.path.isfile(file):
+ os.unlink(file)
+
+ if 'deleted' in snmp:
+ return None
+
+ if 'v3' in snmp:
+ # SNMPv3 uses a hashed password. If CLI defines a plaintext password,
+ # we will hash it in the background and replace the CLI node!
+ if 'user' in snmp['v3']:
+ for user, user_config in snmp['v3']['user'].items():
+ if dict_search('auth.type', user_config) == 'sha':
+ hash = plaintext_to_sha1
+ else:
+ hash = plaintext_to_md5
+
+ if dict_search('auth.plaintext_password', user_config) is not None:
+ tmp = hash(dict_search('auth.plaintext_password', user_config),
+ dict_search('v3.engineid', snmp))
+
+ snmp['v3']['user'][user]['auth']['encrypted_password'] = tmp
+ del snmp['v3']['user'][user]['auth']['plaintext_password']
+
+ cli_base = ['service', 'snmp', 'v3', 'user', user, 'auth']
+ delete_cli_node(cli_base + ['plaintext-password'])
+ add_cli_node(cli_base + ['encrypted-password'], value=tmp)
+
+ if dict_search('privacy.plaintext_password', user_config) is not None:
+ tmp = hash(dict_search('privacy.plaintext_password', user_config),
+ dict_search('v3.engineid', snmp))
+
+ snmp['v3']['user'][user]['privacy']['encrypted_password'] = tmp
+ del snmp['v3']['user'][user]['privacy']['plaintext_password']
+
+ cli_base = ['service', 'snmp', 'v3', 'user', user, 'privacy']
+ delete_cli_node(cli_base + ['plaintext-password'])
+ add_cli_node(cli_base + ['encrypted-password'], value=tmp)
+
+ # Write client config file
+ render(config_file_client, 'snmp/etc.snmp.conf.j2', snmp)
+ # Write server config file
+ render(config_file_daemon, 'snmp/etc.snmpd.conf.j2', snmp)
+ # Write access rights config file
+ render(config_file_access, 'snmp/usr.snmpd.conf.j2', snmp)
+ # Write access rights config file
+ render(config_file_user, 'snmp/var.snmpd.conf.j2', snmp)
+ # Write daemon configuration file
+ render(systemd_override, 'snmp/override.conf.j2', snmp)
+
+ return None
+
+def apply(snmp):
+ # Always reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ if 'deleted' in snmp:
+ return None
+
+ # start SNMP daemon
+ call(f'systemctl reload-or-restart {systemd_service}')
+
+ # Enable AgentX in FRR
+ # This should be done for each daemon individually because common command
+ # works only if all the daemons started with SNMP support
+ # Following daemons from FRR 9.0/stable have SNMP module compiled in VyOS
+ frr_daemons_list = ['zebra', 'bgpd', 'ospf6d', 'ospfd', 'ripd', 'isisd', 'ldpd']
+ for frr_daemon in frr_daemons_list:
+ call(f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null')
+
+ return None
+
+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/service_ssh.py b/src/conf_mode/service_ssh.py
new file mode 100644
index 0000000..9abdd33
--- /dev/null
+++ b/src/conf_mode/service_ssh.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+from syslog import syslog
+from syslog import LOG_INFO
+
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/sshd/sshd_config'
+
+sshguard_config_file = '/etc/sshguard/sshguard.conf'
+sshguard_whitelist = '/etc/sshguard/whitelist'
+
+key_rsa = '/etc/ssh/ssh_host_rsa_key'
+key_dsa = '/etc/ssh/ssh_host_dsa_key'
+key_ed25519 = '/etc/ssh/ssh_host_ed25519_key'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'ssh']
+ if not conf.exists(base):
+ return None
+
+ ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ tmp = is_node_changed(conf, base + ['vrf'])
+ if tmp: ssh.update({'restart_required': {}})
+
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ ssh = conf.merge_defaults(ssh, recursive=True)
+
+ # pass config file path - used in override template
+ ssh['config_file'] = config_file
+
+ # Ignore default XML values if config doesn't exists
+ # Delete key from dict
+ if not conf.exists(base + ['dynamic-protection']):
+ del ssh['dynamic_protection']
+
+ return ssh
+
+def verify(ssh):
+ if not ssh:
+ return None
+
+ if 'rekey' in ssh and 'data' not in ssh['rekey']:
+ raise ConfigError(f'Rekey data is required!')
+
+ verify_vrf(ssh)
+ return None
+
+def generate(ssh):
+ if not ssh:
+ if os.path.isfile(config_file):
+ os.unlink(config_file)
+
+ return None
+
+ # This usually happens only once on a fresh system, SSH keys need to be
+ # freshly generted, one per every system!
+ if not os.path.isfile(key_rsa):
+ syslog(LOG_INFO, 'SSH RSA host key not found, generating new key!')
+ call(f'ssh-keygen -q -N "" -t rsa -f {key_rsa}')
+ if not os.path.isfile(key_dsa):
+ syslog(LOG_INFO, 'SSH DSA host key not found, generating new key!')
+ call(f'ssh-keygen -q -N "" -t dsa -f {key_dsa}')
+ if not os.path.isfile(key_ed25519):
+ syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!')
+ call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}')
+
+ render(config_file, 'ssh/sshd_config.j2', ssh)
+
+ if 'dynamic_protection' in ssh:
+ render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh)
+ render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh)
+
+ return None
+
+def apply(ssh):
+ systemd_service_ssh = 'ssh.service'
+ systemd_service_sshguard = 'sshguard.service'
+ if not ssh:
+ # SSH access is removed in the commit
+ call(f'systemctl stop ssh@*.service')
+ call(f'systemctl stop {systemd_service_sshguard}')
+ return None
+
+ if 'dynamic_protection' not in ssh:
+ call(f'systemctl stop {systemd_service_sshguard}')
+ else:
+ call(f'systemctl reload-or-restart {systemd_service_sshguard}')
+
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'restart_required' in ssh:
+ # this is only true if something for the VRFs changed, thus we
+ # stop all VRF services and only restart then new ones
+ call(f'systemctl stop ssh@*.service')
+ systemd_action = 'restart'
+
+ for vrf in ssh['vrf']:
+ call(f'systemctl {systemd_action} ssh@{vrf}.service')
+ return None
+
+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/service_stunnel.py b/src/conf_mode/service_stunnel.py
new file mode 100644
index 0000000..8ec7625
--- /dev/null
+++ b/src/conf_mode/service_stunnel.py
@@ -0,0 +1,264 @@
+#!/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
+from shutil import rmtree
+
+from sys import exit
+
+from netifaces import AF_INET
+from psutil import net_if_addrs
+
+from vyos.config import Config
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.configverify import verify_pki_certificate
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.pki import find_chain
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.utils.dict import dict_search
+from vyos.utils.file import makedir
+from vyos.utils.file import write_file
+from vyos.utils.network import check_port_availability
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+stunnel_dir = '/run/stunnel'
+config_file = f'{stunnel_dir}/stunnel.conf'
+stunnel_ca_dir = f'{stunnel_dir}/ca'
+stunnel_psk_dir = f'{stunnel_dir}/psk'
+
+# config based on
+# http://man.he.net/man8/stunnel4
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'stunnel']
+ if not conf.exists(base):
+ return None
+
+ stunnel = conf.get_config_dict(base,
+ get_first_key=True,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True,
+ with_pki=True)
+ stunnel['config_file'] = config_file
+ return stunnel
+
+
+def verify(stunnel):
+ if not stunnel:
+ return None
+
+ stunnel_listen_addresses = list()
+ for mode, conf in stunnel.items():
+ if mode not in ['server', 'client']:
+ continue
+
+ for app, app_conf in conf.items():
+ # connect, listen, exec and some protocols e.g. socks on server mode are endpoints.
+ endpoints = 0
+ if 'socks' == app_conf.get('protocol') and mode == 'server':
+ if 'connect' in app_conf:
+ raise ConfigError("The 'connect' option cannot be used with the 'socks' protocol in server mode.")
+ endpoints += 1
+
+ for item in ['connect', 'listen']:
+ if item in app_conf:
+ endpoints += 1
+ if 'port' not in app_conf[item]:
+ raise ConfigError(f'{mode} [{app}]: {item} port number is required!')
+ elif item == 'listen':
+ raise ConfigError(f'{mode} [{app}]: {item} port number is required!')
+
+ if endpoints != 2:
+ raise ConfigError(f'{mode} [{app}]: connect port number is required!')
+
+ if 'address' in app_conf['listen']:
+ laddresses = [dict_search('listen.address', app_conf)]
+ else:
+ laddresses = list()
+ ifaces = net_if_addrs()
+ for iface_name, iface_addresses in ifaces.items():
+ for iface_addr in iface_addresses:
+ if iface_addr.family == AF_INET:
+ laddresses.append(iface_addr.address)
+
+ lport = int(dict_search('listen.port', app_conf))
+
+ for address in laddresses:
+ if f'{address}:{lport}' in stunnel_listen_addresses:
+ raise ConfigError(
+ f'{mode} [{app}]: Address {address}:{lport} already '
+ f'in use by other stunnel service')
+
+ stunnel_listen_addresses.append(f'{address}:{lport}')
+ if not check_port_availability(address, lport, 'tcp') and \
+ not is_listen_port_bind_service(lport, 'stunnel'):
+ raise ConfigError(
+ f'{mode} [{app}]: Address {address}:{lport} already in use')
+
+ if 'options' in app_conf:
+ protocol = app_conf.get('protocol')
+ if protocol not in ['connect', 'smtp']:
+ raise ConfigError("Additional option is only supported in the 'connect' and 'smtp' protocols.")
+ if protocol == 'smtp' and ('domain' in app_conf['options'] or 'host' in app_conf['options']):
+ raise ConfigError("Protocol 'smtp' does not support options 'domain' and 'host'.")
+
+ # set default authentication option
+ if 'authentication' not in app_conf['options']:
+ app_conf['options']['authentication'] = 'basic' if protocol == 'connect' else 'plain'
+
+ for option, option_config in app_conf['options'].items():
+ if option == 'authentication':
+ if protocol == 'connect' and option_config not in ['basic', 'ntlm']:
+ raise ConfigError("Supported authentication types for the 'connect' protocol are 'basic' or 'ntlm'")
+ elif protocol == 'smtp' and option_config not in ['plain', 'login']:
+ raise ConfigError("Supported authentication types for the 'smtp' protocol are 'plain' or 'login'")
+ if option == 'host':
+ if 'address' not in option_config:
+ raise ConfigError('Address is required for option host.')
+ if 'port' not in option_config:
+ raise ConfigError('Port is required for option host.')
+
+ # check pki certs
+ for key in ['ca_certificate', 'certificate']:
+ tmp = dict_search(f'ssl.{key}', app_conf)
+ if mode == 'server' and key != 'ca_certificate' and not tmp and 'psk' not in app_conf:
+ raise ConfigError(f'{mode} [{app}]: TLS server needs a certificate or PSK')
+ if tmp:
+ if key == 'ca_certificate':
+ for ca_cert in tmp:
+ verify_pki_ca_certificate(stunnel, ca_cert)
+ else:
+ verify_pki_certificate(stunnel, tmp)
+
+ #check psk
+ if 'psk' in app_conf:
+ for psk, psk_conf in app_conf['psk'].items():
+ if 'id' not in psk_conf or 'secret' not in psk_conf:
+ raise ConfigError(
+ f'Authentication psk "{psk}" missing "id" or "secret"')
+
+
+def generate(stunnel):
+ if not stunnel or ('client' not in stunnel and 'server' not in stunnel):
+ if os.path.isdir(stunnel_dir):
+ rmtree(stunnel_dir, ignore_errors=True)
+
+ return None
+ makedir(stunnel_dir)
+
+ exist_files = list()
+ current_files = [config_file, config_file.replace('.conf', 'pid')]
+ for root, dirs, files in os.walk(stunnel_dir):
+ for file in files:
+ exist_files.append(os.path.join(root, file))
+
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in stunnel['pki']['ca'].values()} if 'pki' in stunnel and 'ca' in stunnel['pki'] else {}
+
+ for mode, conf in stunnel.items():
+ if mode not in ['server', 'client']:
+ continue
+
+ for app, app_conf in conf.items():
+ if 'ssl' in app_conf:
+ if 'certificate' in app_conf['ssl']:
+ cert_name = app_conf['ssl']['certificate']
+
+ pki_cert = stunnel['pki']['certificate'][cert_name]
+ cert_file_path = os.path.join(stunnel_dir,
+ f'{mode}-{app}-{cert_name}.pem')
+ cert_key_path = os.path.join(stunnel_dir,
+ f'{mode}-{app}-{cert_name}.pem.key')
+ app_conf['ssl']['cert'] = cert_file_path
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+
+ write_file(cert_file_path,
+ '\n'.join(encode_certificate(c) for c in cert_full_chain))
+ current_files.append(cert_file_path)
+
+ if 'private' in pki_cert and 'key' in pki_cert['private']:
+ app_conf['ssl']['cert_key'] = cert_key_path
+ loaded_key = load_private_key(pki_cert['private']['key'],
+ passphrase=None, wrap_tags=True)
+ key_pem = encode_private_key(loaded_key, passphrase=None)
+ write_file(cert_key_path, key_pem, mode=0o600)
+ current_files.append(cert_key_path)
+
+ if 'ca_certificate' in app_conf['ssl']:
+ app_conf['ssl']['ca_path'] = stunnel_ca_dir
+ app_conf['ssl']['ca_file'] = f'{mode}-{app}-ca.pem'
+ ca_cert_file_path = os.path.join(stunnel_ca_dir, app_conf['ssl']['ca_file'])
+ ca_chains = []
+
+ for ca_name in app_conf['ssl']['ca_certificate']:
+ pki_ca_cert = stunnel['pki']['ca'][ca_name]
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ ca_chains.append(
+ '\n'.join(encode_certificate(c) for c in ca_full_chain))
+
+ write_file(ca_cert_file_path, '\n'.join(ca_chains))
+ current_files.append(ca_cert_file_path)
+
+ if 'psk' in app_conf:
+ psk_data = list()
+ psk_file_path = os.path.join(stunnel_psk_dir, f'{mode}_{app}.txt')
+
+ for _, psk_conf in app_conf['psk'].items():
+ psk_data.append(f'{psk_conf["id"]}:{psk_conf["secret"]}')
+
+ write_file(psk_file_path, '\n'.join(psk_data))
+ app_conf['psk']['file'] = psk_file_path
+ current_files.append(psk_file_path)
+
+ for file in exist_files:
+ if file not in current_files:
+ os.unlink(file)
+
+ render(config_file, 'stunnel/stunnel_config.j2', stunnel)
+
+
+def apply(stunnel):
+ if not stunnel or ('client' not in stunnel and 'server' not in stunnel):
+ call('systemctl stop stunnel.service')
+ else:
+ call('systemctl restart stunnel.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/service_suricata.py b/src/conf_mode/service_suricata.py
new file mode 100644
index 0000000..1ce1701
--- /dev/null
+++ b/src/conf_mode/service_suricata.py
@@ -0,0 +1,161 @@
+#!/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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = '/run/suricata/suricata.yaml'
+rotate_file = '/etc/logrotate.d/suricata'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'suricata']
+
+ if not conf.exists(base):
+ return None
+
+ suricata = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, with_recursive_defaults=True)
+
+ return suricata
+
+# https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
+def topological_sort(source):
+ sorted_nodes = []
+ permanent_marks = set()
+ temporary_marks = set()
+
+ def visit(n, v):
+ if n in permanent_marks:
+ return
+ if n in temporary_marks:
+ raise ConfigError('At least one cycle exists in the referenced groups')
+
+ temporary_marks.add(n)
+
+ for m in v.get('group', []):
+ m = m.lstrip('!').replace('-', '_')
+ if m not in source:
+ raise ConfigError(f'Undefined referenced group "{m}"')
+ visit(m, source[m])
+
+ temporary_marks.remove(n)
+ permanent_marks.add(n)
+ sorted_nodes.append((n, v))
+
+ while len(permanent_marks) < len(source):
+ n = next(n for n in source.keys() if n not in permanent_marks)
+ visit(n, source[n])
+
+ return sorted_nodes
+
+def verify(suricata):
+ if not suricata:
+ return None
+
+ if 'interface' not in suricata:
+ raise ConfigError('No interfaces configured!')
+
+ if 'address_group' not in suricata:
+ raise ConfigError('No address-group configured!')
+
+ if 'port_group' not in suricata:
+ raise ConfigError('No port-group configured!')
+
+ try:
+ topological_sort(suricata['address_group'])
+ except (ConfigError,StopIteration) as e:
+ raise ConfigError(f'Invalid address-group: {e}')
+
+ try:
+ topological_sort(suricata['port_group'])
+ except (ConfigError,StopIteration) as e:
+ raise ConfigError(f'Invalid port-group: {e}')
+
+def generate(suricata):
+ if not suricata:
+ for file in [config_file, rotate_file]:
+ if os.path.isfile(file):
+ os.unlink(file)
+
+ return None
+
+ # Config-related formatters
+ def to_var(s:str):
+ return s.replace('-','_').upper()
+
+ def to_val(s:str):
+ return s.replace('-',':')
+
+ def to_ref(s:str):
+ if s[0] == '!':
+ return '!$' + to_var(s[1:])
+ return '$' + to_var(s)
+
+ def to_config(kind:str):
+ def format_group(group):
+ (name, value) = group
+ property = [to_val(property) for property in value.get(kind,[])]
+ group = [to_ref(group) for group in value.get('group',[])]
+ return (to_var(name), property + group)
+ return format_group
+
+ # Format the address group
+ suricata['address_group'] = map(to_config('address'),
+ topological_sort(suricata['address_group']))
+
+ # Format the port group
+ suricata['port_group'] = map(to_config('port'),
+ topological_sort(suricata['port_group']))
+
+ render(config_file, 'ids/suricata.j2', {'suricata': suricata})
+ render(rotate_file, 'ids/suricata_logrotate.j2', suricata)
+ return None
+
+def apply(suricata):
+ systemd_service = 'suricata.service'
+ if not suricata or 'interface' not in suricata:
+ # Stop suricata service if removed
+ call(f'systemctl stop {systemd_service}')
+ else:
+ Warning('To fetch the latest rules, use "update suricata"; '
+ 'To periodically fetch the latest rules, '
+ 'use the task scheduler!')
+ call(f'systemctl restart {systemd_service}')
+
+ return None
+
+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/service_tftp-server.py b/src/conf_mode/service_tftp-server.py
new file mode 100644
index 0000000..5b7303c
--- /dev/null
+++ b/src/conf_mode/service_tftp-server.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 pwd
+
+from copy import deepcopy
+from glob import glob
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configverify import verify_vrf
+from vyos.template import render
+from vyos.template import is_ipv4
+from vyos.utils.process import call
+from vyos.utils.permission import chmod_755
+from vyos.utils.network import is_addr_assigned
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/etc/default/tftpd'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['service', 'tftp-server']
+ if not conf.exists(base):
+ return None
+
+ tftpd = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ return tftpd
+
+def verify(tftpd):
+ # bail out early - looks like removal from running config
+ if not tftpd:
+ return None
+
+ # Configuring allowed clients without a server makes no sense
+ if 'directory' not in tftpd:
+ raise ConfigError('TFTP root directory must be configured!')
+
+ if 'listen_address' not in tftpd:
+ raise ConfigError('TFTP server listen address must be configured!')
+
+ for address, address_config in tftpd['listen_address'].items():
+ if not is_addr_assigned(address):
+ Warning(f'TFTP server listen address "{address}" not ' \
+ 'assigned to any interface!')
+ verify_vrf(address_config)
+
+ return None
+
+def generate(tftpd):
+ # cleanup any available configuration file
+ # files will be recreated on demand
+ for i in glob(config_file + '*'):
+ os.unlink(i)
+
+ # bail out early - looks like removal from running config
+ if tftpd is None:
+ return None
+
+ idx = 0
+ for address, address_config in tftpd['listen_address'].items():
+ config = deepcopy(tftpd)
+ port = tftpd['port']
+ if is_ipv4(address):
+ config['listen_address'] = f'{address}:{port} -4'
+ else:
+ config['listen_address'] = f'[{address}]:{port} -6'
+
+ if 'vrf' in address_config:
+ config['vrf'] = address_config['vrf']
+
+ file = config_file + str(idx)
+ render(file, 'tftp-server/default.j2', config)
+ idx = idx + 1
+
+ return None
+
+def apply(tftpd):
+ # stop all services first - then we will decide
+ call('systemctl stop tftpd@*.service')
+
+ # bail out early - e.g. service deletion
+ if tftpd is None:
+ return None
+
+ tftp_root = tftpd['directory']
+ if not os.path.exists(tftp_root):
+ os.makedirs(tftp_root)
+ chmod_755(tftp_root)
+
+ # get UNIX uid for user 'tftp'
+ tftp_uid = pwd.getpwnam('tftp').pw_uid
+ tftp_gid = pwd.getpwnam('tftp').pw_gid
+
+ # get UNIX uid for tftproot directory
+ dir_uid = os.stat(tftp_root).st_uid
+ dir_gid = os.stat(tftp_root).st_gid
+
+ # adjust uid/gid of tftproot directory if files don't belong to user tftp
+ if (tftp_uid != dir_uid) or (tftp_gid != dir_gid):
+ os.chown(tftp_root, tftp_uid, tftp_gid)
+
+ idx = 0
+ for address in tftpd['listen_address']:
+ call(f'systemctl restart tftpd@{idx}.service')
+ idx = idx + 1
+
+ return None
+
+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/service_webproxy.py b/src/conf_mode/service_webproxy.py
new file mode 100644
index 0000000..12ae413
--- /dev/null
+++ b/src/conf_mode/service_webproxy.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020 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
+
+from shutil import rmtree
+from sys import exit
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.permission import chmod_755
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.network import is_addr_assigned
+from vyos.base import Warning
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+squid_config_file = '/etc/squid/squid.conf'
+squidguard_config_file = '/etc/squidguard/squidGuard.conf'
+squidguard_db_dir = '/opt/vyatta/etc/config/url-filtering/squidguard/db'
+user_group = 'proxy'
+
+
+def check_blacklist_categorydb(config_section):
+ if 'block_category' in config_section:
+ for category in config_section['block_category']:
+ check_categorydb(category)
+ if 'allow_category' in config_section:
+ for category in config_section['allow_category']:
+ check_categorydb(category)
+
+
+def check_categorydb(category: str):
+ """
+ Check if category's db exist
+ :param category:
+ :type str:
+ """
+ path_to_cat: str = f'{squidguard_db_dir}/{category}'
+ if not os.path.exists(f'{path_to_cat}/domains.db') \
+ and not os.path.exists(f'{path_to_cat}/urls.db') \
+ and not os.path.exists(f'{path_to_cat}/expressions.db'):
+ Warning(f'DB of category {category} does not exist.\n '
+ f'Use [update webproxy blacklists] '
+ f'or delete undefined category!')
+
+
+def generate_sg_rule_localdb(category, list_type, role, proxy):
+ if not category or not list_type or not role:
+ return None
+
+ cat_ = category.replace('-', '_')
+
+ if role == 'default':
+ path_to_cat = f'{cat_}'
+ else:
+ path_to_cat = f'rule.{role}.{cat_}'
+ if isinstance(
+ dict_search(f'url_filtering.squidguard.{path_to_cat}', proxy),
+ list):
+ # local block databases must be generated "on-the-fly"
+ tmp = {
+ 'squidguard_db_dir': squidguard_db_dir,
+ 'category': f'{category}-{role}',
+ 'list_type': list_type,
+ 'rule': role
+ }
+ sg_tmp_file = '/tmp/sg.conf'
+ db_file = f'{category}-{role}/{list_type}'
+ domains = '\n'.join(
+ dict_search(f'url_filtering.squidguard.{path_to_cat}', proxy))
+ # local file
+ write_file(f'{squidguard_db_dir}/{category}-{role}/local', '',
+ user=user_group, group=user_group)
+ # database input file
+ write_file(f'{squidguard_db_dir}/{db_file}', domains,
+ user=user_group, group=user_group)
+
+ # temporary config file, deleted after generation
+ render(sg_tmp_file, 'squid/sg_acl.conf.j2', tmp,
+ user=user_group, group=user_group)
+
+ call(
+ f'su - {user_group} -c "squidGuard -d -c {sg_tmp_file} -C {db_file}"')
+
+ if os.path.exists(sg_tmp_file):
+ os.unlink(sg_tmp_file)
+ else:
+ # if category is not part of our configuration, clean out the
+ # squidguard lists
+ tmp = f'{squidguard_db_dir}/{category}-{role}'
+ if os.path.exists(tmp):
+ rmtree(f'{squidguard_db_dir}/{category}-{role}')
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'webproxy']
+ if not conf.exists(base):
+ return None
+
+ proxy = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(**proxy.kwargs,
+ recursive=True)
+
+ # if no authentication method is supplied, no need to add defaults
+ if not dict_search('authentication.method', proxy):
+ default_values.pop('authentication')
+ # if no url_filteringurl-filtering method is supplied, no need to add defaults
+ if 'url_filtering' not in proxy:
+ default_values.pop('url_filtering')
+ else:
+ # store path to squidGuard config, used when generating Squid config
+ proxy['squidguard_conf'] = squidguard_config_file
+ proxy['squidguard_db_dir'] = squidguard_db_dir
+
+ proxy = config_dict_merge(default_values, proxy)
+
+ return proxy
+
+
+def verify(proxy):
+ if not proxy:
+ return None
+
+ if 'listen_address' not in proxy:
+ raise ConfigError('listen-address needs to be configured!')
+
+ ldap_auth = dict_search('authentication.method', proxy) == 'ldap'
+
+ for address, config in proxy['listen_address'].items():
+ if ldap_auth and 'disable_transparent' not in config:
+ raise ConfigError('Authentication can not be configured when ' \
+ 'proxy is in transparent mode')
+
+ if 'outgoing_address' in proxy:
+ address = proxy['outgoing_address']
+ if not is_addr_assigned(address):
+ raise ConfigError(
+ f'outgoing-address "{address}" not assigned on any interface!')
+
+ if 'authentication' in proxy:
+ if 'method' not in proxy['authentication']:
+ raise ConfigError('proxy authentication method required!')
+
+ if ldap_auth:
+ ldap_config = proxy['authentication']['ldap']
+
+ if 'server' not in ldap_config:
+ raise ConfigError(
+ 'LDAP authentication enabled, but no server set')
+
+ if 'password' in ldap_config and 'bind_dn' not in ldap_config:
+ raise ConfigError(
+ 'LDAP password can not be set when base-dn is undefined!')
+
+ if 'bind_dn' in ldap_config and 'password' not in ldap_config:
+ raise ConfigError(
+ 'LDAP bind DN can not be set without password!')
+
+ if 'base_dn' not in ldap_config:
+ raise ConfigError('LDAP base-dn must be set!')
+
+ if 'cache_peer' in proxy:
+ for peer, config in proxy['cache_peer'].items():
+ if 'address' not in config:
+ raise ConfigError(f'Cache-peer "{peer}" address must be set!')
+
+
+def generate(proxy):
+ if not proxy:
+ return None
+
+ render(squid_config_file, 'squid/squid.conf.j2', proxy)
+ render(squidguard_config_file, 'squid/squidGuard.conf.j2', proxy)
+
+ cat_dict = {
+ 'local-block': 'domains',
+ 'local-block-keyword': 'expressions',
+ 'local-block-url': 'urls',
+ 'local-ok': 'domains',
+ 'local-ok-url': 'urls'
+ }
+ if dict_search(f'url_filtering.squidguard', proxy) is not None:
+ squidgard_config_section = proxy['url_filtering']['squidguard']
+
+ for category, list_type in cat_dict.items():
+ generate_sg_rule_localdb(category, list_type, 'default', proxy)
+ check_blacklist_categorydb(squidgard_config_section)
+
+ if 'rule' in squidgard_config_section:
+ for rule in squidgard_config_section['rule']:
+ rule_config_section = squidgard_config_section['rule'][
+ rule]
+ for category, list_type in cat_dict.items():
+ generate_sg_rule_localdb(category, list_type, rule, proxy)
+ check_blacklist_categorydb(rule_config_section)
+
+ return None
+
+
+def apply(proxy):
+ if not proxy:
+ # proxy is removed in the commit
+ call('systemctl stop squid.service')
+
+ if os.path.exists(squid_config_file):
+ os.unlink(squid_config_file)
+ if os.path.exists(squidguard_config_file):
+ os.unlink(squidguard_config_file)
+
+ return None
+
+ if os.path.exists(squidguard_db_dir):
+ chmod_755(squidguard_db_dir)
+ call('systemctl reload-or-restart squid.service')
+ return None
+
+
+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_acceleration.py b/src/conf_mode/system_acceleration.py
new file mode 100644
index 0000000..d2cf44f
--- /dev/null
+++ b/src/conf_mode/system_acceleration.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 os
+import re
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.utils.process import popen
+from vyos.utils.process import run
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+qat_init_script = '/etc/init.d/qat_service'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ data = {}
+
+ if conf.exists(['system', 'acceleration', 'qat']):
+ data.update({'qat_enable' : ''})
+
+ if conf.exists(['vpn', 'ipsec']):
+ data.update({'ipsec' : ''})
+
+ if conf.exists(['interfaces', 'openvpn']):
+ data.update({'openvpn' : ''})
+
+ return data
+
+
+def vpn_control(action, force_ipsec=False):
+ # XXX: Should these commands report failure?
+ if action == 'restore' and force_ipsec:
+ return run('ipsec start')
+
+ return run(f'ipsec {action}')
+
+
+def verify(qat):
+ if 'qat_enable' not in qat:
+ return
+
+ # Check if QAT service installed
+ if not os.path.exists(qat_init_script):
+ raise ConfigError('QAT init script not found')
+
+ # Check if QAT device exist
+ output, err = popen('lspci -nn', decode='utf-8')
+ if not err:
+ # PCI id | Chipset
+ # 19e2 -> C3xx
+ # 37c8 -> C62x
+ # 0435 -> DH895
+ # 6f54 -> D15xx
+ # 18ee -> QAT_200XX
+ data = re.findall(
+ '(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)|(8086:18ee)', output)
+ # If QAT devices found
+ if not data:
+ raise ConfigError('No QAT acceleration device found')
+
+def generate(qat):
+ return
+
+def apply(qat):
+ # Shutdown VPN service which can use QAT
+ if 'ipsec' in qat:
+ vpn_control('stop')
+
+ # Enable/Disable QAT service
+ if 'qat_enable' in qat:
+ run(f'{qat_init_script} start')
+ else:
+ run(f'{qat_init_script} stop')
+
+ # Recover VPN service
+ if 'ipsec' in qat:
+ vpn_control('start')
+
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ vpn_control('restore', force_ipsec=('ipsec' in c))
+ exit(1)
diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py
new file mode 100644
index 0000000..c681a84
--- /dev/null
+++ b/src/conf_mode/system_config-management.py
@@ -0,0 +1,96 @@
+#!/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 os
+import sys
+
+from vyos import ConfigError
+from vyos.config import Config
+from vyos.config_mgmt import ConfigMgmt
+from vyos.config_mgmt import commit_post_hook_dir, commit_hooks
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['system', 'config-management']
+ if not conf.exists(base):
+ return None
+
+ mgmt = ConfigMgmt(config=conf)
+
+ return mgmt
+
+def verify(_mgmt):
+ return
+
+def generate(mgmt):
+ if mgmt is None:
+ return
+
+ mgmt.initialize_revision()
+
+def apply(mgmt):
+ if mgmt is None:
+ return
+
+ locations = mgmt.locations
+ archive_target = os.path.join(commit_post_hook_dir,
+ commit_hooks['commit_archive'])
+ if locations:
+ try:
+ os.symlink('/usr/bin/config-mgmt', archive_target)
+ except FileExistsError:
+ pass
+ except OSError as exc:
+ raise ConfigError from exc
+ else:
+ try:
+ os.unlink(archive_target)
+ except FileNotFoundError:
+ pass
+ except OSError as exc:
+ raise ConfigError from exc
+
+ revisions = mgmt.max_revisions
+ revision_target = os.path.join(commit_post_hook_dir,
+ commit_hooks['commit_revision'])
+ if revisions > 0:
+ try:
+ os.symlink('/usr/bin/config-mgmt', revision_target)
+ except FileExistsError:
+ pass
+ except OSError as exc:
+ raise ConfigError from exc
+ else:
+ try:
+ os.unlink(revision_target)
+ except FileNotFoundError:
+ pass
+ except OSError as exc:
+ raise ConfigError from exc
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py
new file mode 100644
index 0000000..2529445
--- /dev/null
+++ b/src/conf_mode/system_conntrack.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 json
+import os
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdep import set_dependents, call_dependents
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_search_recursive
+from vyos.utils.file import write_file
+from vyos.utils.process import cmd, call
+from vyos.utils.process import rc_cmd
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf'
+sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf'
+nftables_ct_file = r'/run/nftables-ct.conf'
+vyos_conntrack_logger_config = r'/run/vyos-conntrack-logger.conf'
+
+# Every ALG (Application Layer Gateway) consists of either a Kernel Object
+# also called a Kernel Module/Driver or some rules present in iptables
+module_map = {
+ 'ftp': {
+ 'ko': ['nf_nat_ftp', 'nf_conntrack_ftp'],
+ 'nftables': ['tcp dport {21} ct helper set "ftp_tcp" return']
+ },
+ 'h323': {
+ 'ko': ['nf_nat_h323', 'nf_conntrack_h323'],
+ 'nftables': ['udp dport {1719} ct helper set "ras_udp" return',
+ 'tcp dport {1720} ct helper set "q931_tcp" return']
+ },
+ 'nfs': {
+ 'nftables': ['tcp dport {111} ct helper set "rpc_tcp" return',
+ 'udp dport {111} ct helper set "rpc_udp" return']
+ },
+ 'pptp': {
+ 'ko': ['nf_nat_pptp', 'nf_conntrack_pptp'],
+ 'nftables': ['tcp dport {1723} ct helper set "pptp_tcp" return'],
+ 'ipv4': True
+ },
+ 'rtsp': {
+ 'ko': ['nf_nat_rtsp', 'nf_conntrack_rtsp'],
+ 'nftables': ['tcp dport {554} ct helper set "rtsp_tcp" return'],
+ 'ipv4': True
+ },
+ 'sip': {
+ 'ko': ['nf_nat_sip', 'nf_conntrack_sip'],
+ 'nftables': ['tcp dport {5060,5061} ct helper set "sip_tcp" return',
+ 'udp dport {5060,5061} ct helper set "sip_udp" return']
+ },
+ 'sqlnet': {
+ 'nftables': ['tcp dport {1521,1525,1536} ct helper set "tns_tcp" return']
+ },
+ 'tftp': {
+ 'ko': ['nf_nat_tftp', 'nf_conntrack_tftp'],
+ 'nftables': ['udp dport {69} ct helper set "tftp_udp" return']
+ },
+}
+
+valid_groups = [
+ 'address_group',
+ 'domain_group',
+ 'network_group',
+ 'port_group'
+]
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'conntrack']
+
+ conntrack = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ conntrack['firewall'] = conf.get_config_dict(['firewall'], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ conntrack['ipv4_nat_action'] = 'accept' if conf.exists(['nat']) else 'return'
+ conntrack['ipv6_nat_action'] = 'accept' if conf.exists(['nat66']) else 'return'
+ conntrack['wlb_action'] = 'accept' if conf.exists(['load-balancing', 'wan']) else 'return'
+ conntrack['wlb_local_action'] = conf.exists(['load-balancing', 'wan', 'enable-local-traffic'])
+
+ conntrack['module_map'] = module_map
+
+ if conf.exists(['service', 'conntrack-sync']):
+ set_dependents('conntrack_sync', conf)
+
+ # If conntrack status changes, VRF zone rules need updating
+ if conf.exists(['vrf']):
+ set_dependents('vrf', conf)
+
+ return conntrack
+
+
+def verify(conntrack):
+ for inet in ['ipv4', 'ipv6']:
+ if dict_search_args(conntrack, 'ignore', inet, 'rule') != None:
+ for rule, rule_config in conntrack['ignore'][inet]['rule'].items():
+ if dict_search('destination.port', rule_config) or \
+ dict_search('destination.group.port_group', rule_config) or \
+ dict_search('source.port', rule_config) or \
+ dict_search('source.group.port_group', rule_config):
+ if 'protocol' not in rule_config or rule_config['protocol'] not in ['tcp', 'udp']:
+ raise ConfigError(f'Port requires tcp or udp as protocol in rule {rule}')
+
+ tcp_flags = dict_search_args(rule_config, 'tcp', 'flags')
+ if tcp_flags:
+ if dict_search_args(rule_config, 'protocol') != 'tcp':
+ raise ConfigError('Protocol must be tcp when specifying tcp flags')
+
+ not_flags = dict_search_args(rule_config, 'tcp', 'flags', 'not')
+ if not_flags:
+ duplicates = [flag for flag in tcp_flags if flag in not_flags]
+ if duplicates:
+ raise ConfigError(f'Cannot match a tcp flag as set and not set')
+
+ for side in ['destination', 'source']:
+ if side in rule_config:
+ side_conf = rule_config[side]
+
+ if 'group' in side_conf:
+ if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1:
+ raise ConfigError('Only one address-group, network-group or domain-group can be specified')
+
+ for group in valid_groups:
+ if group in side_conf['group']:
+ group_name = side_conf['group'][group]
+ error_group = group.replace("_", "-")
+
+ if group in ['address_group', 'network_group', 'domain_group']:
+ if 'address' in side_conf:
+ raise ConfigError(f'{error_group} and address cannot both be defined')
+
+ if group_name and group_name[0] == '!':
+ group_name = group_name[1:]
+
+ if inet == 'ipv6':
+ group = f'ipv6_{group}'
+
+ group_obj = dict_search_args(conntrack['firewall'], 'group', group, group_name)
+
+ if group_obj is None:
+ raise ConfigError(f'Invalid {error_group} "{group_name}" on ignore rule')
+
+ if not group_obj:
+ Warning(f'{error_group} "{group_name}" has no members!')
+
+ Warning(f'It is prefered to define {inet} conntrack ignore rules in <firewall {inet} prerouting raw> section')
+
+ if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None:
+ for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items():
+ if 'protocol' not in rule_config:
+ raise ConfigError(f'Conntrack custom timeout rule {rule} requires protocol tcp or udp')
+ else:
+ if 'tcp' in rule_config['protocol'] and 'udp' in rule_config['protocol']:
+ raise ConfigError(f'conntrack custom timeout rule {rule} - Cant use both tcp and udp protocol')
+ return None
+
+def generate(conntrack):
+ if not os.path.exists(nftables_ct_file):
+ conntrack['first_install'] = True
+
+ if 'log' not in conntrack:
+ # Remove old conntrack-logger config and return
+ if os.path.exists(vyos_conntrack_logger_config):
+ os.unlink(vyos_conntrack_logger_config)
+
+ # Determine if conntrack is needed
+ conntrack['ipv4_firewall_action'] = 'return'
+ conntrack['ipv6_firewall_action'] = 'return'
+
+ if dict_search_args(conntrack['firewall'], 'global_options', 'state_policy') != None:
+ conntrack['ipv4_firewall_action'] = 'accept'
+ conntrack['ipv6_firewall_action'] = 'accept'
+ else:
+ for rules, path in dict_search_recursive(conntrack['firewall'], 'rule'):
+ if any(('state' in rule_conf or 'connection_status' in rule_conf or 'offload_target' in rule_conf) for rule_conf in rules.values()):
+ if path[0] == 'ipv4':
+ conntrack['ipv4_firewall_action'] = 'accept'
+ elif path[0] == 'ipv6':
+ conntrack['ipv6_firewall_action'] = 'accept'
+
+ render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack)
+ render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack)
+ render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack)
+
+ if 'log' in conntrack:
+ log_conf_json = json.dumps(conntrack['log'], indent=4)
+ write_file(vyos_conntrack_logger_config, log_conf_json)
+
+ return None
+
+def apply(conntrack):
+ # Depending on the enable/disable state of the ALG (Application Layer Gateway)
+ # modules we need to either insmod or rmmod the helpers.
+
+ add_modules = []
+ rm_modules = []
+
+ for module, module_config in module_map.items():
+ if dict_search_args(conntrack, 'modules', module) is None:
+ if 'ko' in module_config:
+ unloaded = [mod for mod in module_config['ko'] if os.path.exists(f'/sys/module/{mod}')]
+ rm_modules.extend(unloaded)
+ else:
+ if 'ko' in module_config:
+ add_modules.extend(module_config['ko'])
+
+ # Add modules before nftables uses them
+ if add_modules:
+ module_str = ' '.join(add_modules)
+ cmd(f'modprobe -a {module_str}')
+
+ # Load new nftables ruleset
+ install_result, output = rc_cmd(f'nft --file {nftables_ct_file}')
+ if install_result == 1:
+ raise ConfigError(f'Failed to apply configuration: {output}')
+
+ # Remove modules after nftables stops using them
+ if rm_modules:
+ module_str = ' '.join(rm_modules)
+ cmd(f'rmmod {module_str}')
+
+ try:
+ call_dependents()
+ except ConfigError:
+ # Ignore config errors on dependent due to being called too early. Example:
+ # ConfigError("ConfigError('Interface ethN requires an IP address!')")
+ pass
+
+ # We silently ignore all errors
+ # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080
+ cmd(f'sysctl -f {sysctl_file}')
+
+ if 'log' in conntrack:
+ call(f'systemctl restart vyos-conntrack-logger.service')
+
+ return None
+
+
+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_console.py b/src/conf_mode/system_console.py
new file mode 100644
index 0000000..b380e05
--- /dev/null
+++ b/src/conf_mode/system_console.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+from pathlib import Path
+
+from vyos.config import Config
+from vyos.utils.process import call
+from vyos.utils.serial import restart_login_consoles
+from vyos.system import grub_util
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+by_bus_dir = '/dev/serial/by-bus'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'console']
+
+ # retrieve configuration at once
+ console = conf.get_config_dict(base, get_first_key=True)
+
+ # bail out early if no serial console is configured
+ if 'device' not in console:
+ return console
+
+ for device, device_config in console['device'].items():
+ if 'speed' not in device_config and device.startswith('hvc'):
+ # XEN console has a different default console speed
+ console['device'][device]['speed'] = 38400
+
+ console = conf.merge_defaults(console, recursive=True)
+
+ return console
+
+def verify(console):
+ if not console or 'device' not in console:
+ return None
+
+ for device in console['device']:
+ if device.startswith('usb'):
+ # It is much easiert to work with the native ttyUSBn name when using
+ # getty, but that name may change across reboots - depending on the
+ # amount of connected devices. We will resolve the fixed device name
+ # to its dynamic device file - and create a new dict entry for it.
+ by_bus_device = f'{by_bus_dir}/{device}'
+ # If the device name still starts with usbXXX no matching tty was found
+ # and it can not be used as a serial interface
+ if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device):
+ raise ConfigError(f'Device {device} does not support beeing used as tty')
+
+ return None
+
+def generate(console):
+ base_dir = '/run/systemd/system'
+ # Remove all serial-getty configuration files in advance
+ for root, dirs, files in os.walk(base_dir):
+ for basename in files:
+ if 'serial-getty' in basename:
+ os.unlink(os.path.join(root, basename))
+
+ if not console or 'device' not in console:
+ return None
+
+ # replace keys in the config for ttyUSB items to use them in `apply()` later
+ for device in console['device'].copy():
+ if device.startswith('usb'):
+ # It is much easiert to work with the native ttyUSBn name when using
+ # getty, but that name may change across reboots - depending on the
+ # amount of connected devices. We will resolve the fixed device name
+ # to its dynamic device file - and create a new dict entry for it.
+ by_bus_device = f'{by_bus_dir}/{device}'
+ if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device):
+ device_updated = os.path.basename(os.readlink(by_bus_device))
+
+ # replace keys in the config to use them in `apply()` later
+ console['device'][device_updated] = console['device'][device]
+ del console['device'][device]
+ else:
+ raise ConfigError(f'Device {device} does not support beeing used as tty')
+
+ for device, device_config in console['device'].items():
+ config_file = base_dir + f'/serial-getty@{device}.service'
+ Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True)
+ getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service'
+
+ render(config_file, 'getty/serial-getty.service.j2', device_config)
+ os.symlink(config_file, getty_wants_symlink)
+
+ # GRUB
+ # For existing serial line change speed (if necessary)
+ # Only applys to ttyS0
+ if 'ttyS0' not in console['device']:
+ return None
+
+ speed = console['device']['ttyS0']['speed']
+ grub_util.update_console_speed(speed)
+
+ return None
+
+def apply(console):
+ # Reset screen blanking
+ call('/usr/bin/setterm -blank 0 -powersave off -powerdown 0 -term linux </dev/tty1 >/dev/tty1 2>&1')
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+ # Service control moved to vyos.utils.serial to unify checks and prompts.
+ # If users are connected, we want to show an informational message on completing
+ # the process, but not halt configuration processing with an interactive prompt.
+ restart_login_consoles(prompt_user=False, quiet=False)
+
+ if not console:
+ return None
+
+ if 'powersave' in console.keys():
+ # Configure screen blank powersaving on VGA console
+ call('/usr/bin/setterm -blank 15 -powersave powerdown -powerdown 60 -term linux </dev/tty1 >/dev/tty1 2>&1')
+
+ return None
+
+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
new file mode 100644
index 0000000..a12ee36
--- /dev/null
+++ b/src/conf_mode/system_flow-accounting.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 re
+
+from sys import exit
+from ipaddress import ip_address
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configverify import verify_vrf
+from vyos.configverify import verify_interface_exists
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.utils.network import is_addr_assigned
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+uacctd_conf_path = '/run/pmacct/uacctd.conf'
+systemd_service = 'uacctd.service'
+systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf'
+nftables_nflog_table = 'raw'
+nftables_nflog_chain = 'VYOS_PREROUTING_HOOK'
+egress_nftables_nflog_table = 'inet mangle'
+egress_nftables_nflog_chain = 'FORWARD'
+
+# get nftables rule dict for chain in table
+def _nftables_get_nflog(chain, table):
+ # define list with rules
+ rules = []
+
+ # prepare regex for parsing rules
+ rule_pattern = '[io]ifname "(?P<interface>[\w\.\*\-]+)".*handle (?P<handle>[\d]+)'
+ rule_re = re.compile(rule_pattern)
+
+ # run nftables, save output and split it by lines
+ nftables_command = f'nft -a list chain {table} {chain}'
+ tmp = cmd(nftables_command, message='Failed to get flows list')
+ # parse each line and add information to list
+ for current_rule in tmp.splitlines():
+ if 'FLOW_ACCOUNTING_RULE' not in current_rule:
+ continue
+ current_rule_parsed = rule_re.search(current_rule)
+ if current_rule_parsed:
+ groups = current_rule_parsed.groupdict()
+ rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] })
+
+ # return list with rules
+ return rules
+
+def _nftables_config(configured_ifaces, direction, length=None):
+ # define list of nftables commands to modify settings
+ nftable_commands = []
+ nftables_chain = nftables_nflog_chain
+ nftables_table = nftables_nflog_table
+
+ if direction == "egress":
+ nftables_chain = egress_nftables_nflog_chain
+ nftables_table = egress_nftables_nflog_table
+
+ # prepare extended list with configured interfaces
+ configured_ifaces_extended = []
+ for iface in configured_ifaces:
+ configured_ifaces_extended.append({ 'iface': iface })
+
+ # get currently configured interfaces with nftables rules
+ active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table)
+
+ # compare current active list with configured one and delete excessive interfaces, add missed
+ active_nflog_ifaces = []
+ for rule in active_nflog_rules:
+ interface = rule['interface']
+ if interface not in configured_ifaces:
+ table = rule['table']
+ handle = rule['handle']
+ nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}')
+ else:
+ active_nflog_ifaces.append({
+ 'iface': interface,
+ })
+
+ # do not create new rules for already configured interfaces
+ for iface in active_nflog_ifaces:
+ if iface in active_nflog_ifaces and iface in configured_ifaces_extended:
+ configured_ifaces_extended.remove(iface)
+
+ # create missed rules
+ for iface_extended in configured_ifaces_extended:
+ iface = iface_extended['iface']
+ iface_prefix = "o" if direction == "egress" else "i"
+ rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"'
+ nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}')
+ # Also add IPv6 ingres logging
+ if nftables_table == nftables_nflog_table:
+ nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}')
+
+ # change nftables
+ for command in nftable_commands:
+ cmd(command, raising=ConfigError)
+
+
+def _nftables_trigger_setup(operation: str) -> None:
+ """Add a dummy rule to unlock the main pmacct loop with a packet-trigger
+
+ Args:
+ operation (str): 'add' or 'delete' a trigger
+ """
+ # check if a chain exists
+ table_exists = False
+ if run('nft -snj list table ip pmacct') == 0:
+ table_exists = True
+
+ if operation == 'delete' and table_exists:
+ nft_cmd: str = 'nft delete table ip pmacct'
+ cmd(nft_cmd, raising=ConfigError)
+ if operation == 'add' and not table_exists:
+ nft_cmds: list[str] = [
+ 'nft add table ip pmacct',
+ 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }',
+ 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER'
+ ]
+ for nft_cmd in nft_cmds:
+ cmd(nft_cmd, raising=ConfigError)
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'flow-accounting']
+ if not conf.exists(base):
+ return None
+
+ flow_accounting = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
+
+ # We have gathered the dict representation of the CLI, but there are
+ # default values which we need to conditionally update into the
+ # dictionary retrieved.
+ default_values = conf.get_config_defaults(**flow_accounting.kwargs,
+ recursive=True)
+
+ # 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_accounting = config_dict_merge(default_values, flow_accounting)
+
+ return flow_accounting
+
+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, ' \
+ 'or not set "disable-imt" for flow-accounting!')
+
+ # Check if at least one interface is configured
+ if 'interface' not in flow_config:
+ raise ConfigError('Flow accounting requires at least one interface to ' \
+ 'be configured!')
+
+ # check that all configured interfaces exists in the system
+ 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!')
+
+ # check NetFlow configuration
+ if 'netflow' in flow_config:
+ # check if vrf is defined for netflow
+ netflow_vrf = None
+ if 'vrf' in flow_config:
+ netflow_vrf = flow_config['vrf']
+
+ # check if at least one NetFlow collector is configured if NetFlow configuration is presented
+ if 'server' not in flow_config['netflow']:
+ raise ConfigError('You need to configure at least one NetFlow server!')
+
+ # Check if configured netflow source-address exist in the system
+ if 'source_address' in flow_config['netflow']:
+ if not is_addr_assigned(flow_config['netflow']['source_address'], netflow_vrf):
+ tmp = flow_config['netflow']['source_address']
+ raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!')
+
+ # Check if engine-id compatible with selected protocol version
+ if 'engine_id' in flow_config['netflow']:
+ v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$'
+ v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$'
+ engine_id = flow_config['netflow']['engine_id']
+ version = flow_config['netflow']['version']
+
+ if flow_config['netflow']['version'] == '5':
+ regex_filter = re.compile(v5_filter)
+ if not regex_filter.search(engine_id):
+ raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\
+ f'together with NetFlow protocol version "{version}"!')
+ else:
+ regex_filter = re.compile(v9v10_filter)
+ if not regex_filter.search(flow_config['netflow']['engine_id']):
+ raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\
+ f'with NetFlow protocol version "{version}"!')
+
+ # return True if all checks were passed
+ return True
+
+def generate(flow_config):
+ if not flow_config:
+ return None
+
+ render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config)
+ render(systemd_override, 'pmacct/override.conf.j2', flow_config)
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+def apply(flow_config):
+ # Check if flow-accounting was removed and define command
+ if not flow_config:
+ _nftables_config([], 'ingress')
+ _nftables_config([], 'egress')
+
+ # Stop flow-accounting daemon and remove configuration file
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(uacctd_conf_path):
+ os.unlink(uacctd_conf_path)
+
+ # must be done after systemctl
+ _nftables_trigger_setup('delete')
+
+ return
+
+ # Start/reload flow-accounting daemon
+ call(f'systemctl restart {systemd_service}')
+
+ # configure nftables rules for defined interfaces
+ if 'interface' in flow_config:
+ _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length'])
+
+ # configure egress the same way if configured otherwise remove it
+ if 'enable_egress' in flow_config:
+ _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length'])
+ else:
+ _nftables_config([], 'egress')
+
+ # add a trigger for signal processing
+ _nftables_trigger_setup('add')
+
+
+if __name__ == '__main__':
+ try:
+ config = get_config()
+ verify(config)
+ generate(config)
+ apply(config)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py
new file mode 100644
index 0000000..d9ac543
--- /dev/null
+++ b/src/conf_mode/system_frr.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+from sys import exit
+
+from vyos import ConfigError
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.logger import syslog
+from vyos.template import render_to_string
+from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.file import read_file
+from vyos.utils.file import write_file
+from vyos.utils.process import call
+
+from vyos import airbag
+airbag.enable()
+
+# path to daemons config and config status files
+config_file = '/etc/frr/daemons'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['system', 'frr']
+ frr_config = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return frr_config
+
+def verify(frr_config):
+ # Nothing to verify here
+ pass
+
+def generate(frr_config):
+ # read daemons config file
+ daemons_config_current = read_file(config_file)
+ # generate new config file
+ daemons_config_new = render_to_string('frr/daemons.frr.tmpl', frr_config)
+ # update configuration file if this is necessary
+ if daemons_config_new != daemons_config_current:
+ syslog.warning('FRR daemons configuration file need to be changed')
+ write_file(config_file, daemons_config_new)
+ frr_config['config_file_changed'] = True
+
+def apply(frr_config):
+ # display warning to user
+ if boot_configuration_complete() and frr_config.get('config_file_changed'):
+ # Since FRR restart is not safe thing, better to give
+ # control over this to users
+ Warning('You need to reboot the router (preferred) or restart '\
+ 'FRR to apply changes in modules settings')
+
+ # restart FRR automatically
+ # During initial boot this should be safe in most cases
+ if not boot_configuration_complete() and frr_config.get('config_file_changed'):
+ syslog.warning('Restarting FRR to apply changes in modules')
+ call(f'systemctl restart frr.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_host-name.py b/src/conf_mode/system_host-name.py
new file mode 100644
index 0000000..3f245f1
--- /dev/null
+++ b/src/conf_mode/system_host-name.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 re
+import sys
+import copy
+
+import vyos.hostsd_client
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import leaf_node_changed
+from vyos.ifconfig import Section
+from vyos.template import is_ip
+from vyos.utils.process import cmd
+from vyos.utils.process import call
+from vyos.utils.process import process_named_running
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+default_config_data = {
+ 'hostname': 'vyos',
+ 'domain_name': '',
+ 'domain_search': [],
+ 'nameserver': [],
+ 'nameservers_dhcp_interfaces': {},
+ 'snmpd_restart_reqired': False,
+ 'static_host_mapping': {}
+}
+
+hostsd_tag = 'system'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ hosts = copy.deepcopy(default_config_data)
+
+ hosts['hostname'] = conf.return_value(['system', 'host-name'])
+
+ base = ['system']
+ if leaf_node_changed(conf, base + ['host-name']) or leaf_node_changed(conf, base + ['domain-name']):
+ hosts['snmpd_restart_reqired'] = True
+
+ # This may happen if the config is not loaded yet,
+ # e.g. if run by cloud-init
+ if not hosts['hostname']:
+ hosts['hostname'] = default_config_data['hostname']
+
+ if conf.exists(['system', 'domain-name']):
+ hosts['domain_name'] = conf.return_value(['system', 'domain-name'])
+ hosts['domain_search'].append(hosts['domain_name'])
+
+ if conf.exists(['system', 'domain-search']):
+ for search in conf.return_values(['system', 'domain-search']):
+ hosts['domain_search'].append(search)
+
+ if conf.exists(['system', 'name-server']):
+ for ns in conf.return_values(['system', 'name-server']):
+ if is_ip(ns):
+ hosts['nameserver'].append(ns)
+ else:
+ tmp = ''
+ config_path = Section.get_config_path(ns)
+ if conf.exists(['interfaces', config_path, 'address']):
+ tmp = conf.return_values(['interfaces', config_path, 'address'])
+
+ hosts['nameservers_dhcp_interfaces'].update({ ns : tmp })
+
+ # system static-host-mapping
+ for hn in conf.list_nodes(['system', 'static-host-mapping', 'host-name']):
+ hosts['static_host_mapping'][hn] = {}
+ hosts['static_host_mapping'][hn]['address'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'inet'])
+ hosts['static_host_mapping'][hn]['aliases'] = conf.return_values(['system', 'static-host-mapping', 'host-name', hn, 'alias'])
+
+ return hosts
+
+
+def verify(hosts):
+ if hosts is None:
+ return None
+
+ # pattern $VAR(@) "^[[:alnum:]][-.[:alnum:]]*[[:alnum:]]$" ; "invalid host name $VAR(@)"
+ hostname_regex = re.compile("^[A-Za-z0-9][-.A-Za-z0-9]*[A-Za-z0-9]$")
+ if not hostname_regex.match(hosts['hostname']):
+ raise ConfigError('Invalid host name ' + hosts["hostname"])
+
+ # pattern $VAR(@) "^.{1,63}$" ; "invalid host-name length"
+ length = len(hosts['hostname'])
+ if length < 1 or length > 63:
+ raise ConfigError(
+ 'Invalid host-name length, must be less than 63 characters')
+
+ all_static_host_mapping_addresses = []
+ # static mappings alias hostname
+ for host, hostprops in hosts['static_host_mapping'].items():
+ if not hostprops['address']:
+ raise ConfigError(f'IP address required for static-host-mapping "{host}"')
+ all_static_host_mapping_addresses.append(hostprops['address'])
+ for a in hostprops['aliases']:
+ if not hostname_regex.match(a) and len(a) != 0:
+ raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"')
+
+ for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items():
+ # Warnin user if interface does not have DHCP or DHCPv6 configured
+ if not set(interface_config).intersection(['dhcp', 'dhcpv6']):
+ Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!')
+
+ return None
+
+
+def generate(config):
+ pass
+
+def apply(config):
+ if config is None:
+ return None
+
+ ## Send the updated data to vyos-hostsd
+ try:
+ hc = vyos.hostsd_client.Client()
+
+ hc.set_host_name(config['hostname'], config['domain_name'])
+
+ hc.delete_search_domains([hostsd_tag])
+ if config['domain_search']:
+ hc.add_search_domains({hostsd_tag: config['domain_search']})
+
+ hc.delete_name_servers([hostsd_tag])
+ if config['nameserver']:
+ hc.add_name_servers({hostsd_tag: config['nameserver']})
+
+ # add our own tag's (system) nameservers and search to resolv.conf
+ hc.delete_name_server_tags_system(hc.get_name_server_tags_system())
+ hc.add_name_server_tags_system([hostsd_tag])
+
+ # this will add the dhcp client nameservers to resolv.conf
+ for intf in config['nameservers_dhcp_interfaces']:
+ hc.add_name_server_tags_system([f'dhcp-{intf}', f'dhcpv6-{intf}'])
+
+ hc.delete_hosts([hostsd_tag])
+ if config['static_host_mapping']:
+ hc.add_hosts({hostsd_tag: config['static_host_mapping']})
+
+ hc.apply()
+ except vyos.hostsd_client.VyOSHostsdError as e:
+ raise ConfigError(str(e))
+
+ ## Actually update the hostname -- vyos-hostsd doesn't do that
+
+ # No domain name -- the Debian way.
+ hostname_new = config['hostname']
+
+ # rsyslog runs into a race condition at boot time with systemd
+ # restart rsyslog only if the hostname changed.
+ hostname_old = cmd('hostnamectl --static')
+ call(f'hostnamectl set-hostname --static {hostname_new}')
+
+ # Restart services that use the hostname
+ if hostname_new != hostname_old:
+ call("systemctl restart rsyslog.service")
+
+ # If SNMP is running, restart it too
+ if process_named_running('snmpd') and config['snmpd_restart_reqired']:
+ call('systemctl restart snmpd.service')
+
+ return None
+
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py
new file mode 100644
index 0000000..c8a91fd
--- /dev/null
+++ b/src/conf_mode/system_ip.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_route_map
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.system import sysctl_write
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'ip']
+
+ opt = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ # When working with FRR we need to know the corresponding address-family
+ opt['afi'] = 'ip'
+
+ # We also need the route-map information from the config
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
+ get_first_key=True)}}
+ # Merge policy dict into "regular" config dict
+ opt = dict_merge(tmp, opt)
+
+ # If IPv4 ARP table size is set here and also manually in sysctl, the more
+ # fine grained value from sysctl must win
+ set_dependents('sysctl', conf)
+
+ return opt
+
+def verify(opt):
+ if 'protocol' in opt:
+ for protocol, protocol_options in opt['protocol'].items():
+ if 'route_map' in protocol_options:
+ verify_route_map(protocol_options['route_map'], opt)
+ return
+
+def generate(opt):
+ opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt)
+ return
+
+def apply(opt):
+ # Apply ARP threshold values
+ # table_size has a default value - thus the key always exists
+ size = int(dict_search('arp.table_size', opt))
+ # Amount upon reaching which the records begin to be cleared immediately
+ sysctl_write('net.ipv4.neigh.default.gc_thresh3', size)
+ # Amount after which the records begin to be cleaned after 5 seconds
+ sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2)
+ # Minimum number of stored records is indicated which is not cleared
+ sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8)
+
+ # enable/disable IPv4 forwarding
+ tmp = dict_search('disable_forwarding', opt)
+ value = '0' if (tmp != None) else '1'
+ write_file('/proc/sys/net/ipv4/conf/all/forwarding', value)
+
+ # configure multipath
+ tmp = dict_search('multipath.ignore_unreachable_nexthops', opt)
+ value = '1' if (tmp != None) else '0'
+ sysctl_write('net.ipv4.fib_multipath_use_neigh', value)
+
+ tmp = dict_search('multipath.layer4_hashing', opt)
+ value = '1' if (tmp != None) else '0'
+ sysctl_write('net.ipv4.fib_multipath_hash_policy', value)
+
+ # configure TCP options (defaults as of Linux 6.4)
+ tmp = dict_search('tcp.mss.probing', opt)
+ if tmp is None:
+ value = 0
+ elif tmp == 'on-icmp-black-hole':
+ value = 1
+ elif tmp == 'force':
+ value = 2
+ else:
+ # Shouldn't happen
+ raise ValueError("TCP MSS probing is neither 'on-icmp-black-hole' nor 'force'!")
+ sysctl_write('net.ipv4.tcp_mtu_probing', value)
+
+ tmp = dict_search('tcp.mss.base', opt)
+ value = '1024' if (tmp is None) else tmp
+ sysctl_write('net.ipv4.tcp_base_mss', value)
+
+ tmp = dict_search('tcp.mss.floor', opt)
+ value = '48' if (tmp is None) else tmp
+ sysctl_write('net.ipv4.tcp_mtu_probe_floor', value)
+
+ # During startup of vyos-router that brings up FRR, the service is not yet
+ # running when this script is called first. Skip this part and wait for initial
+ # commit of the configuration to trigger this statement
+ if is_systemd_service_active('frr.service'):
+ zebra_daemon = 'zebra'
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(r'no ip nht resolve-via-default')
+ frr_cfg.modify_section(r'ip protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
+ if 'frr_zebra_config' in opt:
+ frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ call_dependents()
+
+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_ipv6.py b/src/conf_mode/system_ipv6.py
new file mode 100644
index 0000000..a2442d0
--- /dev/null
+++ b/src/conf_mode/system_ipv6.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 os
+
+from sys import exit
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configverify import verify_route_map
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.system import sysctl_write
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'ipv6']
+
+ opt = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ # When working with FRR we need to know the corresponding address-family
+ opt['afi'] = 'ipv6'
+
+ # We also need the route-map information from the config
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
+ get_first_key=True)}}
+ # Merge policy dict into "regular" config dict
+ opt = dict_merge(tmp, opt)
+
+ # If IPv6 neighbor table size is set here and also manually in sysctl, the more
+ # fine grained value from sysctl must win
+ set_dependents('sysctl', conf)
+
+ return opt
+
+def verify(opt):
+ if 'protocol' in opt:
+ for protocol, protocol_options in opt['protocol'].items():
+ if 'route_map' in protocol_options:
+ verify_route_map(protocol_options['route_map'], opt)
+ return
+
+def generate(opt):
+ opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt)
+ return
+
+def apply(opt):
+ # configure multipath
+ tmp = dict_search('multipath.layer4_hashing', opt)
+ value = '1' if (tmp != None) else '0'
+ sysctl_write('net.ipv6.fib_multipath_hash_policy', value)
+
+ # Apply ND threshold values
+ # table_size has a default value - thus the key always exists
+ size = int(dict_search('neighbor.table_size', opt))
+ # Amount upon reaching which the records begin to be cleared immediately
+ sysctl_write('net.ipv6.neigh.default.gc_thresh3', size)
+ # Amount after which the records begin to be cleaned after 5 seconds
+ sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2)
+ # Minimum number of stored records is indicated which is not cleared
+ sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8)
+
+ # enable/disable IPv6 forwarding
+ tmp = dict_search('disable_forwarding', opt)
+ value = '0' if (tmp != None) else '1'
+ write_file('/proc/sys/net/ipv6/conf/all/forwarding', value)
+
+ # configure IPv6 strict-dad
+ tmp = dict_search('strict_dad', opt)
+ value = '2' if (tmp != None) else '1'
+ for root, dirs, files in os.walk('/proc/sys/net/ipv6/conf'):
+ for name in files:
+ if name == 'accept_dad':
+ write_file(os.path.join(root, name), value)
+
+ # During startup of vyos-router that brings up FRR, the service is not yet
+ # running when this script is called first. Skip this part and wait for initial
+ # commit of the configuration to trigger this statement
+ if is_systemd_service_active('frr.service'):
+ zebra_daemon = 'zebra'
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(r'no ipv6 nht resolve-via-default')
+ frr_cfg.modify_section(r'ipv6 protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
+ if 'frr_zebra_config' in opt:
+ frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ call_dependents()
+
+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_lcd.py b/src/conf_mode/system_lcd.py
new file mode 100644
index 0000000..eb88224
--- /dev/null
+++ b/src/conf_mode/system_lcd.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+#
+# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# 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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.utils.process import call
+from vyos.utils.system import find_device_file
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+lcdd_conf = '/run/LCDd/LCDd.conf'
+lcdproc_conf = '/run/lcdproc/lcdproc.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'lcd']
+ lcd = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+ # Return (possibly empty) dictionary
+ return lcd
+
+def verify(lcd):
+ if not lcd:
+ return None
+
+ if 'model' in lcd and lcd['model'] in ['sdec']:
+ # This is a fixed LCD display, no device needed - bail out early
+ return None
+
+ if not {'device', 'model'} <= set(lcd):
+ raise ConfigError('Both device and driver must be set!')
+
+ return None
+
+def generate(lcd):
+ if not lcd:
+ return None
+
+ if 'device' in lcd:
+ lcd['device'] = find_device_file(lcd['device'])
+
+ # Render config file for daemon LCDd
+ render(lcdd_conf, 'lcd/LCDd.conf.j2', lcd)
+ # Render config file for client lcdproc
+ render(lcdproc_conf, 'lcd/lcdproc.conf.j2', lcd)
+
+ return None
+
+def apply(lcd):
+ if not lcd:
+ call('systemctl stop lcdproc.service LCDd.service')
+
+ for file in [lcdd_conf, lcdproc_conf]:
+ if os.path.exists(file):
+ os.remove(file)
+ else:
+ # Restart server
+ call('systemctl restart LCDd.service lcdproc.service')
+
+ return None
+
+if __name__ == '__main__':
+ try:
+ config_dict = get_config()
+ verify(config_dict)
+ generate(config_dict)
+ apply(config_dict)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py
new file mode 100644
index 0000000..439fa64
--- /dev/null
+++ b/src/conf_mode/system_login.py
@@ -0,0 +1,413 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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
+
+from passlib.hosts import linux_context
+from psutil import users
+from pwd import getpwall
+from pwd import getpwnam
+from pwd import getpwuid
+from sys import exit
+from time import sleep
+
+from vyos.config import Config
+from vyos.configverify import verify_vrf
+from vyos.template import render
+from vyos.template import is_ipv4
+from vyos.utils.auth import get_current_user
+from vyos.utils.configfs import delete_cli_node
+from vyos.utils.configfs import add_cli_node
+from vyos.utils.dict import dict_search
+from vyos.utils.file import chown
+from vyos.utils.process import cmd
+from vyos.utils.process import call
+from vyos.utils.process import run
+from vyos.utils.process import DEVNULL
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+autologout_file = "/etc/profile.d/autologout.sh"
+limits_file = "/etc/security/limits.d/10-vyos.conf"
+radius_config_file = "/etc/pam_radius_auth.conf"
+tacacs_pam_config_file = "/etc/tacplus_servers"
+tacacs_nss_config_file = "/etc/tacplus_nss.conf"
+nss_config_file = "/etc/nsswitch.conf"
+
+# Minimum UID used when adding system users
+MIN_USER_UID: int = 1000
+# Maximim UID used when adding system users
+MAX_USER_UID: int = 59999
+# LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec
+MAX_RADIUS_TIMEOUT: int = 50
+# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout)
+MAX_RADIUS_COUNT: int = 8
+# Maximum number of supported TACACS servers
+MAX_TACACS_COUNT: int = 8
+
+# List of local user accounts that must be preserved
+SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1',
+ 'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6',
+ 'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11',
+ 'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15']
+
+def get_local_users():
+ """Return list of dynamically allocated users (see Debian Policy Manual)"""
+ local_users = []
+ for s_user in getpwall():
+ if getpwnam(s_user.pw_name).pw_uid < MIN_USER_UID:
+ continue
+ if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID:
+ continue
+ if s_user.pw_name in SYSTEM_USER_SKIP_LIST:
+ continue
+ local_users.append(s_user.pw_name)
+
+ return local_users
+
+def get_shadow_password(username):
+ with open('/etc/shadow') as f:
+ for user in f.readlines():
+ items = user.split(":")
+ if username == items[0]:
+ return items[1]
+ return None
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'login']
+ login = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ # users no longer existing in the running configuration need to be deleted
+ local_users = get_local_users()
+ cli_users = []
+ if 'user' in login:
+ cli_users = list(login['user'])
+
+ # prune TACACS global defaults if not set by user
+ if login.from_defaults(['tacacs']):
+ del login['tacacs']
+ # same for RADIUS
+ if login.from_defaults(['radius']):
+ del login['radius']
+
+ # create a list of all users, cli and users
+ all_users = list(set(local_users + cli_users))
+ # We will remove any normal users that dos not exist in the current
+ # configuration. This can happen if user is added but configuration was not
+ # saved and system is rebooted.
+ rm_users = [tmp for tmp in all_users if tmp not in cli_users]
+ if rm_users: login.update({'rm_users' : rm_users})
+
+ return login
+
+def verify(login):
+ if 'rm_users' in login:
+ # This check is required as the script is also executed from vyos-router
+ # init script and there is no SUDO_USER environment variable available
+ # during system boot.
+ tmp = get_current_user()
+ if tmp in login['rm_users']:
+ raise ConfigError(f'Attempting to delete current user: {tmp}')
+
+ if 'user' in login:
+ system_users = getpwall()
+ for user, user_config in login['user'].items():
+ # Linux system users range up until UID 1000, we can not create a
+ # VyOS CLI user which already exists as system user
+ for s_user in system_users:
+ if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID:
+ raise ConfigError(f'User "{user}" can not be created, conflict with local system account!')
+
+ for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items():
+ if 'type' not in pubkey_options:
+ raise ConfigError(f'Missing type for public-key "{pubkey}"!')
+ if 'key' not in pubkey_options:
+ raise ConfigError(f'Missing key for public-key "{pubkey}"!')
+
+ if {'radius', 'tacacs'} <= set(login):
+ raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!')
+
+ # At lease one RADIUS server must not be disabled
+ if 'radius' in login:
+ if 'server' not in login['radius']:
+ raise ConfigError('No RADIUS server defined!')
+ sum_timeout: int = 0
+ radius_servers_count: int = 0
+ fail = True
+ for server, server_config in dict_search('radius.server', login).items():
+ if 'key' not in server_config:
+ raise ConfigError(f'RADIUS server "{server}" requires key!')
+ if 'disable' not in server_config:
+ sum_timeout += int(server_config['timeout'])
+ radius_servers_count += 1
+ fail = False
+
+ if fail:
+ raise ConfigError('All RADIUS servers are disabled')
+
+ if radius_servers_count > MAX_RADIUS_COUNT:
+ raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!')
+
+ if sum_timeout > MAX_RADIUS_TIMEOUT:
+ raise ConfigError('Sum of RADIUS servers timeouts '
+ 'has to be less or eq 50 sec')
+
+ verify_vrf(login['radius'])
+
+ if 'source_address' in login['radius']:
+ ipv4_count = 0
+ ipv6_count = 0
+ for address in login['radius']['source_address']:
+ if is_ipv4(address): ipv4_count += 1
+ else: ipv6_count += 1
+
+ if ipv4_count > 1:
+ raise ConfigError('Only one IPv4 source-address can be set!')
+ if ipv6_count > 1:
+ raise ConfigError('Only one IPv6 source-address can be set!')
+
+ if 'tacacs' in login:
+ tacacs_servers_count: int = 0
+ fail = True
+ for server, server_config in dict_search('tacacs.server', login).items():
+ if 'key' not in server_config:
+ raise ConfigError(f'TACACS server "{server}" requires key!')
+ if 'disable' not in server_config:
+ tacacs_servers_count += 1
+ fail = False
+
+ if fail:
+ raise ConfigError('All RADIUS servers are disabled')
+
+ if tacacs_servers_count > MAX_TACACS_COUNT:
+ raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!')
+
+ verify_vrf(login['tacacs'])
+
+ if 'max_login_session' in login and 'timeout' not in login:
+ raise ConfigError('"login timeout" must be configured!')
+
+ return None
+
+
+def generate(login):
+ # calculate users encrypted password
+ if 'user' in login:
+ for user, user_config in login['user'].items():
+ tmp = dict_search('authentication.plaintext_password', user_config)
+ if tmp:
+ encrypted_password = linux_context.hash(tmp)
+ login['user'][user]['authentication']['encrypted_password'] = encrypted_password
+ del login['user'][user]['authentication']['plaintext_password']
+
+ # Set default commands for re-adding user with encrypted password
+ del_user_plain = ['system', 'login', 'user', user, 'authentication', 'plaintext-password']
+ add_user_encrypt = ['system', 'login', 'user', user, 'authentication', 'encrypted-password']
+
+ delete_cli_node(del_user_plain)
+ add_cli_node(add_user_encrypt, value=encrypted_password)
+
+ else:
+ try:
+ if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config):
+ # If the current encrypted bassword matches the encrypted password
+ # from the config - do not update it. This will remove the encrypted
+ # value from the system logs.
+ #
+ # The encrypted password will be set only once during the first boot
+ # after an image upgrade.
+ del login['user'][user]['authentication']['encrypted_password']
+ except:
+ pass
+
+ ### RADIUS based user authentication
+ if 'radius' in login:
+ render(radius_config_file, 'login/pam_radius_auth.conf.j2', login,
+ permission=0o600, user='root', group='root')
+ else:
+ if os.path.isfile(radius_config_file):
+ os.unlink(radius_config_file)
+
+ ### TACACS+ based user authentication
+ if 'tacacs' in login:
+ render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login,
+ permission=0o644, user='root', group='root')
+ render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login,
+ permission=0o644, user='root', group='root')
+ else:
+ if os.path.isfile(tacacs_pam_config_file):
+ os.unlink(tacacs_pam_config_file)
+ if os.path.isfile(tacacs_nss_config_file):
+ os.unlink(tacacs_nss_config_file)
+
+ # NSS must always be present on the system
+ render(nss_config_file, 'login/nsswitch.conf.j2', login,
+ permission=0o644, user='root', group='root')
+
+ # /etc/security/limits.d/10-vyos.conf
+ if 'max_login_session' in login:
+ render(limits_file, 'login/limits.j2', login,
+ permission=0o644, user='root', group='root')
+ else:
+ if os.path.isfile(limits_file):
+ os.unlink(limits_file)
+
+ if 'timeout' in login:
+ render(autologout_file, 'login/autologout.j2', login,
+ permission=0o755, user='root', group='root')
+ else:
+ if os.path.isfile(autologout_file):
+ os.unlink(autologout_file)
+
+ return None
+
+
+def apply(login):
+ enable_otp = False
+ if 'user' in login:
+ for user, user_config in login['user'].items():
+ # make new user using vyatta shell and make home directory (-m),
+ # default group of 100 (users)
+ command = 'useradd --create-home --no-user-group '
+ # check if user already exists:
+ if user in get_local_users():
+ # update existing account
+ command = 'usermod'
+
+ # all accounts use /bin/vbash
+ command += ' --shell /bin/vbash'
+ # we need to use '' quotes when passing formatted data to the shell
+ # else it will not work as some data parts are lost in translation
+ tmp = dict_search('authentication.encrypted_password', user_config)
+ if tmp: command += f" --password '{tmp}'"
+
+ tmp = dict_search('full_name', user_config)
+ if tmp: command += f" --comment '{tmp}'"
+
+ tmp = dict_search('home_directory', user_config)
+ if tmp: command += f" --home '{tmp}'"
+ else: command += f" --home '/home/{user}'"
+
+ command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}'
+ try:
+ cmd(command)
+ # we should not rely on the value stored in user_config['home_directory'], as a
+ # crazy user will choose username root or any other system user which will fail.
+ #
+ # XXX: Should we deny using root at all?
+ home_dir = getpwnam(user).pw_dir
+ # always re-render SSH keys with appropriate permissions
+ render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2',
+ user_config, permission=0o600,
+ formater=lambda _: _.replace("&quot;", '"'),
+ user=user, group='users')
+ except Exception as e:
+ raise ConfigError(f'Adding user "{user}" raised exception: "{e}"')
+
+ # T5875: ensure UID is properly set on home directory if user is re-added
+ # the home directory will always exist, as it's created above by --create-home,
+ # retrieve current owner of home directory and adjust on demand
+ dir_owner = None
+ try:
+ dir_owner = getpwuid(os.stat(home_dir).st_uid).pw_name
+ except:
+ pass
+
+ if dir_owner != user:
+ chown(home_dir, user=user, recursive=True)
+
+ # Generate 2FA/MFA One-Time-Pad configuration
+ if dict_search('authentication.otp.key', user_config):
+ enable_otp = True
+ render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2',
+ user_config, permission=0o400, user=user, group='users')
+ else:
+ # delete configuration as it's not enabled for the user
+ if os.path.exists(f'{home_dir}/.google_authenticator'):
+ os.remove(f'{home_dir}/.google_authenticator')
+
+ # Lock/Unlock local user account
+ lock_unlock = '--unlock'
+ if 'disable' in user_config:
+ lock_unlock = '--lock'
+ cmd(f'usermod {lock_unlock} {user}')
+
+ if 'rm_users' in login:
+ for user in login['rm_users']:
+ try:
+ # Disable user to prevent re-login
+ call(f'usermod -s /sbin/nologin {user}')
+
+ # Logout user if he is still logged in
+ if user in list(set([tmp[0] for tmp in users()])):
+ print(f'{user} is logged in, forcing logout!')
+ # re-run command until user is logged out
+ while run(f'pkill -HUP -u {user}'):
+ sleep(0.250)
+
+ # Remove user account but leave home directory in place. Re-run
+ # command until user is removed - userdel might return 8 as
+ # SSH sessions are not all yet properly cleaned away, thus we
+ # simply re-run the command until the account wen't away
+ while run(f'userdel {user}', stderr=DEVNULL):
+ sleep(0.250)
+
+ except Exception as e:
+ raise ConfigError(f'Deleting user "{user}" raised exception: {e}')
+
+ # Enable/disable RADIUS in PAM configuration
+ cmd('pam-auth-update --disable radius-mandatory radius-optional')
+ if 'radius' in login:
+ if login['radius'].get('security_mode', '') == 'mandatory':
+ pam_profile = 'radius-mandatory'
+ else:
+ pam_profile = 'radius-optional'
+ cmd(f'pam-auth-update --enable {pam_profile}')
+
+ # Enable/disable TACACS+ in PAM configuration
+ cmd('pam-auth-update --disable tacplus-mandatory tacplus-optional')
+ if 'tacacs' in login:
+ if login['tacacs'].get('security_mode', '') == 'mandatory':
+ pam_profile = 'tacplus-mandatory'
+ else:
+ pam_profile = 'tacplus-optional'
+ cmd(f'pam-auth-update --enable {pam_profile}')
+
+ # Enable/disable Google authenticator
+ cmd('pam-auth-update --disable mfa-google-authenticator')
+ if enable_otp:
+ cmd(f'pam-auth-update --enable mfa-google-authenticator')
+
+ return None
+
+
+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_login_banner.py b/src/conf_mode/system_login_banner.py
new file mode 100644
index 0000000..923e1bf
--- /dev/null
+++ b/src/conf_mode/system_login_banner.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+from sys import exit
+from copy import deepcopy
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.file import write_file
+from vyos.version import get_version_data
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+PRELOGIN_FILE = r'/etc/issue'
+PRELOGIN_NET_FILE = r'/etc/issue.net'
+POSTLOGIN_FILE = r'/etc/motd'
+
+default_config_data = {
+ 'issue': 'Welcome to VyOS - \\n \\l\n\n',
+ 'issue_net': ''
+}
+
+def get_config(config=None):
+ banner = deepcopy(default_config_data)
+ banner['version_data'] = get_version_data()
+
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base_level = ['system', 'login', 'banner']
+
+ if not conf.exists(base_level):
+ return banner
+ else:
+ conf.set_level(base_level)
+
+ # Post-Login banner
+ if conf.exists(['post-login']):
+ tmp = conf.return_value(['post-login'])
+ # post-login banner can be empty as well
+ if tmp:
+ tmp = tmp.replace('\\n','\n')
+ tmp = tmp.replace('\\t','\t')
+ # always add newline character
+ tmp += '\n'
+ else:
+ tmp = ''
+
+ banner['motd'] = tmp
+
+ # Pre-Login banner
+ if conf.exists(['pre-login']):
+ tmp = conf.return_value(['pre-login'])
+ # pre-login banner can be empty as well
+ if tmp:
+ tmp = tmp.replace('\\n','\n')
+ tmp = tmp.replace('\\t','\t')
+ # always add newline character
+ tmp += '\n'
+ else:
+ tmp = ''
+
+ banner['issue'] = banner['issue_net'] = tmp
+
+ return banner
+
+def verify(banner):
+ pass
+
+def generate(banner):
+ pass
+
+def apply(banner):
+ write_file(PRELOGIN_FILE, banner['issue'])
+ write_file(PRELOGIN_NET_FILE, banner['issue_net'])
+ if 'motd' in banner:
+ write_file(POSTLOGIN_FILE, banner['motd'])
+ else:
+ render(POSTLOGIN_FILE, 'login/default_motd.j2', banner,
+ permission=0o644, user='root', group='root')
+
+ return None
+
+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_logs.py b/src/conf_mode/system_logs.py
new file mode 100644
index 0000000..8ad4875
--- /dev/null
+++ b/src/conf_mode/system_logs.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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/>.
+
+from sys import exit
+
+from vyos import ConfigError
+from vyos import airbag
+from vyos.config import Config
+from vyos.logger import syslog
+from vyos.template import render
+from vyos.utils.dict import dict_search
+airbag.enable()
+
+# path to logrotate configs
+logrotate_atop_file = '/etc/logrotate.d/vyos-atop'
+logrotate_rsyslog_file = '/etc/logrotate.d/vyos-rsyslog'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['system', 'logs']
+ logs_config = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return logs_config
+
+
+def verify(logs_config):
+ # Nothing to verify here
+ pass
+
+
+def generate(logs_config):
+ # get configuration for logrotate atop
+ logrotate_atop = dict_search('logrotate.atop', logs_config)
+ # generate new config file for atop
+ syslog.debug('Adding logrotate config for atop')
+ render(logrotate_atop_file, 'logs/logrotate/vyos-atop.j2', logrotate_atop)
+
+ # get configuration for logrotate rsyslog
+ logrotate_rsyslog = dict_search('logrotate.messages', logs_config)
+ # generate new config file for rsyslog
+ syslog.debug('Adding logrotate config for rsyslog')
+ render(logrotate_rsyslog_file, 'logs/logrotate/vyos-rsyslog.j2',
+ logrotate_rsyslog)
+
+
+def apply(logs_config):
+ # No further actions needed
+ pass
+
+
+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_option.py b/src/conf_mode/system_option.py
new file mode 100644
index 0000000..a84572f
--- /dev/null
+++ b/src/conf_mode/system_option.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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
+
+from sys import exit
+from time import sleep
+
+
+from vyos.config import Config
+from vyos.configverify import verify_source_interface
+from vyos.configverify import verify_interface_exists
+from vyos.system import grub_util
+from vyos.template import render
+from vyos.utils.cpu import get_cpus
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.kernel import check_kmod
+from vyos.utils.process import cmd
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.network import is_addr_assigned
+from vyos.utils.network import is_intf_addr_assigned
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+curlrc_config = r'/etc/curlrc'
+ssh_config = r'/etc/ssh/ssh_config.d/91-vyos-ssh-client-options.conf'
+systemd_action_file = '/lib/systemd/system/ctrl-alt-del.target'
+usb_autosuspend = r'/etc/udev/rules.d/40-usb-autosuspend.rules'
+kernel_dynamic_debug = r'/sys/kernel/debug/dynamic_debug/control'
+time_format_to_locale = {'12-hour': 'en_US.UTF-8', '24-hour': 'en_GB.UTF-8'}
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'option']
+ options = conf.get_config_dict(
+ base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True
+ )
+
+ if 'performance' in options:
+ # Update IPv4/IPv6 and sysctl options after tuned applied it's settings
+ set_dependents('ip_ipv6', conf)
+ set_dependents('sysctl', conf)
+
+ return options
+
+
+def verify(options):
+ if 'http_client' in options:
+ config = options['http_client']
+ if 'source_interface' in config:
+ verify_interface_exists(options, config['source_interface'])
+
+ if {'source_address', 'source_interface'} <= set(config):
+ raise ConfigError(
+ 'Can not define both HTTP source-interface and source-address'
+ )
+
+ if 'source_address' in config:
+ if not is_addr_assigned(config['source_address']):
+ raise ConfigError('No interface with give address specified!')
+
+ if 'ssh_client' in options:
+ config = options['ssh_client']
+ if 'source_address' in config:
+ address = config['source_address']
+ if not is_addr_assigned(config['source_address']):
+ raise ConfigError('No interface with address "{address}" configured!')
+
+ if 'source_interface' in config:
+ # verify_source_interface reuires key 'ifname'
+ config['ifname'] = config['source_interface']
+ verify_source_interface(config)
+ if 'source_address' in config:
+ address = config['source_address']
+ interface = config['source_interface']
+ if not is_intf_addr_assigned(interface, address):
+ raise ConfigError(
+ f'Address "{address}" not assigned on interface "{interface}"!'
+ )
+
+ if 'kernel' in options:
+ cpu_vendor = get_cpus()[0]['vendor_id']
+ if 'amd_pstate_driver' in options['kernel'] and cpu_vendor != 'AuthenticAMD':
+ raise ConfigError(
+ f'AMD pstate driver cannot be used with "{cpu_vendor}" CPU!'
+ )
+
+ return None
+
+
+def generate(options):
+ render(curlrc_config, 'system/curlrc.j2', options)
+ render(ssh_config, 'system/ssh_config.j2', options)
+ render(usb_autosuspend, 'system/40_usb_autosuspend.j2', options)
+
+ cmdline_options = []
+ if 'kernel' in options:
+ if 'disable_mitigations' in options['kernel']:
+ cmdline_options.append('mitigations=off')
+ if 'disable_power_saving' in options['kernel']:
+ cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1')
+ if 'amd_pstate_driver' in options['kernel']:
+ mode = options['kernel']['amd_pstate_driver']
+ cmdline_options.append(
+ f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}'
+ )
+ grub_util.update_kernel_cmdline_options(' '.join(cmdline_options))
+
+ return None
+
+
+def apply(options):
+ # System bootup beep
+ beep_service = 'vyos-beep.service'
+ if 'startup_beep' in options:
+ cmd(f'systemctl enable {beep_service}')
+ else:
+ cmd(f'systemctl disable {beep_service}')
+
+ # Ctrl-Alt-Delete action
+ if os.path.exists(systemd_action_file):
+ os.unlink(systemd_action_file)
+ if 'ctrl_alt_delete' in options:
+ if options['ctrl_alt_delete'] == 'reboot':
+ os.symlink('/lib/systemd/system/reboot.target', systemd_action_file)
+ elif options['ctrl_alt_delete'] == 'poweroff':
+ os.symlink('/lib/systemd/system/poweroff.target', systemd_action_file)
+
+ # Configure HTTP client
+ if 'http_client' not in options:
+ if os.path.exists(curlrc_config):
+ os.unlink(curlrc_config)
+
+ # Configure SSH client
+ if 'ssh_client' not in options:
+ if os.path.exists(ssh_config):
+ os.unlink(ssh_config)
+
+ # Reboot system on kernel panic
+ timeout = '0'
+ if 'reboot_on_panic' in options:
+ timeout = '60'
+ with open('/proc/sys/kernel/panic', 'w') as f:
+ f.write(timeout)
+
+ # tuned - performance tuning
+ if 'performance' in options:
+ cmd('systemctl restart tuned.service')
+ # wait until daemon has started before sending configuration
+ while not is_systemd_service_running('tuned.service'):
+ sleep(0.250)
+ cmd('tuned-adm profile network-{performance}'.format(**options))
+ else:
+ cmd('systemctl stop tuned.service')
+
+ call_dependents()
+
+ # Keyboard layout - there will be always the default key inside the dict
+ # but we check for key existence anyway
+ if 'keyboard_layout' in options:
+ cmd('loadkeys {keyboard_layout}'.format(**options))
+
+ # Enable/diable root-partition-auto-resize SystemD service
+ if 'root_partition_auto_resize' in options:
+ cmd('systemctl enable root-partition-auto-resize.service')
+ else:
+ cmd('systemctl disable root-partition-auto-resize.service')
+
+ # Time format 12|24-hour
+ if 'time_format' in options:
+ time_format = time_format_to_locale.get(options['time_format'])
+ cmd(f'localectl set-locale LC_TIME={time_format}')
+
+ # Reload UDEV, required for USB auto suspend
+ cmd('udevadm control --reload-rules')
+
+ # Enable/disable dynamic debugging for kernel modules
+ modules = ['wireguard']
+ modules_enabled = dict_search('kernel.debug', options) or []
+ for module in modules:
+ if module in modules_enabled:
+ check_kmod(module)
+ write_file(kernel_dynamic_debug, f'module {module} +p')
+ else:
+ write_file(kernel_dynamic_debug, f'module {module} -p')
+
+
+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_proxy.py b/src/conf_mode/system_proxy.py
new file mode 100644
index 0000000..079c43e
--- /dev/null
+++ b/src/conf_mode/system_proxy.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2022 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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+proxy_def = r'/etc/profile.d/vyos-system-proxy.sh'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'proxy']
+ if not conf.exists(base):
+ return None
+
+ proxy = conf.get_config_dict(base, get_first_key=True)
+ return proxy
+
+def verify(proxy):
+ if not proxy:
+ return
+
+ if 'url' not in proxy or 'port' not in proxy:
+ raise ConfigError('Proxy URL and port require a value')
+
+ if ('username' in proxy and 'password' not in proxy) or \
+ ('username' not in proxy and 'password' in proxy):
+ raise ConfigError('Both username and password need to be defined!')
+
+def generate(proxy):
+ if not proxy:
+ if os.path.isfile(proxy_def):
+ os.unlink(proxy_def)
+ return
+
+ render(proxy_def, 'system/proxy.j2', proxy, permission=0o755)
+
+def apply(proxy):
+ pass
+
+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_sflow.py b/src/conf_mode/system_sflow.py
new file mode 100644
index 0000000..41119b4
--- /dev/null
+++ b/src/conf_mode/system_sflow.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configverify import verify_vrf
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.network import is_addr_assigned
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+hsflowd_conf_path = '/run/sflow/hsflowd.conf'
+systemd_service = 'hsflowd.service'
+systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'sflow']
+ if not conf.exists(base):
+ return None
+
+ sflow = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return sflow
+
+def verify(sflow):
+ if not sflow:
+ return None
+
+ # Check if configured sflow agent-address exist in the system
+ if 'agent_address' in sflow:
+ tmp = sflow['agent_address']
+ if not is_addr_assigned(tmp):
+ raise ConfigError(
+ f'Configured "sflow agent-address {tmp}" does not exist in the system!'
+ )
+
+ # Check if at least one interface is configured
+ if 'interface' not in sflow:
+ raise ConfigError(
+ 'sFlow requires at least one interface to be configured!')
+
+ # Check if at least one server is configured
+ if 'server' not in sflow:
+ raise ConfigError('You need to configure at least one sFlow server!')
+
+ verify_vrf(sflow)
+ return None
+
+def generate(sflow):
+ if not sflow:
+ return None
+
+ render(hsflowd_conf_path, 'sflow/hsflowd.conf.j2', sflow)
+ render(systemd_override, 'sflow/override.conf.j2', sflow)
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+
+def apply(sflow):
+ if not sflow:
+ # Stop flow-accounting daemon and remove configuration file
+ call(f'systemctl stop {systemd_service}')
+ if os.path.exists(hsflowd_conf_path):
+ os.unlink(hsflowd_conf_path)
+ return
+
+ # Start/reload flow-accounting daemon
+ call(f'systemctl restart {systemd_service}')
+
+if __name__ == '__main__':
+ try:
+ config = get_config()
+ verify(config)
+ generate(config)
+ apply(config)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py
new file mode 100644
index 0000000..f6b0202
--- /dev/null
+++ b/src/conf_mode/system_sysctl.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import cmd
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+config_file = r'/run/sysctl/99-vyos-sysctl.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'sysctl']
+ if not conf.exists(base):
+ return None
+
+ sysctl = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ return sysctl
+
+def verify(sysctl):
+ return None
+
+def generate(sysctl):
+ if not sysctl:
+ if os.path.isfile(config_file):
+ os.unlink(config_file)
+ return None
+
+ render(config_file, 'system/sysctl.conf.j2', sysctl)
+ return None
+
+def apply(sysctl):
+ if not sysctl:
+ return None
+
+ # We silently ignore all errors
+ # See: https://bugzilla.redhat.com/show_bug.cgi?id=1264080
+ cmd(f'sysctl -f {config_file}')
+ return None
+
+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_syslog.py b/src/conf_mode/system_syslog.py
new file mode 100644
index 0000000..eb2f02e
--- /dev/null
+++ b/src/conf_mode/system_syslog.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_vrf
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf'
+logrotate_conf = '/etc/logrotate.d/vyos-rsyslog'
+systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'syslog']
+ if not conf.exists(base):
+ return None
+
+ syslog = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ syslog.update({ 'logrotate' : logrotate_conf })
+
+ tmp = is_node_changed(conf, base + ['vrf'])
+ if tmp: syslog.update({'restart_required': {}})
+
+ syslog = conf.merge_defaults(syslog, recursive=True)
+ if syslog.from_defaults(['global']):
+ del syslog['global']
+
+ if (
+ 'global' in syslog
+ and 'preserve_fqdn' in syslog['global']
+ and conf.exists(['system', 'host-name'])
+ and conf.exists(['system', 'domain-name'])
+ ):
+ hostname = conf.return_value(['system', 'host-name'])
+ domain = conf.return_value(['system', 'domain-name'])
+ fqdn = f'{hostname}.{domain}'
+ syslog['global']['local_host_name'] = fqdn
+
+ return syslog
+
+def verify(syslog):
+ if not syslog:
+ return None
+
+ if 'host' in syslog:
+ for host, host_options in syslog['host'].items():
+ if 'protocol' in host_options and host_options['protocol'] == 'udp':
+ if 'format' in host_options and 'octet_counted' in host_options['format']:
+ Warning(f'Syslog UDP transport for "{host}" should not use octet-counted format!')
+
+ verify_vrf(syslog)
+
+def generate(syslog):
+ if not syslog:
+ if os.path.exists(rsyslog_conf):
+ os.unlink(rsyslog_conf)
+ if os.path.exists(logrotate_conf):
+ os.unlink(logrotate_conf)
+
+ return None
+
+ render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog)
+ render(systemd_override, 'rsyslog/override.conf.j2', syslog)
+ render(logrotate_conf, 'rsyslog/logrotate.j2', syslog)
+
+ # Reload systemd manager configuration
+ call('systemctl daemon-reload')
+ return None
+
+def apply(syslog):
+ systemd_socket = 'syslog.socket'
+ systemd_service = 'syslog.service'
+ if not syslog:
+ call(f'systemctl stop {systemd_service} {systemd_socket}')
+ return None
+
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'restart_required' in syslog:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {systemd_service}')
+ return None
+
+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_task-scheduler.py b/src/conf_mode/system_task-scheduler.py
new file mode 100644
index 0000000..129be5d
--- /dev/null
+++ b/src/conf_mode/system_task-scheduler.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2017 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 re
+import sys
+
+from vyos.config import Config
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+crontab_file = "/etc/cron.d/vyos-crontab"
+
+
+def format_task(minute="*", hour="*", day="*", dayofweek="*", month="*", user="root", rawspec=None, command=""):
+ fmt_full = "{minute} {hour} {day} {month} {dayofweek} {user} {command}\n"
+ fmt_raw = "{spec} {user} {command}\n"
+
+ if rawspec is None:
+ s = fmt_full.format(minute=minute, hour=hour, day=day,
+ dayofweek=dayofweek, month=month, command=command, user=user)
+ else:
+ s = fmt_raw.format(spec=rawspec, user=user, command=command)
+
+ return s
+
+def split_interval(s):
+ result = re.search(r"(\d+)([mdh]?)", s)
+ value = int(result.group(1))
+ suffix = result.group(2)
+ return( (value, suffix) )
+
+def make_command(executable, arguments):
+ if arguments:
+ return("sg vyattacfg \"{0} {1}\"".format(executable, arguments))
+ else:
+ return("sg vyattacfg \"{0}\"".format(executable))
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ conf.set_level("system task-scheduler task")
+ task_names = conf.list_nodes("")
+ tasks = []
+
+ for name in task_names:
+ interval = conf.return_value("{0} interval".format(name))
+ spec = conf.return_value("{0} crontab-spec".format(name))
+ executable = conf.return_value("{0} executable path".format(name))
+ args = conf.return_value("{0} executable arguments".format(name))
+ task = {
+ "name": name,
+ "interval": interval,
+ "spec": spec,
+ "executable": executable,
+ "args": args
+ }
+ tasks.append(task)
+
+ return tasks
+
+def verify(tasks):
+ for task in tasks:
+ if not task["interval"] and not task["spec"]:
+ raise ConfigError("Invalid task {0}: must define either interval or crontab-spec".format(task["name"]))
+
+ if task["interval"]:
+ if task["spec"]:
+ raise ConfigError("Invalid task {0}: cannot use interval and crontab-spec at the same time".format(task["name"]))
+
+ if not re.match(r"^\d+[mdh]?$", task["interval"]):
+ raise(ConfigError("Invalid interval {0} in task {1}: interval should be a number optionally followed by m, h, or d".format(task["name"], task["interval"])))
+ else:
+ # Check if values are within allowed range
+ value, suffix = split_interval(task["interval"])
+
+ if not suffix or suffix == "m":
+ if value > 60:
+ raise ConfigError("Invalid task {0}: interval in minutes must not exceed 60".format(task["name"]))
+ elif suffix == "h":
+ if value > 24:
+ raise ConfigError("Invalid task {0}: interval in hours must not exceed 24".format(task["name"]))
+ elif suffix == "d":
+ if value > 31:
+ raise ConfigError("Invalid task {0}: interval in days must not exceed 31".format(task["name"]))
+
+ if not task["executable"]:
+ raise ConfigError("Invalid task {0}: executable is not defined".format(task["name"]))
+ else:
+ # Check if executable exists and is executable
+ if not (os.path.isfile(task["executable"]) and os.access(task["executable"], os.X_OK)):
+ raise ConfigError("Invalid task {0}: file {1} does not exist or is not executable".format(task["name"], task["executable"]))
+
+def generate(tasks):
+ crontab_header = "### Generated by vyos-update-crontab.py ###\n"
+ if len(tasks) == 0:
+ if os.path.exists(crontab_file):
+ os.remove(crontab_file)
+ else:
+ pass
+ else:
+ crontab_lines = []
+ for task in tasks:
+ command = make_command(task["executable"], task["args"])
+ if task["spec"]:
+ line = format_task(command=command, rawspec=task["spec"])
+ else:
+ value, suffix = split_interval(task["interval"])
+ if not suffix or suffix == "m":
+ line = format_task(command=command, minute="*/{0}".format(value))
+ elif suffix == "h":
+ line = format_task(command=command, minute="0", hour="*/{0}".format(value))
+ elif suffix == "d":
+ line = format_task(command=command, minute="0", hour="0", day="*/{0}".format(value))
+ crontab_lines.append(line)
+
+ with open(crontab_file, 'w') as f:
+ f.write(crontab_header)
+ f.writelines(crontab_lines)
+
+def apply(config):
+ # No daemon restarts etc. needed for cron
+ pass
+
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/conf_mode/system_timezone.py b/src/conf_mode/system_timezone.py
new file mode 100644
index 0000000..39770fd
--- /dev/null
+++ b/src/conf_mode/system_timezone.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 sys
+
+from copy import deepcopy
+from vyos.config import Config
+from vyos import ConfigError
+from vyos.utils.process import call
+
+from vyos import airbag
+airbag.enable()
+
+default_config_data = {
+ 'name': 'UTC'
+}
+
+def get_config(config=None):
+ tz = deepcopy(default_config_data)
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ if conf.exists('system time-zone'):
+ tz['name'] = conf.return_value('system time-zone')
+
+ return tz
+
+def verify(tz):
+ pass
+
+def generate(tz):
+ pass
+
+def apply(tz):
+ call('/usr/bin/timedatectl set-timezone {}'.format(tz['name']))
+ call('systemctl restart rsyslog')
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/conf_mode/system_update-check.py b/src/conf_mode/system_update-check.py
new file mode 100644
index 0000000..71ac13e
--- /dev/null
+++ b/src/conf_mode/system_update-check.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 json
+
+from pathlib import Path
+from sys import exit
+
+from vyos.config import Config
+from vyos.utils.process import call
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+base = ['system', 'update-check']
+service_name = 'vyos-system-update'
+service_conf = Path(f'/run/{service_name}.conf')
+motd_file = Path('/run/motd.d/10-vyos-update')
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ if not conf.exists(base):
+ return None
+
+ config = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ return config
+
+
+def verify(config):
+ # bail out early - looks like removal from running config
+ if config is None:
+ return
+
+ if 'url' not in config:
+ raise ConfigError('URL is required!')
+
+
+def generate(config):
+ # bail out early - looks like removal from running config
+ if config is None:
+ # Remove old config and return
+ service_conf.unlink(missing_ok=True)
+ # MOTD used in /run/motd.d/10-update
+ motd_file.unlink(missing_ok=True)
+ return None
+
+ # Write configuration file
+ conf_json = json.dumps(config, indent=4)
+ service_conf.write_text(conf_json)
+
+ return None
+
+
+def apply(config):
+ if config:
+ if 'auto_check' in config:
+ call(f'systemctl restart {service_name}.service')
+ else:
+ call(f'systemctl stop {service_name}.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_wireless.py b/src/conf_mode/system_wireless.py
new file mode 100644
index 0000000..e0ca0ab
--- /dev/null
+++ b/src/conf_mode/system_wireless.py
@@ -0,0 +1,64 @@
+#!/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/>.
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['system', 'wireless']
+ interface_base = ['interfaces', 'wireless']
+
+ wireless = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True)
+
+
+ if conf.exists(interface_base):
+ wireless['interfaces'] = conf.list_nodes(interface_base)
+ for interface in wireless['interfaces']:
+ set_dependents('wireless', conf, interface)
+
+ return wireless
+
+def verify(wireless):
+ pass
+
+def generate(wireless):
+ pass
+
+def apply(wireless):
+ if 'interfaces' in wireless:
+ call_dependents()
+ pass
+
+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/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py
new file mode 100644
index 0000000..ca0c365
--- /dev/null
+++ b/src/conf_mode/vpn_ipsec.py
@@ -0,0 +1,745 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 ipaddress
+import os
+import re
+import jmespath
+
+from sys import exit
+from time import sleep
+from ipaddress import ip_address
+from netaddr import IPNetwork
+from netaddr import IPRange
+
+from vyos.config import Config
+from vyos.config import config_dict_merge
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.configdict import get_interface_dict
+from vyos.configdict import leaf_node_changed
+from vyos.configverify import verify_interface_exists
+from vyos.configverify import dynamic_interface_pattern
+from vyos.defaults import directories
+from vyos.ifconfig import Interface
+from vyos.pki import encode_public_key
+from vyos.pki import load_private_key
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_crl
+from vyos.pki import wrap_public_key
+from vyos.pki import wrap_private_key
+from vyos.template import ip_from_cidr
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+from vyos.template import render
+from vyos.utils.network import is_ipv6_link_local
+from vyos.utils.network import interface_exists
+from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.process import call
+from vyos.utils.vti_updown_db import vti_updown_db_exists
+from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update
+from vyos.utils.vti_updown_db import remove_vti_updown_db
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+dhcp_wait_attempts = 2
+dhcp_wait_sleep = 1
+
+swanctl_dir = '/etc/swanctl'
+charon_conf = '/etc/strongswan.d/charon.conf'
+charon_dhcp_conf = '/etc/strongswan.d/charon/dhcp.conf'
+charon_radius_conf = '/etc/strongswan.d/charon/eap-radius.conf'
+interface_conf = '/etc/strongswan.d/interfaces_use.conf'
+swanctl_conf = f'{swanctl_dir}/swanctl.conf'
+
+default_install_routes = 'yes'
+
+vici_socket = '/var/run/charon.vici'
+
+CERT_PATH = f'{swanctl_dir}/x509/'
+PUBKEY_PATH = f'{swanctl_dir}/pubkey/'
+KEY_PATH = f'{swanctl_dir}/private/'
+CA_PATH = f'{swanctl_dir}/x509ca/'
+CRL_PATH = f'{swanctl_dir}/x509crl/'
+
+DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_interfaces'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ 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=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_pki=True)
+
+ # 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
+ # unless the feature is explicitly opted in to by setting the
+ # top-level node
+ default_values = conf.get_config_defaults(**ipsec.kwargs, recursive=True)
+
+ if 'ike_group' in ipsec:
+ for name, ike in ipsec['ike_group'].items():
+ if 'dead_peer_detection' not in ike:
+ del default_values['ike_group'][name]['dead_peer_detection']
+
+ ipsec = config_dict_merge(default_values, ipsec)
+
+ ipsec['dhcp_interfaces'] = set()
+ ipsec['enabled_vti_interfaces'] = set()
+ ipsec['persistent_vti_interfaces'] = set()
+ 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)
+
+ tmp = conf.get_config_dict(l2tp_base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True)
+ if tmp:
+ ipsec['l2tp'] = conf.merge_defaults(tmp, recursive=True)
+ ipsec['l2tp_outside_address'] = conf.return_value(['vpn', 'l2tp', 'remote-access', 'outside-address'])
+ ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024'
+ ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1'
+
+ # Collect the interface dicts for any refernced VTI interfaces in
+ # case we need to bring the interface up
+ ipsec['vti_interface_dicts'] = {}
+
+ if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']:
+ for peer, peer_conf in ipsec['site_to_site']['peer'].items():
+ if 'vti' in peer_conf:
+ if 'bind' in peer_conf['vti']:
+ vti_interface = peer_conf['vti']['bind']
+ if vti_interface not in ipsec['vti_interface_dicts']:
+ _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface)
+ ipsec['vti_interface_dicts'][vti_interface] = vti
+
+ if 'remote_access' in ipsec:
+ if 'connection' in ipsec['remote_access']:
+ for name, ra_conf in ipsec['remote_access']['connection'].items():
+ if 'bind' in ra_conf:
+ vti_interface = ra_conf['bind']
+ if vti_interface not in ipsec['vti_interface_dicts']:
+ _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface)
+ ipsec['vti_interface_dicts'][vti_interface] = vti
+
+ return ipsec
+
+def get_dhcp_address(iface):
+ addresses = Interface(iface).get_addr()
+ if not addresses:
+ return None
+ for address in addresses:
+ if not is_ipv6_link_local(address):
+ return ip_from_cidr(address)
+ return None
+
+def verify_pki_x509(pki, x509_conf):
+ if not pki or 'ca' not in pki or 'certificate' not in pki:
+ raise ConfigError(f'PKI is not configured')
+
+ cert_name = x509_conf['certificate']
+
+ for ca_cert_name in x509_conf['ca_certificate']:
+ if not dict_search_args(pki, 'ca', ca_cert_name, 'certificate'):
+ raise ConfigError(f'Missing CA certificate on specified PKI CA certificate "{ca_cert_name}"')
+
+ if not dict_search_args(pki, 'certificate', cert_name, 'certificate'):
+ raise ConfigError(f'Missing certificate on specified PKI certificate "{cert_name}"')
+
+ if not dict_search_args(pki, 'certificate', cert_name, 'private', 'key'):
+ raise ConfigError(f'Missing private key on specified PKI certificate "{cert_name}"')
+
+ return True
+
+def verify_pki_rsa(pki, rsa_conf):
+ if not pki or 'key_pair' not in pki:
+ raise ConfigError(f'PKI is not configured')
+
+ local_key = rsa_conf['local_key']
+ remote_key = rsa_conf['remote_key']
+
+ if not dict_search_args(pki, 'key_pair', local_key, 'private', 'key'):
+ raise ConfigError(f'Missing private key on specified local-key "{local_key}"')
+
+ if not dict_search_args(pki, 'key_pair', remote_key, 'public', 'key'):
+ raise ConfigError(f'Missing public key on specified remote-key "{remote_key}"')
+
+ return True
+
+def verify(ipsec):
+ if not ipsec:
+ return None
+
+ if 'authentication' in ipsec:
+ if 'psk' in ipsec['authentication']:
+ for psk, psk_config in ipsec['authentication']['psk'].items():
+ if 'id' not in psk_config or 'secret' not in psk_config:
+ raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"')
+
+ if 'interface' in ipsec:
+ tmp = re.compile(dynamic_interface_pattern)
+ for interface in ipsec['interface']:
+ # exclude check interface for dynamic interfaces
+ if tmp.match(interface):
+ verify_interface_exists(ipsec, interface, warning_only=True)
+ else:
+ verify_interface_exists(ipsec, interface)
+
+ if 'l2tp' in ipsec:
+ if 'esp_group' in ipsec['l2tp']:
+ if 'esp_group' not in ipsec or ipsec['l2tp']['esp_group'] not in ipsec['esp_group']:
+ raise ConfigError(f"Invalid esp-group on L2TP remote-access config")
+
+ if 'ike_group' in ipsec['l2tp']:
+ if 'ike_group' not in ipsec or ipsec['l2tp']['ike_group'] not in ipsec['ike_group']:
+ raise ConfigError(f"Invalid ike-group on L2TP remote-access config")
+
+ if 'authentication' not in ipsec['l2tp']:
+ raise ConfigError(f'Missing authentication settings on L2TP remote-access config')
+
+ if 'mode' not in ipsec['l2tp']['authentication']:
+ raise ConfigError(f'Missing authentication mode on L2TP remote-access config')
+
+ if not ipsec['l2tp_outside_address']:
+ raise ConfigError(f'Missing outside-address on L2TP remote-access config')
+
+ if ipsec['l2tp']['authentication']['mode'] == 'pre-shared-secret':
+ if 'pre_shared_secret' not in ipsec['l2tp']['authentication']:
+ raise ConfigError(f'Missing pre shared secret on L2TP remote-access config')
+
+ if ipsec['l2tp']['authentication']['mode'] == 'x509':
+ if 'x509' not in ipsec['l2tp']['authentication']:
+ raise ConfigError(f'Missing x509 settings on L2TP remote-access config')
+
+ x509 = ipsec['l2tp']['authentication']['x509']
+
+ if 'ca_certificate' not in x509 or 'certificate' not in x509:
+ raise ConfigError(f'Missing x509 certificates on L2TP remote-access config')
+
+ verify_pki_x509(ipsec['pki'], x509)
+
+ if 'profile' in ipsec:
+ for profile, profile_conf in ipsec['profile'].items():
+ if 'esp_group' in profile_conf:
+ if 'esp_group' not in ipsec or profile_conf['esp_group'] not in ipsec['esp_group']:
+ raise ConfigError(f"Invalid esp-group on {profile} profile")
+ else:
+ raise ConfigError(f"Missing esp-group on {profile} profile")
+
+ if 'ike_group' in profile_conf:
+ if 'ike_group' not in ipsec or profile_conf['ike_group'] not in ipsec['ike_group']:
+ raise ConfigError(f"Invalid ike-group on {profile} profile")
+ else:
+ raise ConfigError(f"Missing ike-group on {profile} profile")
+
+ if 'authentication' not in profile_conf:
+ raise ConfigError(f"Missing authentication on {profile} profile")
+
+ if 'remote_access' in ipsec:
+ if 'connection' in ipsec['remote_access']:
+ for name, ra_conf in ipsec['remote_access']['connection'].items():
+ if 'local_address' not in ra_conf and 'dhcp_interface' not in ra_conf:
+ raise ConfigError(f"Missing local-address or dhcp-interface on remote-access connection {name}")
+
+ if 'dhcp_interface' in ra_conf:
+ dhcp_interface = ra_conf['dhcp_interface']
+
+ verify_interface_exists(ipsec, dhcp_interface)
+ dhcp_base = directories['isc_dhclient_dir']
+
+ if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
+ raise ConfigError(f"Invalid dhcp-interface on remote-access connection {name}")
+
+ if 'disable' not in ra_conf:
+ ipsec['dhcp_interfaces'].add(dhcp_interface)
+
+ address = get_dhcp_address(dhcp_interface)
+ count = 0
+ while not address and count < dhcp_wait_attempts:
+ address = get_dhcp_address(dhcp_interface)
+ count += 1
+ sleep(dhcp_wait_sleep)
+
+ if not address:
+ ipsec['dhcp_no_address'][f'ra_{name}'] = dhcp_interface
+ print(f"Failed to get address from dhcp-interface on remote-access connection {name} -- skipped")
+ continue
+
+ if 'esp_group' in ra_conf:
+ if 'esp_group' not in ipsec or ra_conf['esp_group'] not in ipsec['esp_group']:
+ raise ConfigError(f"Invalid esp-group on {name} remote-access config")
+ else:
+ raise ConfigError(f"Missing esp-group on {name} remote-access config")
+
+ if 'ike_group' in ra_conf:
+ if 'ike_group' not in ipsec or ra_conf['ike_group'] not in ipsec['ike_group']:
+ raise ConfigError(f"Invalid ike-group on {name} remote-access config")
+
+ ike = ra_conf['ike_group']
+ if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2':
+ raise ConfigError('IPsec remote-access connections requires IKEv2!')
+
+ else:
+ raise ConfigError(f"Missing ike-group on {name} remote-access config")
+
+ if 'authentication' not in ra_conf:
+ raise ConfigError(f"Missing authentication on {name} remote-access config")
+
+ if ra_conf['authentication']['server_mode'] == 'x509':
+ if 'x509' not in ra_conf['authentication']:
+ raise ConfigError(f"Missing x509 settings on {name} remote-access config")
+
+ x509 = ra_conf['authentication']['x509']
+
+ if 'ca_certificate' not in x509 or 'certificate' not in x509:
+ raise ConfigError(f"Missing x509 certificates on {name} remote-access config")
+
+ verify_pki_x509(ipsec['pki'], x509)
+ elif ra_conf['authentication']['server_mode'] == 'pre-shared-secret':
+ if 'pre_shared_secret' not in ra_conf['authentication']:
+ raise ConfigError(f"Missing pre-shared-key on {name} remote-access config")
+
+ if 'client_mode' not in ra_conf['authentication']:
+ raise ConfigError('Client authentication method is required!')
+
+ if dict_search('authentication.client_mode', ra_conf) == 'eap-radius':
+ if dict_search('remote_access.radius.server', ipsec) == None:
+ raise ConfigError('RADIUS authentication requires at least one server')
+
+ if 'bind' in ra_conf:
+ vti_interface = ra_conf['bind']
+ if not interface_exists(vti_interface):
+ raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!')
+
+ if 'disable' not in ra_conf:
+ ipsec['enabled_vti_interfaces'].add(vti_interface)
+ # remote access VPN interfaces are always up regardless of whether clients are connected
+ ipsec['persistent_vti_interfaces'].add(vti_interface)
+
+ if 'pool' in ra_conf:
+ if {'dhcp', 'radius'} <= set(ra_conf['pool']):
+ raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\
+ f'at the same time for "{name}"!')
+
+ if 'dhcp' in ra_conf['pool'] and len(ra_conf['pool']) > 1:
+ raise ConfigError(f'Can not use DHCP and a predefined address pool for "{name}"!')
+
+ if 'radius' in ra_conf['pool'] and len(ra_conf['pool']) > 1:
+ raise ConfigError(f'Can not use RADIUS and a predefined address pool for "{name}"!')
+
+ for pool in ra_conf['pool']:
+ if pool == 'dhcp':
+ if dict_search('remote_access.dhcp.server', ipsec) == None:
+ raise ConfigError('IPsec DHCP server is not configured!')
+ elif pool == 'radius':
+ if dict_search('remote_access.radius.server', ipsec) == None:
+ raise ConfigError('IPsec RADIUS server is not configured!')
+
+ if dict_search('authentication.client_mode', ra_conf) != 'eap-radius':
+ raise ConfigError('RADIUS IP pool requires eap-radius client authentication!')
+
+ elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']:
+ raise ConfigError(f'Requested pool "{pool}" does not exist!')
+
+ if 'pool' in ipsec['remote_access']:
+ pool_networks = []
+ for pool, pool_config in ipsec['remote_access']['pool'].items():
+ if 'prefix' not in pool_config and 'range' not in pool_config:
+ raise ConfigError(f'Mandatory prefix or range must be specified for pool "{pool}"!')
+
+ if 'prefix' in pool_config and 'range' in pool_config:
+ raise ConfigError(f'Only one of prefix or range can be specified for pool "{pool}"!')
+
+ if 'prefix' in pool_config:
+ range_is_ipv4 = is_ipv4(pool_config['prefix'])
+ range_is_ipv6 = is_ipv6(pool_config['prefix'])
+
+ net = IPNetwork(pool_config['prefix'])
+ start = net.first
+ stop = net.last
+ for network in pool_networks:
+ if start in network or stop in network:
+ raise ConfigError(f'Prefix for pool "{pool}" is already part of another pool\'s range!')
+
+ tmp = IPRange(start, stop)
+ pool_networks.append(tmp)
+
+ if 'range' in pool_config:
+ range_config = pool_config['range']
+ if not {'start', 'stop'} <= set(range_config.keys()):
+ raise ConfigError(f'Range start and stop address must be defined for pool "{pool}"!')
+
+ range_both_ipv4 = is_ipv4(range_config['start']) and is_ipv4(range_config['stop'])
+ range_both_ipv6 = is_ipv6(range_config['start']) and is_ipv6(range_config['stop'])
+
+ if not (range_both_ipv4 or range_both_ipv6):
+ raise ConfigError(f'Range start and stop must be of the same address family for pool "{pool}"!')
+
+ if ip_address(range_config['stop']) < ip_address(range_config['start']):
+ raise ConfigError(f'Range stop address must be greater or equal\n' \
+ 'to the range\'s start address for pool "{pool}"!')
+
+ range_is_ipv4 = is_ipv4(range_config['start'])
+ range_is_ipv6 = is_ipv6(range_config['start'])
+
+ start = range_config['start']
+ stop = range_config['stop']
+ for network in pool_networks:
+ if start in network:
+ raise ConfigError(f'Range "{range}" start address "{start}" already part of another pool\'s range!')
+ if stop in network:
+ raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another pool\'s range!')
+
+ tmp = IPRange(start, stop)
+ pool_networks.append(tmp)
+
+ if 'name_server' in pool_config:
+ if len(pool_config['name_server']) > 2:
+ raise ConfigError(f'Only two name-servers are supported for remote-access pool "{pool}"!')
+
+ for ns in pool_config['name_server']:
+ v4_addr_and_ns = is_ipv4(ns) and not range_is_ipv4
+ v6_addr_and_ns = is_ipv6(ns) and not range_is_ipv6
+ if v4_addr_and_ns or v6_addr_and_ns:
+ raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and name-server addresses!')
+
+ if 'exclude' in pool_config:
+ for exclude in pool_config['exclude']:
+ v4_addr_and_exclude = is_ipv4(exclude) and not range_is_ipv4
+ v6_addr_and_exclude = is_ipv6(exclude) and not range_is_ipv6
+ if v4_addr_and_exclude or v6_addr_and_exclude:
+ raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and exclude prefixes!')
+
+ if 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']:
+ for server, server_config in ipsec['remote_access']['radius']['server'].items():
+ if 'key' not in server_config:
+ raise ConfigError(f'Missing RADIUS secret key for server "{server}"')
+
+ if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']:
+ for peer, peer_conf in ipsec['site_to_site']['peer'].items():
+ has_default_esp = False
+ # Peer name it is swanctl connection name and shouldn't contain dots or colons, T4118
+ if bool(re.search(':|\.', peer)):
+ raise ConfigError(f'Incorrect peer name "{peer}" '
+ f'Peer name can contain alpha-numeric letters, hyphen and underscore')
+
+ if 'remote_address' not in peer_conf:
+ print(f'You should set correct remote-address "peer {peer} remote-address x.x.x.x"\n')
+
+ if 'default_esp_group' in peer_conf:
+ has_default_esp = True
+ if 'esp_group' not in ipsec or peer_conf['default_esp_group'] not in ipsec['esp_group']:
+ raise ConfigError(f"Invalid esp-group on site-to-site peer {peer}")
+
+ if 'ike_group' in peer_conf:
+ if 'ike_group' not in ipsec or peer_conf['ike_group'] not in ipsec['ike_group']:
+ raise ConfigError(f"Invalid ike-group on site-to-site peer {peer}")
+ else:
+ raise ConfigError(f"Missing ike-group on site-to-site peer {peer}")
+
+ if 'authentication' not in peer_conf or 'mode' not in peer_conf['authentication']:
+ raise ConfigError(f"Missing authentication on site-to-site peer {peer}")
+
+ if {'id', 'use_x509_id'} <= set(peer_conf['authentication']):
+ raise ConfigError(f"Manually set peer id and use-x509-id are mutually exclusive!")
+
+ if peer_conf['authentication']['mode'] == 'x509':
+ if 'x509' not in peer_conf['authentication']:
+ raise ConfigError(f"Missing x509 settings on site-to-site peer {peer}")
+
+ x509 = peer_conf['authentication']['x509']
+
+ if 'ca_certificate' not in x509 or 'certificate' not in x509:
+ raise ConfigError(f"Missing x509 certificates on site-to-site peer {peer}")
+
+ verify_pki_x509(ipsec['pki'], x509)
+ elif peer_conf['authentication']['mode'] == 'rsa':
+ if 'rsa' not in peer_conf['authentication']:
+ raise ConfigError(f"Missing RSA settings on site-to-site peer {peer}")
+
+ rsa = peer_conf['authentication']['rsa']
+
+ if 'local_key' not in rsa:
+ raise ConfigError(f"Missing RSA local-key on site-to-site peer {peer}")
+
+ if 'remote_key' not in rsa:
+ raise ConfigError(f"Missing RSA remote-key on site-to-site peer {peer}")
+
+ verify_pki_rsa(ipsec['pki'], rsa)
+
+ if 'local_address' not in peer_conf and 'dhcp_interface' not in peer_conf:
+ raise ConfigError(f"Missing local-address or dhcp-interface on site-to-site peer {peer}")
+
+ if 'dhcp_interface' in peer_conf:
+ dhcp_interface = peer_conf['dhcp_interface']
+
+ verify_interface_exists(ipsec, dhcp_interface)
+ dhcp_base = directories['isc_dhclient_dir']
+
+ if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'):
+ raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}")
+
+ if 'disable' not in peer_conf:
+ ipsec['dhcp_interfaces'].add(dhcp_interface)
+
+ address = get_dhcp_address(dhcp_interface)
+ count = 0
+ while not address and count < dhcp_wait_attempts:
+ address = get_dhcp_address(dhcp_interface)
+ count += 1
+ sleep(dhcp_wait_sleep)
+
+ if not address:
+ ipsec['dhcp_no_address'][f'peer_{peer}'] = dhcp_interface
+ print(f"Failed to get address from dhcp-interface on site-to-site peer {peer} -- skipped")
+ continue
+
+ if 'vti' in peer_conf:
+ if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf:
+ raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}")
+
+ if 'bind' in peer_conf['vti']:
+ vti_interface = peer_conf['vti']['bind']
+ if not interface_exists(vti_interface):
+ raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!')
+ if 'disable' not in peer_conf:
+ ipsec['enabled_vti_interfaces'].add(vti_interface)
+
+ if 'vti' not in peer_conf and 'tunnel' not in peer_conf:
+ raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}")
+
+ if 'tunnel' in peer_conf:
+ for tunnel, tunnel_conf in peer_conf['tunnel'].items():
+ if 'esp_group' not in tunnel_conf and not has_default_esp:
+ raise ConfigError(f"Missing esp-group on tunnel {tunnel} for site-to-site peer {peer}")
+
+ esp_group_name = tunnel_conf['esp_group'] if 'esp_group' in tunnel_conf else peer_conf['default_esp_group']
+
+ if esp_group_name not in ipsec['esp_group']:
+ raise ConfigError(f"Invalid esp-group on tunnel {tunnel} for site-to-site peer {peer}")
+
+ esp_group = ipsec['esp_group'][esp_group_name]
+
+ if 'mode' in esp_group and esp_group['mode'] == 'transport':
+ if 'protocol' in tunnel_conf and ((peer in ['any', '0.0.0.0']) or ('local_address' not in peer_conf or peer_conf['local_address'] in ['any', '0.0.0.0'])):
+ raise ConfigError(f"Fixed local-address or peer required when a protocol is defined with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}")
+
+ if ('local' in tunnel_conf and 'prefix' in tunnel_conf['local']) or ('remote' in tunnel_conf and 'prefix' in tunnel_conf['remote']):
+ raise ConfigError(f"Local/remote prefix cannot be used with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}")
+
+def cleanup_pki_files():
+ for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]:
+ if not os.path.exists(path):
+ continue
+ for file in os.listdir(path):
+ file_path = os.path.join(path, file)
+ if os.path.isfile(file_path):
+ os.unlink(file_path)
+
+def generate_pki_files_x509(pki, x509_conf):
+ for ca_cert_name in x509_conf['ca_certificate']:
+ ca_cert_data = dict_search_args(pki, 'ca', ca_cert_name, 'certificate')
+ ca_cert_crls = dict_search_args(pki, 'ca', ca_cert_name, 'crl') or []
+ crl_index = 1
+
+ with open(os.path.join(CA_PATH, f'{ca_cert_name}.pem'), 'w') as f:
+ f.write(wrap_certificate(ca_cert_data))
+
+ for crl in ca_cert_crls:
+ with open(os.path.join(CRL_PATH, f'{ca_cert_name}_{crl_index}.pem'), 'w') as f:
+ f.write(wrap_crl(crl))
+ crl_index += 1
+
+ cert_name = x509_conf['certificate']
+ cert_data = dict_search_args(pki, 'certificate', cert_name, 'certificate')
+ key_data = dict_search_args(pki, 'certificate', cert_name, 'private', 'key')
+ protected = 'passphrase' in x509_conf
+
+ with open(os.path.join(CERT_PATH, f'{cert_name}.pem'), 'w') as f:
+ f.write(wrap_certificate(cert_data))
+
+ with open(os.path.join(KEY_PATH, f'x509_{cert_name}.pem'), 'w') as f:
+ f.write(wrap_private_key(key_data, protected))
+
+def generate_pki_files_rsa(pki, rsa_conf):
+ local_key_name = rsa_conf['local_key']
+ local_key_data = dict_search_args(pki, 'key_pair', local_key_name, 'private', 'key')
+ protected = 'passphrase' in rsa_conf
+ remote_key_name = rsa_conf['remote_key']
+ remote_key_data = dict_search_args(pki, 'key_pair', remote_key_name, 'public', 'key')
+
+ local_key = load_private_key(local_key_data, rsa_conf['passphrase'] if protected else None)
+
+ with open(os.path.join(KEY_PATH, f'rsa_{local_key_name}.pem'), 'w') as f:
+ f.write(wrap_private_key(local_key_data, protected))
+
+ with open(os.path.join(PUBKEY_PATH, f'{local_key_name}.pem'), 'w') as f:
+ f.write(encode_public_key(local_key.public_key()))
+
+ with open(os.path.join(PUBKEY_PATH, f'{remote_key_name}.pem'), 'w') as f:
+ f.write(wrap_public_key(remote_key_data))
+
+def generate(ipsec):
+ cleanup_pki_files()
+
+ if not 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)
+ render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes})
+ return
+
+ if ipsec['dhcp_interfaces']:
+ with open(DHCP_HOOK_IFLIST, 'w') as f:
+ f.write(" ".join(ipsec['dhcp_interfaces']))
+ elif os.path.exists(DHCP_HOOK_IFLIST):
+ os.unlink(DHCP_HOOK_IFLIST)
+
+ for path in [swanctl_dir, CERT_PATH, CA_PATH, CRL_PATH, PUBKEY_PATH]:
+ if not os.path.exists(path):
+ os.mkdir(path, mode=0o755)
+
+ if not os.path.exists(KEY_PATH):
+ os.mkdir(KEY_PATH, mode=0o700)
+
+ if 'l2tp' in ipsec:
+ if 'authentication' in ipsec['l2tp'] and 'x509' in ipsec['l2tp']['authentication']:
+ generate_pki_files_x509(ipsec['pki'], ipsec['l2tp']['authentication']['x509'])
+
+ if 'remote_access' in ipsec and 'connection' in ipsec['remote_access']:
+ for rw, rw_conf in ipsec['remote_access']['connection'].items():
+ if f'ra_{rw}' in ipsec['dhcp_no_address']:
+ continue
+
+ local_ip = ''
+ if 'local_address' in rw_conf:
+ local_ip = rw_conf['local_address']
+ elif 'dhcp_interface' in rw_conf:
+ local_ip = get_dhcp_address(rw_conf['dhcp_interface'])
+
+ ipsec['remote_access']['connection'][rw]['local_address'] = local_ip
+
+ if 'authentication' in rw_conf and 'x509' in rw_conf['authentication']:
+ generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509'])
+
+ if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']:
+ for peer, peer_conf in ipsec['site_to_site']['peer'].items():
+ if f'peer_{peer}' in ipsec['dhcp_no_address']:
+ continue
+
+ if peer_conf['authentication']['mode'] == 'x509':
+ generate_pki_files_x509(ipsec['pki'], peer_conf['authentication']['x509'])
+ elif peer_conf['authentication']['mode'] == 'rsa':
+ generate_pki_files_rsa(ipsec['pki'], peer_conf['authentication']['rsa'])
+
+ local_ip = ''
+ if 'local_address' in peer_conf:
+ local_ip = peer_conf['local_address']
+ elif 'dhcp_interface' in peer_conf:
+ local_ip = get_dhcp_address(peer_conf['dhcp_interface'])
+
+ ipsec['site_to_site']['peer'][peer]['local_address'] = local_ip
+
+ if 'tunnel' in peer_conf:
+ for tunnel, tunnel_conf in peer_conf['tunnel'].items():
+ local_prefixes = dict_search_args(tunnel_conf, 'local', 'prefix')
+ remote_prefixes = dict_search_args(tunnel_conf, 'remote', 'prefix')
+
+ if not local_prefixes or not remote_prefixes:
+ continue
+
+ passthrough = None
+
+ for local_prefix in local_prefixes:
+ for remote_prefix in remote_prefixes:
+ local_net = ipaddress.ip_network(local_prefix)
+ remote_net = ipaddress.ip_network(remote_prefix)
+ if local_net.overlaps(remote_net):
+ if passthrough is None:
+ passthrough = []
+ passthrough.append(local_prefix)
+
+ ipsec['site_to_site']['peer'][peer]['tunnel'][tunnel]['passthrough'] = passthrough
+
+ # auth psk <tag> dhcp-interface <xxx>
+ if jmespath.search('authentication.psk.*.dhcp_interface', ipsec):
+ for psk, psk_config in ipsec['authentication']['psk'].items():
+ if 'dhcp_interface' in psk_config:
+ for iface in psk_config['dhcp_interface']:
+ id = get_dhcp_address(iface)
+ if id:
+ ipsec['authentication']['psk'][psk]['id'].append(id)
+
+ render(charon_conf, 'ipsec/charon.j2', ipsec)
+ render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.j2', ipsec)
+ render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.j2', ipsec)
+ render(interface_conf, 'ipsec/interfaces_use.conf.j2', ipsec)
+ render(swanctl_conf, 'ipsec/swanctl.conf.j2', ipsec)
+
+
+def apply(ipsec):
+ systemd_service = 'strongswan.service'
+ if not 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'])
+ db.setPersistentInterfaces(ipsec['persistent_vti_interfaces'])
+ db.commit(lambda interface: ipsec['vti_interface_dicts'][interface])
+ elif vti_updown_db_exists():
+ remove_vti_updown_db()
+
+ if ipsec.get('nhrp_exists', False):
+ try:
+ call_dependents()
+ except ConfigError:
+ # Ignore config errors on dependent due to being called too early. Example:
+ # ConfigError("ConfigError('Interface ethN requires an IP address!')")
+ pass
+
+
+if __name__ == '__main__':
+ try:
+ ipsec = get_config()
+ verify(ipsec)
+ generate(ipsec)
+ apply(ipsec)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py
new file mode 100644
index 0000000..04ccbce
--- /dev/null
+++ b/src/conf_mode/vpn_l2tp.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdep import call_dependents, set_dependents
+from vyos.configdict import get_accel_dict
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.accel_ppp_util import verify_accel_ppp_name_servers
+from vyos.accel_ppp_util import verify_accel_ppp_wins_servers
+from vyos.accel_ppp_util import verify_accel_ppp_authentication
+from vyos.accel_ppp_util import verify_accel_ppp_ip_pool
+from vyos.accel_ppp_util import get_pools_in_order
+from vyos import ConfigError
+
+from vyos import airbag
+airbag.enable()
+
+
+l2tp_conf = '/run/accel-pppd/l2tp.conf'
+l2tp_chap_secrets = '/run/accel-pppd/l2tp.chap-secrets'
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['vpn', 'l2tp', 'remote-access']
+
+ set_dependents('ipsec', conf)
+
+ if not conf.exists(base):
+ return None
+
+ # retrieve common dictionary keys
+ l2tp = get_accel_dict(conf, base, l2tp_chap_secrets)
+ if dict_search('client_ip_pool', l2tp):
+ # Multiple named pools require ordered values T5099
+ l2tp['ordered_named_pools'] = get_pools_in_order(
+ dict_search('client_ip_pool', l2tp))
+ l2tp['server_type'] = 'l2tp'
+ return l2tp
+
+
+def verify(l2tp):
+ if not l2tp:
+ return None
+
+ verify_accel_ppp_authentication(l2tp)
+ verify_accel_ppp_ip_pool(l2tp)
+ verify_accel_ppp_name_servers(l2tp)
+ verify_accel_ppp_wins_servers(l2tp)
+
+ return None
+
+
+def generate(l2tp):
+ if not l2tp:
+ return None
+
+ render(l2tp_conf, 'accel-ppp/l2tp.config.j2', l2tp)
+
+ if dict_search('authentication.mode', l2tp) == 'local':
+ render(l2tp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',
+ l2tp, permission=0o640)
+
+ return None
+
+
+def apply(l2tp):
+ if not l2tp:
+ call('systemctl stop accel-ppp@l2tp.service')
+ for file in [l2tp_chap_secrets, l2tp_conf]:
+ if os.path.exists(file):
+ os.unlink(file)
+ else:
+ call('systemctl restart accel-ppp@l2tp.service')
+
+ call_dependents()
+
+
+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/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py
new file mode 100644
index 0000000..4278513
--- /dev/null
+++ b/src/conf_mode/vpn_openconnect.py
@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+from sys import exit
+
+from vyos.base import Warning
+from vyos.config import Config
+from vyos.configverify import verify_pki_certificate
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.pki import find_chain
+from vyos.pki import encode_certificate
+from vyos.pki import load_certificate
+from vyos.pki import wrap_private_key
+from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.network import check_port_availability
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.utils.process import call
+from vyos.utils.process import is_systemd_service_running
+from vyos import ConfigError
+from passlib.hash import sha512_crypt
+from time import sleep
+
+from vyos import airbag
+airbag.enable()
+
+cfg_dir = '/run/ocserv'
+ocserv_conf = cfg_dir + '/ocserv.conf'
+ocserv_passwd = cfg_dir + '/ocpasswd'
+ocserv_otp_usr = cfg_dir + '/users.oath'
+radius_cfg = cfg_dir + '/radiusclient.conf'
+radius_servers = cfg_dir + '/radius_servers'
+
+# Generate hash from user cleartext password
+def get_hash(password):
+ return sha512_crypt.hash(password)
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['vpn', 'openconnect']
+ if not conf.exists(base):
+ return None
+
+ ocserv = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True,
+ with_pki=True)
+
+ return ocserv
+
+def verify(ocserv):
+ if ocserv is None:
+ return None
+ # Check if listen-ports not binded other services
+ # It can be only listen by 'ocserv-main'
+ for proto, port in ocserv.get('listen_ports').items():
+ if check_port_availability(ocserv['listen_address'], int(port), proto) is not True and \
+ not is_listen_port_bind_service(int(port), 'ocserv-main'):
+ raise ConfigError(f'"{proto}" port "{port}" is used by another service')
+
+ # Check accounting
+ if "accounting" in ocserv:
+ if "mode" in ocserv["accounting"] and "radius" in ocserv["accounting"]["mode"]:
+ if not origin["accounting"]['radius']['server']:
+ raise ConfigError('OpenConnect accounting mode radius requires at least one RADIUS server')
+ if "authentication" not in ocserv or "mode" not in ocserv["authentication"]:
+ raise ConfigError('Accounting depends on OpenConnect authentication configuration')
+ elif "radius" not in ocserv["authentication"]["mode"]:
+ raise ConfigError('RADIUS accounting must be used with RADIUS authentication')
+
+ # Check authentication
+ if "authentication" in ocserv:
+ if "mode" in ocserv["authentication"]:
+ if ("local" in ocserv["authentication"]["mode"] and
+ "radius" in ocserv["authentication"]["mode"]):
+ raise ConfigError('OpenConnect authentication modes are mutually-exclusive, remove either local or radius from your configuration')
+ if "radius" in ocserv["authentication"]["mode"]:
+ if not ocserv["authentication"]['radius']['server']:
+ raise ConfigError('OpenConnect authentication mode radius requires at least one RADIUS server')
+ if "local" in ocserv["authentication"]["mode"]:
+ if not ocserv.get("authentication", {}).get("local_users"):
+ raise ConfigError('OpenConnect mode local required at least one user')
+ if not ocserv["authentication"]["local_users"]["username"]:
+ raise ConfigError('OpenConnect mode local required at least one user')
+ else:
+ # For OTP mode: verify that each local user has an OTP key
+ if "otp" in ocserv["authentication"]["mode"]["local"]:
+ users_wo_key = []
+ for user, user_config in ocserv["authentication"]["local_users"]["username"].items():
+ # User has no OTP key defined
+ if dict_search('otp.key', user_config) == None:
+ users_wo_key.append(user)
+ if users_wo_key:
+ raise ConfigError(f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}')
+ # For password (and default) mode: verify that each local user has password
+ if "password" in ocserv["authentication"]["mode"]["local"] or "otp" not in ocserv["authentication"]["mode"]["local"]:
+ users_wo_pswd = []
+ for user in ocserv["authentication"]["local_users"]["username"]:
+ if not "password" in ocserv["authentication"]["local_users"]["username"][user]:
+ users_wo_pswd.append(user)
+ if users_wo_pswd:
+ raise ConfigError(f'password required for users:\n{users_wo_pswd}')
+
+ # Validate that if identity-based-config is configured all child config nodes are set
+ if 'identity_based_config' in ocserv["authentication"]:
+ if 'disabled' not in ocserv["authentication"]["identity_based_config"]:
+ Warning("Identity based configuration files is a 3rd party addition. Use at your own risk, this might break the ocserv daemon!")
+ if 'mode' not in ocserv["authentication"]["identity_based_config"]:
+ raise ConfigError('OpenConnect radius identity-based-config enabled but mode not selected')
+ elif 'group' in ocserv["authentication"]["identity_based_config"]["mode"] and "radius" not in ocserv["authentication"]["mode"]:
+ raise ConfigError('OpenConnect config-per-group must be used with radius authentication')
+ if 'directory' not in ocserv["authentication"]["identity_based_config"]:
+ raise ConfigError('OpenConnect identity-based-config enabled but directory not set')
+ if 'default_config' not in ocserv["authentication"]["identity_based_config"]:
+ raise ConfigError('OpenConnect identity-based-config enabled but default-config not set')
+ else:
+ raise ConfigError('OpenConnect authentication mode required')
+ else:
+ raise ConfigError('OpenConnect authentication credentials required')
+
+ # Check ssl
+ if 'ssl' not in ocserv:
+ raise ConfigError('SSL missing on OpenConnect config!')
+
+ if 'certificate' not in ocserv['ssl']:
+ raise ConfigError('SSL certificate missing on OpenConnect config!')
+ verify_pki_certificate(ocserv, ocserv['ssl']['certificate'])
+
+ if 'ca_certificate' in ocserv['ssl']:
+ for ca_cert in ocserv['ssl']['ca_certificate']:
+ verify_pki_ca_certificate(ocserv, ca_cert)
+
+ # Check network settings
+ if "network_settings" in ocserv:
+ if "push_route" in ocserv["network_settings"]:
+ # Replace default route
+ if "0.0.0.0/0" in ocserv["network_settings"]["push_route"]:
+ ocserv["network_settings"]["push_route"].remove("0.0.0.0/0")
+ ocserv["network_settings"]["push_route"].append("default")
+ else:
+ ocserv["network_settings"]["push_route"] = ["default"]
+ else:
+ raise ConfigError('OpenConnect network settings required!')
+
+def generate(ocserv):
+ if not ocserv:
+ return None
+
+ if "radius" in ocserv["authentication"]["mode"]:
+ if dict_search(ocserv, 'accounting.mode.radius'):
+ # Render radius client configuration
+ render(radius_cfg, 'ocserv/radius_conf.j2', ocserv)
+ merged_servers = ocserv["accounting"]["radius"]["server"] | ocserv["authentication"]["radius"]["server"]
+ # Render radius servers
+ # Merge the accounting and authentication servers into a single dictionary
+ render(radius_servers, 'ocserv/radius_servers.j2', {'server': merged_servers})
+ else:
+ # Render radius client configuration
+ render(radius_cfg, 'ocserv/radius_conf.j2', ocserv)
+ # Render radius servers
+ render(radius_servers, 'ocserv/radius_servers.j2', ocserv["authentication"]["radius"])
+ elif "local" in ocserv["authentication"]["mode"]:
+ # if mode "OTP", generate OTP users file parameters
+ if "otp" in ocserv["authentication"]["mode"]["local"]:
+ if "local_users" in ocserv["authentication"]:
+ for user in ocserv["authentication"]["local_users"]["username"]:
+ # OTP token type from CLI parameters:
+ otp_interval = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("interval"))
+ token_type = ocserv["authentication"]["local_users"]["username"][user]["otp"].get("token_type")
+ otp_length = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("otp_length"))
+ if token_type == "hotp-time":
+ otp_type = "HOTP/T" + otp_interval
+ elif token_type == "hotp-event":
+ otp_type = "HOTP/E"
+ else:
+ otp_type = "HOTP/T" + otp_interval
+ ocserv["authentication"]["local_users"]["username"][user]["otp"]["token_tmpl"] = otp_type + "/" + otp_length
+ # if there is a password, generate hash
+ if "password" in ocserv["authentication"]["mode"]["local"] or not "otp" in ocserv["authentication"]["mode"]["local"]:
+ if "local_users" in ocserv["authentication"]:
+ for user in ocserv["authentication"]["local_users"]["username"]:
+ ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"])
+
+ if "password-otp" in ocserv["authentication"]["mode"]["local"]:
+ # Render local users ocpasswd
+ render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])
+ # Render local users OTP keys
+ render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"])
+ elif "password" in ocserv["authentication"]["mode"]["local"]:
+ # Render local users ocpasswd
+ render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])
+ elif "otp" in ocserv["authentication"]["mode"]["local"]:
+ # Render local users OTP keys
+ render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"])
+ else:
+ # Render local users ocpasswd
+ render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])
+ else:
+ if "local_users" in ocserv["authentication"]:
+ for user in ocserv["authentication"]["local_users"]["username"]:
+ ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"])
+ # Render local users
+ render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"])
+
+ if "ssl" in ocserv:
+ cert_file_path = os.path.join(cfg_dir, 'cert.pem')
+ cert_key_path = os.path.join(cfg_dir, 'cert.key')
+
+
+ if 'certificate' in ocserv['ssl']:
+ cert_name = ocserv['ssl']['certificate']
+ pki_cert = ocserv['pki']['certificate'][cert_name]
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in ocserv['pki']['ca'].values()} if 'ca' in ocserv['pki'] else {}
+
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+
+ write_file(cert_file_path,
+ '\n'.join(encode_certificate(c) for c in cert_full_chain))
+
+ if 'private' in pki_cert and 'key' in pki_cert['private']:
+ write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))
+
+ if 'ca_certificate' in ocserv['ssl']:
+ ca_cert_file_path = os.path.join(cfg_dir, 'ca.pem')
+ ca_chains = []
+
+ for ca_name in ocserv['ssl']['ca_certificate']:
+ pki_ca_cert = ocserv['pki']['ca'][ca_name]
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ ca_chains.append(
+ '\n'.join(encode_certificate(c) for c in ca_full_chain))
+
+ write_file(ca_cert_file_path, '\n'.join(ca_chains))
+
+ # Render config
+ render(ocserv_conf, 'ocserv/ocserv_config.j2', ocserv)
+
+
+def apply(ocserv):
+ if not ocserv:
+ call('systemctl stop ocserv.service')
+ for file in [ocserv_conf, ocserv_passwd, ocserv_otp_usr]:
+ if os.path.exists(file):
+ os.unlink(file)
+ else:
+ call('systemctl reload-or-restart ocserv.service')
+ counter = 0
+ while True:
+ # exit early when service runs
+ if is_systemd_service_running("ocserv.service"):
+ break
+ sleep(0.250)
+ if counter > 5:
+ raise ConfigError('OpenConnect failed to start, check the logs for details')
+ break
+ counter += 1
+
+
+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/vpn_pptp.py b/src/conf_mode/vpn_pptp.py
new file mode 100644
index 0000000..c0d8330
--- /dev/null
+++ b/src/conf_mode/vpn_pptp.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 os
+from sys import exit
+
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.dict import dict_search
+from vyos.accel_ppp_util import verify_accel_ppp_name_servers
+from vyos.accel_ppp_util import verify_accel_ppp_wins_servers
+from vyos.accel_ppp_util import verify_accel_ppp_authentication
+from vyos.accel_ppp_util import verify_accel_ppp_ip_pool
+from vyos.accel_ppp_util import get_pools_in_order
+from vyos import ConfigError
+from vyos.configdict import get_accel_dict
+
+from vyos import airbag
+airbag.enable()
+
+pptp_conf = '/run/accel-pppd/pptp.conf'
+pptp_chap_secrets = '/run/accel-pppd/pptp.chap-secrets'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['vpn', 'pptp', 'remote-access']
+ if not conf.exists(base):
+ return None
+
+ # retrieve common dictionary keys
+ pptp = get_accel_dict(conf, base, pptp_chap_secrets)
+
+ if dict_search('client_ip_pool', pptp):
+ # Multiple named pools require ordered values T5099
+ pptp['ordered_named_pools'] = get_pools_in_order(
+ dict_search('client_ip_pool', pptp))
+ pptp['chap_secrets_file'] = pptp_chap_secrets
+ pptp['server_type'] = 'pptp'
+ return pptp
+
+
+def verify(pptp):
+ if not pptp:
+ return None
+
+ verify_accel_ppp_authentication(pptp)
+ verify_accel_ppp_ip_pool(pptp)
+ verify_accel_ppp_name_servers(pptp)
+ verify_accel_ppp_wins_servers(pptp)
+
+
+def generate(pptp):
+ if not pptp:
+ return None
+
+ render(pptp_conf, 'accel-ppp/pptp.config.j2', pptp)
+
+ if dict_search('authentication.mode', pptp) == 'local':
+ render(pptp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',
+ pptp, permission=0o640)
+
+ return None
+
+
+def apply(pptp):
+ if not pptp:
+ call('systemctl stop accel-ppp@pptp.service')
+ for file in [pptp_conf, pptp_chap_secrets]:
+ if os.path.exists(file):
+ os.unlink(file)
+
+ return None
+
+ call('systemctl restart accel-ppp@pptp.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/vpn_sstp.py b/src/conf_mode/vpn_sstp.py
new file mode 100644
index 0000000..7490fd0
--- /dev/null
+++ b/src/conf_mode/vpn_sstp.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_accel_dict
+from vyos.configverify import verify_pki_certificate
+from vyos.configverify import verify_pki_ca_certificate
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_private_key
+from vyos.template import render
+from vyos.utils.process import call
+from vyos.utils.network import check_port_availability
+from vyos.utils.dict import dict_search
+from vyos.accel_ppp_util import verify_accel_ppp_name_servers
+from vyos.accel_ppp_util import verify_accel_ppp_wins_servers
+from vyos.accel_ppp_util import verify_accel_ppp_authentication
+from vyos.accel_ppp_util import verify_accel_ppp_ip_pool
+from vyos.accel_ppp_util import get_pools_in_order
+from vyos.utils.network import is_listen_port_bind_service
+from vyos.utils.file import write_file
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+cfg_dir = '/run/accel-pppd'
+sstp_conf = '/run/accel-pppd/sstp.conf'
+sstp_chap_secrets = '/run/accel-pppd/sstp.chap-secrets'
+
+cert_file_path = os.path.join(cfg_dir, 'sstp-cert.pem')
+cert_key_path = os.path.join(cfg_dir, 'sstp-cert.key')
+ca_cert_file_path = os.path.join(cfg_dir, 'sstp-ca.pem')
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['vpn', 'sstp']
+ if not conf.exists(base):
+ return None
+
+ # retrieve common dictionary keys
+ sstp = get_accel_dict(conf, base, sstp_chap_secrets, with_pki=True)
+ if dict_search('client_ip_pool', sstp):
+ # Multiple named pools require ordered values T5099
+ sstp['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', sstp))
+
+ sstp['server_type'] = 'sstp'
+ return sstp
+
+
+def verify(sstp):
+ if not sstp:
+ return None
+
+ port = sstp.get('port')
+ proto = 'tcp'
+ if check_port_availability('0.0.0.0', int(port), proto) is not True and \
+ not is_listen_port_bind_service(int(port), 'accel-pppd'):
+ raise ConfigError(f'"{proto}" port "{port}" is used by another service')
+
+ verify_accel_ppp_authentication(sstp)
+ verify_accel_ppp_ip_pool(sstp)
+ verify_accel_ppp_name_servers(sstp)
+ verify_accel_ppp_wins_servers(sstp)
+
+ if 'ssl' not in sstp:
+ raise ConfigError('SSL missing on SSTP config!')
+
+ if 'certificate' not in sstp['ssl']:
+ raise ConfigError('SSL certificate missing on SSTP config!')
+ verify_pki_certificate(sstp, sstp['ssl']['certificate'])
+
+ if 'ca_certificate' not in sstp['ssl']:
+ raise ConfigError('SSL CA certificate missing on SSTP config!')
+ verify_pki_ca_certificate(sstp, sstp['ssl']['ca_certificate'])
+
+
+def generate(sstp):
+ if not sstp:
+ return None
+
+ # accel-cmd reload doesn't work so any change results in a restart of the daemon
+ render(sstp_conf, 'accel-ppp/sstp.config.j2', sstp)
+
+ cert_name = sstp['ssl']['certificate']
+ pki_cert = sstp['pki']['certificate'][cert_name]
+
+ ca_cert_name = sstp['ssl']['ca_certificate']
+ pki_ca = sstp['pki']['ca'][ca_cert_name]
+ write_file(cert_file_path, wrap_certificate(pki_cert['certificate']))
+ write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))
+ write_file(ca_cert_file_path, wrap_certificate(pki_ca['certificate']))
+
+ if dict_search('authentication.mode', sstp) == 'local':
+ render(sstp_chap_secrets, 'accel-ppp/chap-secrets.config_dict.j2',
+ sstp, permission=0o640)
+ else:
+ if os.path.exists(sstp_chap_secrets):
+ os.unlink(sstp_chap_secrets)
+
+ return sstp
+
+
+def apply(sstp):
+ systemd_service = 'accel-ppp@sstp.service'
+ if not sstp:
+ call(f'systemctl stop {systemd_service}')
+ for file in [sstp_chap_secrets, sstp_conf]:
+ if os.path.exists(file):
+ os.unlink(file)
+ return None
+
+ call(f'systemctl reload-or-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/vrf.py b/src/conf_mode/vrf.py
new file mode 100644
index 0000000..72b178c
--- /dev/null
+++ b/src/conf_mode/vrf.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+from sys import exit
+from jmespath import search
+from json import loads
+
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import node_changed
+from vyos.configverify import verify_route_map
+from vyos.firewall import conntrack_required
+from vyos.ifconfig import Interface
+from vyos.template import render
+from vyos.template import render_to_string
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_vrf_tableid
+from vyos.utils.network import get_vrf_members
+from vyos.utils.network import interface_exists
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import popen
+from vyos.utils.system import sysctl_write
+from vyos import ConfigError
+from vyos import frr
+from vyos import airbag
+airbag.enable()
+
+config_file = '/etc/iproute2/rt_tables.d/vyos-vrf.conf'
+k_mod = ['vrf']
+
+nftables_table = 'inet vrf_zones'
+nftables_rules = {
+ 'vrf_zones_ct_in': 'counter ct original zone set iifname map @ct_iface_map',
+ 'vrf_zones_ct_out': 'counter ct original zone set oifname map @ct_iface_map'
+}
+
+def has_rule(af : str, priority : int, table : str=None):
+ """
+ Check if a given ip rule exists
+ $ ip --json -4 rule show
+ [{'l3mdev': None, 'priority': 1000, 'src': 'all'},
+ {'action': 'unreachable', 'l3mdev': None, 'priority': 2000, 'src': 'all'},
+ {'priority': 32765, 'src': 'all', 'table': 'local'},
+ {'priority': 32766, 'src': 'all', 'table': 'main'},
+ {'priority': 32767, 'src': 'all', 'table': 'default'}]
+ """
+ if af not in ['-4', '-6']:
+ raise ValueError()
+ command = f'ip --detail --json {af} rule show'
+ for tmp in loads(cmd(command)):
+ if 'priority' in tmp and 'table' in tmp:
+ if tmp['priority'] == priority and tmp['table'] == table:
+ return True
+ elif 'priority' in tmp and table in tmp:
+ # l3mdev table has a different layout
+ if tmp['priority'] == priority:
+ return True
+ return False
+
+def is_nft_vrf_zone_rule_setup() -> bool:
+ """
+ Check if an nftables connection tracking rule already exists
+ """
+ tmp = loads(cmd('sudo nft -j list table inet vrf_zones'))
+ num_rules = len(search("nftables[].rule[].chain", tmp))
+ return bool(num_rules)
+
+def vrf_interfaces(c, match):
+ matched = []
+ old_level = c.get_level()
+ c.set_level(['interfaces'])
+ section = c.get_config_dict([], get_first_key=True)
+ for type in section:
+ interfaces = section[type]
+ for name in interfaces:
+ interface = interfaces[name]
+ if 'vrf' in interface:
+ v = interface.get('vrf', '')
+ if v == match:
+ matched.append(name)
+
+ c.set_level(old_level)
+ return matched
+
+def vrf_routing(c, match):
+ matched = []
+ old_level = c.get_level()
+ c.set_level(['protocols', 'vrf'])
+ if match in c.list_nodes([]):
+ matched.append(match)
+
+ c.set_level(old_level)
+ return matched
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+
+ base = ['vrf']
+ vrf = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True, get_first_key=True)
+
+ # determine which VRF has been removed
+ for name in node_changed(conf, base + ['name']):
+ if 'vrf_remove' not in vrf:
+ vrf.update({'vrf_remove' : {}})
+
+ vrf['vrf_remove'][name] = {}
+ # get VRF bound interfaces
+ interfaces = vrf_interfaces(conf, name)
+ if interfaces: vrf['vrf_remove'][name]['interface'] = interfaces
+ # get VRF bound routing instances
+ routes = vrf_routing(conf, name)
+ if routes: vrf['vrf_remove'][name]['route'] = routes
+
+ if 'name' in vrf:
+ vrf['conntrack'] = conntrack_required(conf)
+
+ # We also need the route-map information from the config
+ #
+ # XXX: one MUST always call this without the key_mangling() option! See
+ # vyos.configverify.verify_common_route_maps() for more information.
+ tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
+ get_first_key=True)}}
+
+ # Merge policy dict into "regular" config dict
+ vrf = dict_merge(tmp, vrf)
+ return vrf
+
+def verify(vrf):
+ # ensure VRF is not assigned to any interface
+ if 'vrf_remove' in vrf:
+ for name, config in vrf['vrf_remove'].items():
+ if 'interface' in config:
+ raise ConfigError(f'Can not remove VRF "{name}", it still has '\
+ f'member interfaces!')
+ if 'route' in config:
+ raise ConfigError(f'Can not remove VRF "{name}", it still has '\
+ f'static routes installed!')
+
+ if 'name' in vrf:
+ reserved_names = ["add", "all", "broadcast", "default", "delete", "dev",
+ "get", "inet", "mtu", "link", "type", "vrf"]
+ table_ids = []
+ for name, vrf_config in vrf['name'].items():
+ # Reserved VRF names
+ if name in reserved_names:
+ raise ConfigError(f'VRF name "{name}" is reserved and connot be used!')
+
+ # table id is mandatory
+ if 'table' not in vrf_config:
+ raise ConfigError(f'VRF "{name}" table id is mandatory!')
+
+ # routing table id can't be changed - OS restriction
+ if interface_exists(name):
+ tmp = get_vrf_tableid(name)
+ if tmp and tmp != int(vrf_config['table']):
+ raise ConfigError(f'VRF "{name}" table id modification not possible!')
+
+ # VRF routing table ID must be unique on the system
+ if 'table' in vrf_config and vrf_config['table'] in table_ids:
+ raise ConfigError(f'VRF "{name}" table id is not unique!')
+ table_ids.append(vrf_config['table'])
+
+ tmp = dict_search('ip.protocol', vrf_config)
+ if tmp != None:
+ for protocol, protocol_options in tmp.items():
+ if 'route_map' in protocol_options:
+ verify_route_map(protocol_options['route_map'], vrf)
+
+ tmp = dict_search('ipv6.protocol', vrf_config)
+ if tmp != None:
+ for protocol, protocol_options in tmp.items():
+ if 'route_map' in protocol_options:
+ verify_route_map(protocol_options['route_map'], vrf)
+
+ return None
+
+
+def generate(vrf):
+ # Render iproute2 VR helper names
+ render(config_file, 'iproute2/vrf.conf.j2', vrf)
+ # Render VRF Kernel/Zebra route-map filters
+ vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf)
+
+ return None
+
+def apply(vrf):
+ # Documentation
+ #
+ # - https://github.com/torvalds/linux/blob/master/Documentation/networking/vrf.txt
+ # - https://github.com/Mellanox/mlxsw/wiki/Virtual-Routing-and-Forwarding-(VRF)
+ # - https://github.com/Mellanox/mlxsw/wiki/L3-Tunneling
+ # - https://netdevconf.info/1.1/proceedings/slides/ahern-vrf-tutorial.pdf
+ # - https://netdevconf.info/1.2/slides/oct6/02_ahern_what_is_l3mdev_slides.pdf
+
+ # set the default VRF global behaviour
+ bind_all = '0'
+ if 'bind_to_all' in vrf:
+ bind_all = '1'
+ sysctl_write('net.ipv4.tcp_l3mdev_accept', bind_all)
+ sysctl_write('net.ipv4.udp_l3mdev_accept', bind_all)
+
+ for tmp in (dict_search('vrf_remove', vrf) or []):
+ if interface_exists(tmp):
+ # T5492: deleting a VRF instance may leafe processes running
+ # (e.g. dhclient) as there is a depedency ordering issue in the CLI.
+ # We need to ensure that we stop the dhclient processes first so
+ # a proper DHCLP RELEASE message is sent
+ for interface in get_vrf_members(tmp):
+ vrf_iface = Interface(interface)
+ vrf_iface.set_dhcp(False)
+ vrf_iface.set_dhcpv6(False)
+
+ # Remove nftables conntrack zone map item
+ nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}'
+ # Check if deleting is possible first to avoid raising errors
+ _, err = popen(f'nft --check {nft_del_element}')
+ if not err:
+ # Remove map element
+ cmd(f'nft {nft_del_element}')
+
+ # Delete the VRF Kernel interface
+ call(f'ip link delete dev {tmp}')
+
+ if 'name' in vrf:
+ # Linux routing uses rules to find tables - routing targets are then
+ # looked up in those tables. If the lookup got a matching route, the
+ # process ends.
+ #
+ # TL;DR; first table with a matching entry wins!
+ #
+ # You can see your routing table lookup rules using "ip rule", sadly the
+ # local lookup is hit before any VRF lookup. Pinging an addresses from the
+ # VRF will usually find a hit in the local table, and never reach the VRF
+ # routing table - this is usually not what you want. Thus we will
+ # re-arrange the tables and move the local lookup further down once VRFs
+ # are enabled.
+ #
+ # Thanks to https://stbuehler.de/blog/article/2020/02/29/using_vrf__virtual_routing_and_forwarding__on_linux.html
+
+ for afi in ['-4', '-6']:
+ # move lookup local to pref 32765 (from 0)
+ if not has_rule(afi, 32765, 'local'):
+ call(f'ip {afi} rule add pref 32765 table local')
+ if has_rule(afi, 0, 'local'):
+ call(f'ip {afi} rule del pref 0')
+ # make sure that in VRFs after failed lookup in the VRF specific table
+ # nothing else is reached
+ if not has_rule(afi, 1000, 'l3mdev'):
+ # this should be added by the kernel when a VRF is created
+ # add it here for completeness
+ call(f'ip {afi} rule add pref 1000 l3mdev protocol kernel')
+
+ # add another rule with an unreachable target which only triggers in VRF context
+ # if a route could not be reached
+ if not has_rule(afi, 2000, 'l3mdev'):
+ call(f'ip {afi} rule add pref 2000 l3mdev unreachable')
+
+ nft_vrf_zone_rule_setup = False
+ for name, config in vrf['name'].items():
+ table = config['table']
+ if not interface_exists(name):
+ # For each VRF apart from your default context create a VRF
+ # interface with a separate routing table
+ call(f'ip link add {name} type vrf table {table}')
+
+ # set VRF description for e.g. SNMP monitoring
+ vrf_if = Interface(name)
+ # We also should add proper loopback IP addresses to the newly added
+ # VRF for services bound to the loopback address (SNMP, NTP)
+ vrf_if.add_addr('127.0.0.1/8')
+ vrf_if.add_addr('::1/128')
+ # add VRF description if available
+ vrf_if.set_alias(config.get('description', ''))
+
+ # Enable/Disable IPv4 forwarding
+ tmp = dict_search('ip.disable_forwarding', config)
+ value = '0' if (tmp != None) else '1'
+ vrf_if.set_ipv4_forwarding(value)
+ # Enable/Disable IPv6 forwarding
+ tmp = dict_search('ipv6.disable_forwarding', config)
+ value = '0' if (tmp != None) else '1'
+ vrf_if.set_ipv6_forwarding(value)
+
+ # Enable/Disable of an interface must always be done at the end of the
+ # derived class to make use of the ref-counting set_admin_state()
+ # function. We will only enable the interface if 'up' was called as
+ # often as 'down'. This is required by some interface implementations
+ # as certain parameters can only be changed when the interface is
+ # in admin-down state. This ensures the link does not flap during
+ # reconfiguration.
+ state = 'down' if 'disable' in config else 'up'
+ vrf_if.set_admin_state(state)
+ # Add nftables conntrack zone map item
+ nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}'
+ cmd(f'nft {nft_add_element}')
+
+ # Only call into nftables as long as there is nothing setup to avoid wasting
+ # CPU time and thus lenghten the commit process
+ if not nft_vrf_zone_rule_setup:
+ nft_vrf_zone_rule_setup = is_nft_vrf_zone_rule_setup()
+ # Install nftables conntrack rules only once
+ if vrf['conntrack'] and not nft_vrf_zone_rule_setup:
+ for chain, rule in nftables_rules.items():
+ cmd(f'nft add rule inet vrf_zones {chain} {rule}')
+
+ if 'name' not in vrf or not vrf['conntrack']:
+ for chain, rule in nftables_rules.items():
+ cmd(f'nft flush chain inet vrf_zones {chain}')
+
+ # Return default ip rule values
+ if 'name' not in vrf:
+ for afi in ['-4', '-6']:
+ # move lookup local to pref 0 (from 32765)
+ if not has_rule(afi, 0, 'local'):
+ call(f'ip {afi} rule add pref 0 from all lookup local')
+ if has_rule(afi, 32765, 'local'):
+ call(f'ip {afi} rule del pref 32765 table local')
+
+ if has_rule(afi, 1000, 'l3mdev'):
+ call(f'ip {afi} rule del pref 1000 l3mdev protocol kernel')
+ if has_rule(afi, 2000, 'l3mdev'):
+ call(f'ip {afi} rule del pref 2000 l3mdev unreachable')
+
+ # Apply FRR filters
+ zebra_daemon = 'zebra'
+ # Save original configuration prior to starting any commit actions
+ frr_cfg = frr.FRRConfig()
+
+ # The route-map used for the FIB (zebra) is part of the zebra daemon
+ frr_cfg.load_configuration(zebra_daemon)
+ frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True)
+ if 'frr_zebra_config' in vrf:
+ frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config'])
+ frr_cfg.commit_configuration(zebra_daemon)
+
+ return None
+
+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/etc/bash_completion.d/vyatta-op b/src/etc/bash_completion.d/vyatta-op
new file mode 100644
index 0000000..8ac2d9b
--- /dev/null
+++ b/src/etc/bash_completion.d/vyatta-op
@@ -0,0 +1,685 @@
+# vyatta bash operational mode completion
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2006, 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# Author: Tom Grennan
+# Date: 2007
+# Description: setup bash completion for Vyatta operational commands
+#
+# **** End License ****
+
+test -z "$_vyatta_less_options" && \
+ declare -r _vyatta_less_options="\
+ --QUIT-AT-EOF\
+ --quit-if-one-screen\
+ --RAW-CONTROL-CHARS\
+ --squeeze-blank-lines\
+ --no-init"
+test -z "$_vyatta_default_pager" && \
+ declare -r _vyatta_default_pager="less \
+ --buffers=64\
+ --auto-buffers\
+ --no-lessopen\
+ $_vyatta_less_options"
+test -z "$VYATTA_PAGER" && \
+ declare -x VYATTA_PAGER=$_vyatta_default_pager
+
+_vyatta_op_do_key_bindings ()
+{
+ if [[ "$SHELL" != "/bin/vbash" && "$SHELL" != "/sbin/radius_shell" ]]; then
+ # only do bindings if vbash and radius_shell
+ return
+ fi
+ nullglob_save=$(shopt -p nullglob)
+ shopt -u nullglob
+ case "$-" in
+ *i*)
+ bind '"?": possible-completions'
+ bind 'set show-all-if-ambiguous on'
+ bind_cmds=$(grep '^bind .* # vyatta key binding$' $HOME/.bashrc)
+ eval $bind_cmds
+ ;;
+ esac
+ eval $nullglob_save
+}
+
+_vyatta_op_do_key_bindings
+
+test -f /etc/default/vyatta && \
+ source /etc/default/vyatta
+
+test ! -d "$vyatta_op_templates" && \
+ return 0
+
+case "$-" in
+ *i*)
+ declare -r _vyatta_op_last_comp_init='>>>>>>LASTCOMP<<<<<<'
+ ;;
+esac
+declare _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+declare _vyatta_op_node_path
+declare -a _vyatta_op_noncompletions _vyatta_op_completions
+declare -x -a _vyatta_pipe_noncompletions _vyatta_pipe_completions
+declare _vyatta_comptype
+declare -x -a reply
+declare -a _vyatta_operator_allowed
+
+if [[ "$VYATTA_USER_LEVEL_DIR" != "/opt/vyatta/etc/shell/level/admin" ]]; then
+ _vyatta_operator_allowed=( $(cat $VYATTA_USER_LEVEL_DIR/allowed-op) )
+fi
+
+declare -a functions
+functions=( /opt/vyatta/share/vyatta-op/functions/interpreter/* )
+
+for file in "${functions[@]}";do
+ source $file;
+done
+
+# $1: label
+# #2...: strings
+_vyatta_op_debug ()
+{
+ echo -ne \\n$1:
+ shift
+ for s ; do
+ echo -ne " \"$s\""
+ done
+}
+
+# this is needed to provide original "default completion" behavior.
+# see "vyatta-cfg" completion script for details.
+_vyatta_op_default_expand ()
+{
+ local wc=${#COMP_WORDS[@]}
+ if [[ "${COMP_WORDS[0]}" =~ "/" ]]; then
+ # if we are looking for a directory on the first completion then do directory completions
+ _filedir_xspec_vyos
+ elif (( wc < 2 )) ||
+ [[ $COMP_CWORD -eq 0 ]] ||
+ [[ $1 == $2 ]]; then
+ _vyatta_op_expand "$@"
+ else
+ # after the first word => cannot be vyatta command so use original default
+ _filedir_xspec_vyos
+ fi
+}
+
+# $1: label
+# $2...: help
+_vyatta_op_print_help ()
+{
+ local label=$1 help=$2
+ if [ ${#label} -eq 0 ] ; then
+ return
+ elif [ ${#help} -eq 0 ] ; then
+ echo -ne "\n $label"
+ elif [ ${#label} -lt 6 ] ; then
+ echo -ne "\n $label\t\t\t$help"
+ elif [ ${#label} -lt 14 ] ; then
+ echo -ne "\n $label\t\t$help"
+ elif [ ${#label} -lt 21 ] ; then
+ echo -ne "\n $label\t$help"
+ else
+ echo -ne "\n $label\n\t\t\t$help"
+ fi
+}
+
+# $1: $cur
+# $2...: possible completions
+_vyatta_op_help ()
+{
+ local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; )
+ shopt -u nullglob
+ local cur=$1; shift
+ local ndef node_tag_help node_run help last_help
+
+ ndef=${_vyatta_op_node_path}/node.tag/node.def
+ [ -f $ndef ] && \
+ node_tag_help=$( _vyatta_op_get_node_def_field $ndef help )
+
+ ndef=${_vyatta_op_node_path}/node.def
+ [ -f $ndef ] && \
+ node_run=$( _vyatta_op_get_node_def_field $ndef run )
+
+ if [[ "$1" == "<nocomps>" ]]; then
+ eval "$restore_shopts"
+ return
+ fi
+ echo -en "\nPossible completions:"
+ if [ -z "$cur" -a -n "$node_run" ]; then
+ _vyatta_op_print_help '<Enter>' "Execute the current command"
+ fi
+ if [ $# -eq 0 ];then
+ _vyatta_op_print_help '<text>' "$node_tag_help"
+ eval "$restore_shopts"
+ return
+ fi
+ for comp ; do
+ if [[ "$comp" == "<Enter>" ]]; then
+ continue
+ fi
+ if [ -z "$comp" ] ; then
+ if [ "X$node_tag_help" == "X$last_help" ] ; then
+ help=""
+ else
+ last_help=$node_tag_help
+ help=$node_tag_help
+ fi
+ _vyatta_op_print_help '*' "$help"
+ elif [[ -z "$cur" || $comp == ${cur}* ]] ; then
+ ndef=${_vyatta_op_node_path}/$comp/node.def
+ if [ -f $ndef ] ; then
+ help=$( _vyatta_op_get_node_def_field $ndef help )
+ else
+ help=$node_tag_help
+ fi
+ if [ "X$help" == "X$last_help" ] ; then
+ help=""
+ else
+ last_help=$help
+ fi
+ _vyatta_op_print_help "$comp" "$help"
+ fi
+ done
+ eval "$restore_shopts"
+}
+
+_vyatta_op_set_node_path ()
+{
+ local node
+ _vyatta_op_node_path=$vyatta_op_templates
+ for (( i=0 ; i<COMP_CWORD ; i++ )) ; do
+ # expand the command so completion continues to work with short versions
+ if [[ "${COMP_WORDS[i]}" == "*" ]]; then
+ node="node.tag" # user defined wildcars are always tag nodes
+ else
+ node=$(_vyatta_op_conv_node_path $_vyatta_op_node_path ${COMP_WORDS[i]})
+ fi
+ if [ -f "${_vyatta_op_node_path}/$node/node.def" ] ; then
+ _vyatta_op_node_path+=/$node
+ elif [ -f ${_vyatta_op_node_path}/node.tag/node.def ] ; then
+ _vyatta_op_node_path+=/node.tag
+ else
+ return 1
+ fi
+ done
+}
+
+_vyatta_op_set_completions ()
+{
+ local -a allowed completions
+ local cur=$1
+ local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; )
+ for ndef in ${_vyatta_op_node_path}/*/node.def ; do
+ if [[ $ndef == */node.tag/node.def ]] ; then
+ local acmd=$( _vyatta_op_get_node_def_field $ndef allowed )
+ shopt -u extglob nullglob
+ local -a a=($( eval "$acmd" ))
+ eval "$restore_shopts"
+
+ if [ ${#a[@]} -ne 0 ] ; then
+ allowed+=( "${a[@]}" )
+ else
+ allowed+=( "<text>" )
+ fi
+ else
+ local sdir=${ndef%/*}
+ allowed+=( ${sdir##*/} )
+ fi
+ done
+
+ # donot complete entries like <HOSTNAME> or <A.B.C.D>
+ _vyatta_op_noncompletions=( )
+ completions=( )
+
+ # make runable commands have a non-comp
+ ndef=${_vyatta_op_node_path}/node.def
+ [ -f $ndef ] && \
+ node_run=$( _vyatta_op_get_node_def_field $ndef run )
+ if [ -z "$cur" -a -n "$node_run" ]; then
+ _vyatta_op_noncompletions+=('<Enter>')
+ fi
+
+ for (( i=0 ; i<${#allowed[@]} ; i++ )) ; do
+ if [[ "${allowed[i]}" == \<*\> ]] ; then
+ _vyatta_op_noncompletions+=( "${allowed[i]}" )
+ else
+ if [[ "$VYATTA_USER_LEVEL_DIR" == "/opt/vyatta/etc/shell/level/admin" ]]; then
+ completions+=( ${allowed[i]} )
+ elif is_elem_of ${allowed[i]} _vyatta_operator_allowed; then
+ completions+=( ${allowed[i]} )
+ elif [[ $_vyatta_op_node_path == $vyatta_op_templates ]];then
+ continue
+ else
+ completions+=( ${allowed[i]} )
+ fi
+ fi
+ done
+
+ # Prefix filter the non empty completions
+ if [ -n "$cur" ]; then
+ _vyatta_op_completions=()
+ get_prefix_filtered_list "$cur" completions _vyatta_op_completions
+ _vyatta_op_completions=($( printf "%s\n" ${_vyatta_op_completions[@]} | sort -u ))
+ else
+ _vyatta_op_completions=($( printf "%s\n" ${completions[@]} | sort -u ))
+ fi
+ #shopt -s nullglob
+}
+
+_vyatta_op_comprely_needs_ambiguity ()
+{
+ local -a uniq
+
+ [ ${#COMPREPLY[@]} -eq 1 ] && return
+
+ uniq=( `printf "%s\n" ${COMPREPLY[@]} | cut -c1 | sort -u` )
+
+ [ ${#uniq[@]} -eq 1 ] && return
+ false
+}
+
+_vyatta_op_invalid_completion ()
+{
+ local tpath=$vyatta_op_templates
+ local -a args
+ local i=1
+ for arg in "${COMP_WORDS[@]}"; do
+ arg=( $(_vyatta_op_conv_node_path $tpath $arg) ) # expand the arguments
+ # output proper error message based on the above expansion
+ if [[ "${arg[1]}" == "ambiguous" ]]; then
+ echo -ne "\n\n Ambiguous command: ${args[@]} [$arg]\n"
+ local -a cmds=( $(compgen -d $tpath/$arg) )
+ _vyatta_op_node_path=$tpath
+ local comps=$(_vyatta_op_help $arg ${cmds[@]##*/})
+ echo -ne "$comps" | sed -e 's/^P/ P/'
+ break
+ elif [[ "${arg[1]}" == "invalid" ]]; then
+ echo -ne "\n\n Invalid command: ${args[@]} [$arg]"
+ break
+ fi
+
+ if [ -f "$tpath/$arg/node.def" ] ; then
+ tpath+=/$arg
+ elif [ -f $tpath/node.tag/node.def ] ; then
+ tpath+=/node.tag
+ else
+ echo -ne "\n\n Invalid command: ${args[@]} [$arg]" >&2
+ break
+ fi
+ args[$i]=$arg
+ let "i+=1"
+ if [ $[${#COMP_WORDS[@]}+1] -eq $i ];then
+ _vyatta_op_help "" \
+ "${_vyatta_op_noncompletions[@]}" \
+ "${_vyatta_op_completions[@]}" \
+ | ${VYATTA_PAGER:-cat}
+ fi
+ done
+}
+
+_vyatta_op_expand ()
+{
+ # We need nospace here and we have to append our own spaces
+ compopt -o nospace
+
+ local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; )
+ shopt -s extglob nullglob
+ local cur=""
+ local _has_comptype=0
+ local current_prefix=$2
+ local current_word=$3
+ _vyatta_comptype=""
+
+ if (( ${#COMP_WORDS[@]} > 0 )); then
+ cur=${COMP_WORDS[COMP_CWORD]}
+ else
+ (( COMP_CWORD = ${#COMP_WORDS[@]} ))
+ fi
+
+ if _vyatta_pipe_completion "${COMP_WORDS[@]}"; then
+ if [ "${COMP_WORDS[*]}" == "$_vyatta_op_last_comp" ] ||
+ [ ${#_vyatta_pipe_completions[@]} -eq 0 ]; then
+ _vyatta_do_pipe_help
+ COMPREPLY=( "" " " )
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ else
+ COMPREPLY=( "${_vyatta_pipe_completions[@]}" )
+ _vyatta_op_last_comp="${COMP_WORDS[*]}"
+ if [ ${#COMPREPLY[@]} -eq 1 ]; then
+ COMPREPLY=( "${COMPREPLY[0]} " )
+ fi
+ fi
+ eval "$restore_shopts"
+ return
+ fi
+
+ # this needs to be done on every completion even if it is the 'same' comp.
+ # The cursor can be at different places in the string.
+ # this will lead to unexpected cases if setting the node path isn't attempted
+ # each time.
+ if ! _vyatta_op_set_node_path ; then
+ echo -ne \\a
+ _vyatta_op_invalid_completion
+ COMPREPLY=( "" " " )
+ eval "$restore_shopts"
+ return 1
+ fi
+
+ if [ "${COMP_WORDS[*]:0:$[$COMP_CWORD+1]}" != "$_vyatta_op_last_comp" ] ; then
+ _vyatta_set_comptype
+ case $_vyatta_comptype in
+ 'imagefiles')
+ _has_comptype=1
+ _vyatta_image_file_complete
+ ;;
+ *)
+ _has_comptype=0
+ if [[ -z "$current_word" ]]; then
+ _vyatta_op_set_completions $cur
+ else
+ _vyatta_op_set_completions $current_prefix
+ fi
+ ;;
+ esac
+ fi
+ if [[ $_has_comptype == 1 ]]; then
+ COMPREPLY=( "${_vyatta_op_completions[@]}" )
+ else
+ COMPREPLY=($( compgen -W "${_vyatta_op_completions[*]}" -- $current_prefix ))
+ fi
+
+ # if the last command line arg is empty and we have
+ # an empty completion option (meaning wild card),
+ # append a blank(s) to the completion array to force ambiguity
+ if [ -z "$current_prefix" -a -n "$current_word" ] ||
+ [[ "${COMPREPLY[0]}" =~ "$cur" ]]; then
+ for comp ; do
+ if [ -z "$comp" ] ; then
+ if [ ${#COMPREPLY[@]} -eq 0 ] ; then
+ COMPREPLY=( " " "" )
+ elif _vyatta_op_comprely_needs_ambiguity ; then
+ COMPREPLY+=( " " )
+ fi
+ fi
+ done
+ fi
+ # Set this environment to enable and disable debugging on the fly
+ if [[ $DBG_OP_COMPS -eq 1 ]]; then
+ echo -e "\nCurrent: '$cur'"
+ echo -e "Current word: '$current_word'"
+ echo -e "Current prefix: '$current_prefix'"
+ echo "Number of comps: ${#_vyatta_op_completions[*]}"
+ echo "Number of non-comps: ${#_vyatta_op_noncompletions[*]}"
+ echo "_vyatta_op_completions: '${_vyatta_op_completions[*]}'"
+ echo "COMPREPLY: '${COMPREPLY[@]}'"
+ echo "CWORD: $COMP_CWORD"
+ echo "Last comp: '$_vyatta_op_last_comp'"
+ echo -e "Current comp: '${COMP_WORDS[*]:0:$[$COMP_CWORD+1]}'\n"
+ fi
+
+ # This is non obvious...
+ # To have completion continue to work when working with words that aren't the last word,
+ # we have to set nospace at the beginning of this script and then append the spaces here.
+ if [ ${#COMPREPLY[@]} -eq 1 ] &&
+ [[ $_has_comptype -ne 1 ]]; then
+ COMPREPLY=( "${COMPREPLY[0]} " )
+ fi
+ # if there are no completions then handle invalid commands
+ if [ ${#_vyatta_op_noncompletions[@]} -eq 0 ] &&
+ [ ${#_vyatta_op_completions[@]} -eq 0 ]; then
+ _vyatta_op_invalid_completion
+ COMPREPLY=( "" " " )
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ elif [ ${#COMPREPLY[@]} -eq 0 ] &&
+ [ -n "$current_prefix" ]; then
+ _vyatta_op_invalid_completion
+ COMPREPLY=( "" " " )
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ # Stop completions from getting stuck
+ elif [ ${#_vyatta_op_completions[@]} -eq 1 ] &&
+ [ -n "$cur" ] &&
+ [[ "${COMPREPLY[0]}" =~ "$cur" ]]; then
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ elif [ ${#_vyatta_op_completions[@]} -eq 1 ] &&
+ [ -n "$current_prefix" ] &&
+ [[ "${COMPREPLY[0]}" =~ "$current_prefix" ]]; then
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ # if there are no completions then always show the non-comps
+ elif [ "${COMP_WORDS[*]:0:$[$COMP_CWORD+1]}" == "$_vyatta_op_last_comp" ] ||
+ [ ${#_vyatta_op_completions[@]} -eq 0 ] ||
+ [ -z "$cur" ]; then
+ _vyatta_op_help "$current_prefix" \
+ "${_vyatta_op_noncompletions[@]}" \
+ "${_vyatta_op_completions[@]}" \
+ | ${VYATTA_PAGER:-cat}
+ COMPREPLY=( "" " " )
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ else
+ _vyatta_op_last_comp="${COMP_WORDS[*]:0:$[$COMP_CWORD+1]}"
+ fi
+
+ eval "$restore_shopts"
+}
+
+# "pipe" functions
+count ()
+{
+ wc -l
+}
+
+match ()
+{
+ grep -E -e "$1"
+}
+
+no-match ()
+{
+ grep -E -v -e "$1"
+}
+
+no-more ()
+{
+ cat
+}
+
+strip-private ()
+{
+ ${vyos_libexec_dir}/strip-private.py
+}
+
+commands ()
+{
+ if [ "$_OFR_CONFIGURE" != "" ]; then
+ if $(cli-shell-api sessionChanged); then
+ echo "You have uncommited changes, please commit them before using the commands pipe"
+ else
+ vyos-config-to-commands
+ fi
+ else
+ echo "commands pipe is not supported in operational mode"
+ fi
+}
+
+json ()
+{
+ if [ "$_OFR_CONFIGURE" != "" ]; then
+ if $(cli-shell-api sessionChanged); then
+ echo "You have uncommited changes, please commit them before using the JSON pipe"
+ else
+ vyos-config-to-json
+ fi
+ else
+ echo "JSON pipe is not supported in operational mode"
+ fi
+}
+
+# pipe command help
+# $1: command
+_vyatta_pipe_help ()
+{
+ local help="No help text available"
+ case "$1" in
+ count) help="Count the number of lines in the output";;
+ match) help="Only output lines that match specified pattern";;
+ no-match) help="Only output lines that do not match specified pattern";;
+ more) help="Paginate the output";;
+ no-more) help="Do not paginate the output";;
+ strip-private) help="Remove private information from the config";;
+ commands) help="Convert config to set commands";;
+ json) help="Convert config to JSON";;
+ '<pattern>') help="Pattern for matching";;
+ esac
+ echo -n "$help"
+}
+
+_vyatta_do_pipe_help ()
+{
+ local help=''
+ if (( ${#_vyatta_pipe_completions[@]} + ${#_vyatta_pipe_noncompletions[@]}
+ == 0 )); then
+ return
+ fi
+ echo -en "\nPossible completions:"
+ for comp in "${_vyatta_pipe_completions[@]}" \
+ "${_vyatta_pipe_noncompletions[@]}"; do
+ _vyatta_op_print_help "$comp" "$(_vyatta_pipe_help "$comp")"
+ done
+}
+
+# pipe completion
+# $@: words
+_vyatta_pipe_completion ()
+{
+ local -a pipe_cmd=()
+ local -a all_cmds=( 'count' 'match' 'no-match' 'more' 'no-more' 'strip-private' 'commands' 'json' )
+ local found=0
+ _vyatta_pipe_completions=()
+ _vyatta_pipe_noncompletions=()
+
+ for word in "$@"; do
+ if [[ "$found" == "1" || "$word" == "|" ]]; then
+ pipe_cmd+=( "$word" )
+ found=1
+ fi
+ done
+ if (( found == 0 )); then
+ return 1
+ fi
+ if (( ${#pipe_cmd[@]} == 1 )); then
+ # "|" only
+ _vyatta_pipe_completions=( "${all_cmds[@]}" )
+ return 0
+ fi
+ if (( ${#pipe_cmd[@]} == 2 )); then
+ # "|<space, chars, or space+chars>"
+ _vyatta_pipe_completions=($(compgen -W "${all_cmds[*]}" -- ${pipe_cmd[1]}))
+ return 0
+ fi
+ if (( ${#pipe_cmd[@]} == 3 )); then
+ # "|<chars or space+chars><space or space+chars>"
+ case "${pipe_cmd[1]}" in
+ match|no-match) _vyatta_pipe_noncompletions=( '<pattern>' );;
+ esac
+ return 0
+ fi
+ return 0
+}
+
+# comptype
+_vyatta_set_comptype ()
+{
+ local comptype
+ unset _vyatta_comptype
+ for ndef in ${_vyatta_op_node_path}/*/node.def ; do
+ if [[ $ndef == */node.tag/node.def ]] ; then
+ local comptype=$( _vyatta_op_get_node_def_field $ndef comptype )
+ if [[ $comptype == "imagefiles" ]] ; then
+ _vyatta_comptype=$comptype
+ return 0
+ else
+ _vyatta_comptype=""
+ return 1
+ fi
+ else
+ _vyatta_comptype=""
+ return 1
+ fi
+ done
+}
+
+_filedir_xspec_vyos()
+{
+ local cur prev words cword
+ _init_completion || return
+
+ _tilde "$cur" || return 0
+
+ local IFS=$'\n' xspec=${_xspec[${1##*/}]} tmp
+ local -a toks
+
+ toks=( $(
+ compgen -d -- "$(quote_readline "$cur")" | {
+ while read -r tmp; do
+ printf '%s\n' $tmp
+ done
+ }
+ ))
+
+ # Munge xspec to contain uppercase version too
+ # http://thread.gmane.org/gmane.comp.shells.bash.bugs/15294/focus=15306
+ eval xspec="${xspec}"
+ local matchop=!
+ if [[ $xspec == !* ]]; then
+ xspec=${xspec#!}
+ matchop=@
+ fi
+ xspec="$matchop($xspec|${xspec^^})"
+
+ toks+=( $(
+ eval compgen -f -X "!$xspec" -- "\$(quote_readline "\$cur")" | {
+ while read -r tmp; do
+ [[ -n $tmp ]] && printf '%s\n' $tmp
+ done
+ }
+ ))
+
+ if [[ ${#toks[@]} -ne 0 ]]; then
+ compopt -o filenames
+ COMPREPLY=( "${toks[@]}" )
+ fi
+}
+
+nullglob_save=$( shopt -p nullglob )
+shopt -s nullglob
+for f in ${vyatta_datadir}/vyatta-op/functions/allowed/* ; do
+ source $f
+done
+eval $nullglob_save
+unset nullglob_save
+
+# don't initialize if we are in configure mode
+if [ "$_OFR_CONFIGURE" == "ok" ]; then
+ return 0
+fi
+
+if [[ "$VYATTA_USER_LEVEL_DIR" != "/opt/vyatta/etc/shell/level/admin" ]]; then
+ vyatta_unpriv_init $@
+else
+ _vyatta_op_init $@
+fi
+
+### Local Variables:
+### mode: shell-script
+### End:
diff --git a/src/etc/commit/post-hooks.d/00vyos-sync b/src/etc/commit/post-hooks.d/00vyos-sync
new file mode 100644
index 0000000..8ec732d
--- /dev/null
+++ b/src/etc/commit/post-hooks.d/00vyos-sync
@@ -0,0 +1,7 @@
+#!/bin/sh
+# When power is lost right after a commit modified files, the
+# system can be corrupted and e.g. login is no longer possible.
+# Always sync files to the backend storage after a commit.
+# https://vyos.dev/T4975
+sync
+
diff --git a/src/etc/cron.d/vyos-geoip b/src/etc/cron.d/vyos-geoip
new file mode 100644
index 0000000..9bb38a8
--- /dev/null
+++ b/src/etc/cron.d/vyos-geoip
@@ -0,0 +1 @@
+30 4 * * 1 root sg vyattacfg "/usr/libexec/vyos/geoip-update.py --force" >/tmp/geoip-update.log 2>&1
diff --git a/src/etc/default/vyatta b/src/etc/default/vyatta
new file mode 100644
index 0000000..e5fa3bb
--- /dev/null
+++ b/src/etc/default/vyatta
@@ -0,0 +1,217 @@
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2006, 2007 Vyatta, Inc.
+# All Rights Reserved.
+
+# declare configured Vyatta shell environment variables
+
+# first set vars per args of the "source /etc/default/vyatta VAR=FOO"
+_vyatta_extglob=$(shopt -p extglob)
+shopt -s extglob
+for arg ; do
+ [[ $arg == *=* ]] && \
+ eval declare -x $arg
+done
+eval $_vyatta_extglob
+unset _vyatta_extglob
+
+{
+ # These declarations must go within braces in order to be able to silence
+ # readonly variable errors.
+
+ for var in prefix exec_prefix datarootdir ; do
+ eval test -n \"\$$var\" \&\& _vyatta_save_$var=\$$var
+ done
+
+ prefix=/opt/vyatta
+ exec_prefix=${prefix}
+ datarootdir=${prefix}/share
+
+ if test -z "$vyatta_prefix" ; then
+ if test -n "/opt/vyatta" ; then
+ declare -x -r vyatta_prefix=/opt/vyatta
+ declare -x -r vyos_prefix=/opt/vyatta
+ else
+ declare -x -r vyatta_prefix=/opt/vyatta
+ declare -x -r vyos_prefix=/opt/vyatta
+ fi
+ fi
+ if test -z "$vyatta_exec_prefix" ; then
+ if test -n "${prefix}" ; then
+ declare -x -r vyatta_prefix=${prefix}
+ declare -x -r vyos_prefix=${prefix}
+ else
+ declare -x -r vyatta_prefix=$vyatta_prefix
+ declare -x -r vyos_prefix=$vyatta_prefix
+ fi
+ fi
+ if test -z "$vyatta_datarootdir" ; then
+ if test -n "${prefix}/share" ; then
+ declare -x -r vyatta_datarootdir=${prefix}/share
+ declare -x -r vyos_datarootdir=${prefix}/share
+ else
+ declare -x -r vyatta_datarootdir=$vyatta_prefix/share
+ declare -x -r vyos_datarootdir=$vyatta_prefix/share
+ fi
+ fi
+ if test -z "$vyatta_bindir" ; then
+ if test -n "${exec_prefix}/bin" ; then
+ declare -x -r vyatta_bindir=${exec_prefix}/bin
+ else
+ declare -x -r vyatta_bindir=$vyatta_exec_prefix/bin
+ fi
+ fi
+ if test -z "$vyatta_sbindir" ; then
+ if test -n "${exec_prefix}/sbin" ; then
+ declare -x -r vyatta_sbindir=${exec_prefix}/sbin
+ else
+ declare -x -r vyatta_sbindir=$vyatta_exec_prefix/sbin
+ fi
+ fi
+ if test -z "$vyatta_libdir" ; then
+ if test -n "${exec_prefix}/lib" ; then
+ declare -x -r vyatta_libdir=${exec_prefix}/lib
+ declare -x -r vyos_libdir=${exec_prefix}/lib
+ else
+ declare -x -r vyatta_libdir=$vyatta_exec_prefix/lib
+ declare -x -r vyos_libdir=$vyatta_exec_prefix/lib
+ fi
+ fi
+ if test -z "$vyatta_libexecdir" ; then
+ if test -n "${exec_prefix}/libexec" ; then
+ declare -x -r vyatta_libexecdir=${exec_prefix}/libexec
+ else
+ declare -x -r vyatta_libexecdir=$vyatta_exec_prefix/libexec
+ fi
+ fi
+ if test -z "$vyatta_datadir" ; then
+ if test -n "${datarootdir}" ; then
+ declare -x -r vyatta_datadir=${datarootdir}
+ declare -x -r vyos_datadir=${datarootdir}
+ else
+ declare -x -r vyatta_datadir=$vyatta_datarootdir
+ declare -x -r vyos_datadir=$vyatta_datarootdir
+ fi
+ fi
+ if test -z "$vyatta_htmldir" ; then
+ if test -n "${docdir}" ; then
+ declare -x -r vyatta_htmldir=${docdir}
+ else
+ declare -x -r vyatta_htmldir=$vyatta_datarootdir/html
+ fi
+ fi
+ if test -z "$vyatta_infodir" ; then
+ if test -n "${prefix}/share/info" ; then
+ declare -x -r vyatta_infodir=${prefix}/share/info
+ else
+ declare -x -r vyatta_infodir=$vyatta_datarootdir/info
+ fi
+ fi
+ if test -z "$vyatta_mandir" ; then
+ if test -n "${prefix}/share/man" ; then
+ declare -x -r vyatta_htmldir=${prefix}/share/man
+ else
+ declare -x -r vyatta_htmldir=$vyatta_datarootdir/man
+ fi
+ fi
+ if test -z "$vyatta_localedir" ; then
+ if test -n "${datarootdir}/locale" ; then
+ declare -x -r vyatta_localedir=${datarootdir}/locale
+ else
+ declare -x -r vyatta_localedir=$vyatta_datarootdir/locale
+ fi
+ fi
+ if test -z "$vyatta_localstatedir" ; then
+ if test -n "${prefix}/var" ; then
+ declare -x -r vyatta_localstatedir=${prefix}/var
+ else
+ declare -x -r vyatta_localstatedir=$vyatta_prefix/var
+ fi
+ fi
+ if test -z "$vyatta_sharedstatedir" ; then
+ if test -n "${prefix}/com" ; then
+ declare -x -r vyatta_sharedstatedir=${prefix}/com
+ else
+ declare -x -r vyatta_sharedstatedir=$vyatta_prefix/com
+ fi
+ fi
+ if test -z "$vyatta_sysconfdir" ; then
+ if test -n "${prefix}/etc" ; then
+ declare -x -r vyatta_sysconfdir=${prefix}/etc
+ else
+ declare -x -r vyatta_sysconfdir=$vyatta_prefix/etc
+ fi
+ fi
+ if test -z "$vyatta_op_templates" ; then
+ declare -x -r vyatta_op_templates=$vyatta_datadir/vyatta-op/templates
+ declare -x -r vyos_op_templates=$vyatta_datadir/vyatta-op/templates
+ fi
+ if test -z "$vyatta_cfg_templates" ; then
+ declare -x -r vyatta_cfg_templates=$vyatta_datadir/vyatta-cfg/templates
+ declare -x -r vyos_cfg_templates=$vyatta_datadir/vyatta-cfg/templates
+ fi
+ if test -z "$vyatta_configdir" ; then
+ declare -x -r vyatta_configdir=$vyatta_prefix/config
+ declare -x -r vyos_configdir=$vyatta_prefix/config
+ fi
+
+ for var in prefix exec_prefix datarootdir ; do
+ eval test -n \"\$_vyatta_save_$var\" \&\& $var=\$_vyatta_save_$var
+ done
+
+ # It's not like we do, or should support installing VyOS at a different prefix
+ declare -x -r vyos_libexec_dir=/usr/libexec/vyos
+ declare -x -r vyos_bin_dir=/usr/bin
+ declare -x -r vyos_sbin_dir=/usr/sbin
+ declare -x -r vyos_share_dir=/usr/share
+
+ if test -z "$vyos_conf_scripts_dir" ; then
+ declare -x -r vyos_conf_scripts_dir=$vyos_libexec_dir/conf_mode
+ fi
+ if test -z "$vyos_op_scripts_dir" ; then
+ declare -x -r vyos_op_scripts_dir=$vyos_libexec_dir/op_mode
+ fi
+ if test -z "$vyos_completion_dir" ; then
+ declare -x -r vyos_completion_dir=$vyos_libexec_dir/completion
+ fi
+ if test -z "$vyos_validators_dir" ; then
+ declare -x -r vyos_validators_dir=$vyos_libexec_dir/validators
+ fi
+ if test -z "$vyos_data_dir" ; then
+ declare -x -r vyos_data_dir=$vyos_share_dir/vyos
+ fi
+ if test -z "$vyos_persistence_dir" ; then
+ UNION_NAME=$(cat /proc/cmdline | sed -e s+^.*vyos-union=++ | sed -e 's/ .*$//')
+ declare -x -r vyos_persistence_dir="/usr/lib/live/mount/persistence/${UNION_NAME}"
+ fi
+ if test -z "$vyos_rootfs_dir" ; then
+ ROOTFS=$(mount -t squashfs | grep loop0 | cut -d' ' -f3)
+ declare -x -r vyos_rootfs_dir="${ROOTFS}"
+ fi
+ if test -z "$VRF" ; then
+ VRF=$(ip vrf identify)
+ [ -n "$VRF" ] && declare -x -r VRF="${VRF}"
+ fi
+ if test -z "$NETNS" ; then
+ NETNS=$(ip netns identify)
+ [ -n "$NETNS" ] && declare -x -r NETNS="${NETNS}"
+ fi
+
+} 2>/dev/null || :
+
+[ -r /etc/default/vyatta-cfg ] && source /etc/default/vyatta-cfg
+
+[ -r /etc/default/vyatta-local-env ] && source /etc/default/vyatta-local-env
+
+### Local Variables:
+### mode: shell-script
+### End:
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging b/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging
new file mode 100644
index 0000000..121fb21
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/01-vyos-logging
@@ -0,0 +1,20 @@
+# enable logging
+LOG_ENABLE="yes"
+LOG_STDERR="no"
+LOG_TAG="dhclient-script-vyos"
+
+function logmsg () {
+ # log message to journal
+ case $1 in
+ error) LOG_PRIO="daemon.err" ;;
+ info) LOG_PRIO="daemon.info" ;;
+ esac
+
+ if [ "${LOG_ENABLE}" == "yes" ] ; then
+ if [ "${LOG_STDERR}" == "yes" ] ; then
+ /usr/bin/logger -e --id=$$ -s -p ${LOG_PRIO} -t ${LOG_TAG} "${@:2}"
+ else
+ /usr/bin/logger -e --id=$$ -p ${LOG_PRIO} -t ${LOG_TAG} "${@:2}"
+ fi
+ fi
+}
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient b/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient
new file mode 100644
index 0000000..ae6bf9f
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/02-vyos-stopdhclient
@@ -0,0 +1,38 @@
+# skip all of this if dhclient-script running by stop command defined below
+if [ -z ${CONTROLLED_STOP} ] ; then
+ # stop dhclient for this interface, if it is not current one
+ # get PID for current dhclient
+ current_dhclient=`ps --no-headers --format ppid --pid $$ | awk '{ print \$1 }'`
+
+ # get PID for master process (current can be a fork)
+ master_dhclient=`ps --no-headers --format ppid --pid $current_dhclient | awk '{ print \$1 }'`
+
+ # get IP version for current dhclient
+ ipversion_arg=`ps --no-headers --format args --pid $current_dhclient | awk 'match(\$0, /\s-(4|6)\s/, IPV) { printf("%s", IPV[1]) }'`
+
+ # get list of all dhclient running for current interface
+ if [[ $ipversion_arg == "6" ]]; then
+ dhclients_pids=(`pgrep -f "dhclient.*\s-6\s.*\s$interface(\s|$)"`)
+ else
+ dhclients_pids=(`ps --no-headers --format pid,args -C dhclient | awk "{ if(match(\\$0, /\s${interface}(\s|$)/) && !match(\\$0, /\s-6\s/)) printf(\"%s\n\", \\$1) }"`)
+ fi
+
+ logmsg info "Current dhclient PID: $current_dhclient, Parent PID: $master_dhclient, IP version: $ipversion_arg, All dhclients for interface $interface: ${dhclients_pids[@]}"
+ # stop all dhclients for current interface, except current one
+ for dhclient in ${dhclients_pids[@]}; do
+ if ([ $dhclient -ne $current_dhclient ] && [ $dhclient -ne $master_dhclient ]); then
+ # get path to PID-file of dhclient process
+ local dhclient_pidfile=`ps --no-headers --format args --pid $dhclient | awk 'match(\$0, ".*-pf (/.*pid) .*", PF) { print PF[1] }'`
+ # get path to lease-file of dhclient process
+ local dhclient_leasefile=`ps --no-headers --format args --pid $dhclient | awk 'match(\$0, ".*-lf (/\\\S*leases) .*", LF) { print LF[1] }'`
+ # stop dhclient with native command - this will run dhclient-script with correct reason unlike simple kill
+ logmsg info "Stopping dhclient with PID: ${dhclient}, PID file: ${dhclient_pidfile}, Leases file: ${dhclient_leasefile}"
+ if [[ -e $dhclient_pidfile ]]; then
+ dhclient -e CONTROLLED_STOP=yes -x -pf $dhclient_pidfile -lf $dhclient_leasefile
+ else
+ logmsg error "PID file $dhclient_pidfile does not exists, killing dhclient with SIGTERM signal"
+ kill -s 15 ${dhclient}
+ fi
+ fi
+ done
+fi
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper
new file mode 100644
index 0000000..2a1c5a7
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper
@@ -0,0 +1,110 @@
+# redefine ip command to use FRR when it is available
+
+# default route distance
+IF_METRIC=${IF_METRIC:-210}
+
+# Check if interface is inside a VRF
+VRF_OPTION=$(/usr/sbin/ip -j -d link show ${interface} | awk '{if(match($0, /.*"master":"(\w+)".*"info_slave_kind":"vrf"/, IFACE_DETAILS)) printf("vrf %s", IFACE_DETAILS[1])}')
+
+# get status of FRR
+function frr_alive () {
+ /usr/lib/frr/watchfrr.sh all_status
+ if [ "$?" -eq "0" ] ; then
+ logmsg info "FRR status: running"
+ return 0
+ else
+ logmsg info "FRR status: not running"
+ return 1
+ fi
+}
+
+# convert ip route command to vtysh
+function iptovtysh () {
+ # prepare variables for vtysh command
+ local VTYSH_ACTION=$3
+ local VTYSH_NETADDR=""
+ local VTYSH_GATEWAY=""
+ local VTYSH_DEV=""
+ local VTYSH_TAG="210"
+ local VTYSH_DISTANCE=$IF_METRIC
+ # convert default route to 0.0.0.0/0
+ if [ "$4" == "default" ] ; then
+ VTYSH_NETADDR="0.0.0.0/0"
+ else
+ VTYSH_NETADDR=$4
+ fi
+ # add /32 to ip addresses without netmasks
+ if [[ ! $VTYSH_NETADDR =~ ^.*/[[:digit:]]+$ ]] ; then
+ VTYSH_NETADDR="$VTYSH_NETADDR/32"
+ fi
+ shift 4
+ # get gateway address
+ if [ "$1" == "via" ] ; then
+ VTYSH_GATEWAY=$2
+ shift 2
+ fi
+ # get device name
+ if [ "$1" == "dev" ]; then
+ VTYSH_DEV=$2
+ shift 2
+ fi
+ # get distance
+ if [ "$1" == "metric" ]; then
+ VTYSH_DISTANCE=$2
+ shift 2
+ fi
+
+ VTYSH_CMD="ip route $VTYSH_NETADDR $VTYSH_GATEWAY $VTYSH_DEV tag $VTYSH_TAG $VTYSH_DISTANCE $VRF_OPTION"
+
+ # delete route if the command is "del"
+ if [ "$VTYSH_ACTION" == "del" ] ; then
+ VTYSH_CMD="no $VTYSH_CMD"
+ fi
+ logmsg info "Converted vtysh command: \"$VTYSH_CMD\""
+}
+
+# delete the same route from kernel before adding new one
+function delroute () {
+ logmsg info "Checking if the route presented in kernel: $@ $VRF_OPTION"
+ if /usr/sbin/ip route show $@ $VRF_OPTION | grep -qx "$1 " ; then
+ logmsg info "Deleting IP route: \"/usr/sbin/ip route del $@ $VRF_OPTION\""
+ /usr/sbin/ip route del $@ $VRF_OPTION
+ fi
+}
+
+# try to communicate with vtysh
+function vtysh_conf () {
+ # perform 10 attempts with 1 second delay for retries
+ for i in {1..10} ; do
+ if vtysh -c "conf t" -c "$1" ; then
+ logmsg info "Command was executed successfully via vtysh: \"$1\""
+ return 0
+ else
+ logmsg info "Failed to send command to vtysh, retrying in 1 second"
+ sleep 1
+ fi
+ done
+ logmsg error "Failed to execute command via vtysh after 10 attempts: \"$1\""
+ return 1
+}
+
+# replace ip command with this wrapper
+function ip () {
+ # pass comand to system `ip` if this is not related to routes change
+ if [ "$2" != "route" ] ; then
+ logmsg info "Passing command to /usr/sbin/ip: \"$@\""
+ /usr/sbin/ip $@
+ else
+ # if we want to work with routes, try to use FRR first
+ if frr_alive ; then
+ delroute ${@:4}
+ iptovtysh $@
+ logmsg info "Sending command to vtysh"
+ vtysh_conf "$VTYSH_CMD"
+ else
+ # add ip route to kernel
+ logmsg info "Modifying routes in kernel: \"/usr/sbin/ip $@\""
+ /usr/sbin/ip $@ $VRF_OPTION
+ fi
+ fi
+}
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf
new file mode 100644
index 0000000..9a8a53b
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/04-vyos-resolvconf
@@ -0,0 +1,32 @@
+# modified make_resolv_conf() for VyOS
+# should be used only if vyos-hostsd is running
+
+if /usr/bin/systemctl -q is-active vyos-hostsd; then
+ make_resolv_conf() {
+ hostsd_client="/usr/bin/vyos-hostsd-client"
+ hostsd_changes=
+
+ if [ -n "$new_domain_name" ]; then
+ logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client"
+ $hostsd_client --delete-search-domains --tag "dhcp-$interface"
+ logmsg info "Adding domain name \"$new_domain_name\" as search domain with tag \"dhcp-$interface\" via vyos-hostsd-client"
+ $hostsd_client --add-search-domains "$new_domain_name" --tag "dhcp-$interface"
+ hostsd_changes=y
+ fi
+
+ if [ -n "$new_domain_name_servers" ]; then
+ logmsg info "Deleting nameservers with tag \"dhcp-$interface\" via vyos-hostsd-client"
+ $hostsd_client --delete-name-servers --tag "dhcp-$interface"
+ logmsg info "Adding nameservers \"$new_domain_name_servers\" with tag \"dhcp-$interface\" via vyos-hostsd-client"
+ $hostsd_client --add-name-servers $new_domain_name_servers --tag "dhcp-$interface"
+ hostsd_changes=y
+ fi
+
+ if [ $hostsd_changes ]; then
+ logmsg info "Applying changes via vyos-hostsd-client"
+ $hostsd_client --apply
+ else
+ logmsg info "No changes to apply via vyos-hostsd-client"
+ fi
+ }
+fi
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/05-vyos-mtureplace b/src/etc/dhcp/dhclient-enter-hooks.d/05-vyos-mtureplace
new file mode 100644
index 0000000..4a08765
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/05-vyos-mtureplace
@@ -0,0 +1,38 @@
+# replace MTU with value from configuration
+
+# get MTU value via Python
+# as configuration is not available to cli-shell-api at the first boot, we must use vyos.config, which contain workaround for this, instead clean shell
+function get_mtu {
+python3 - <<PYEND
+from vyos.config import Config
+import os
+import re
+
+# check if interface is not VLAN and fix name if necessary
+interface_name = os.getenv('interface', '')
+regex_filter = re.compile('^(?P<interface>\w+)\.(?P<vid>\d+)$')
+if regex_filter.search(interface_name):
+ iface = regex_filter.search(interface_name).group('interface')
+ vid = regex_filter.search(interface_name).group('vid')
+ interface_name = "{} vif {}".format(iface, vid)
+
+# initialize config
+config = Config()
+if config.exists('interfaces'):
+ iface_types = config.list_nodes('interfaces')
+ for iface_type in iface_types:
+ # check if configuration contain MTU value for interface and return (print) it
+ if config.exists("interfaces {} {} mtu".format(iface_type, interface_name)):
+ print(format(config.return_value("interfaces {} {} mtu".format(iface_type, interface_name))))
+PYEND
+}
+
+# check if DHCP server return MTU value
+if [ -n "$new_interface_mtu" ]; then
+ # try to get MTU from config and replace original one
+ configured_mtu="$(get_mtu)"
+ if [[ -n $configured_mtu ]] ; then
+ logmsg info "Replacing MTU value for $interface with preconfigured one: $configured_mtu"
+ new_interface_mtu="$configured_mtu"
+ fi
+fi
diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks b/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks
new file mode 100644
index 0000000..570758b
--- /dev/null
+++ b/src/etc/dhcp/dhclient-enter-hooks.d/99-run-user-hooks
@@ -0,0 +1,5 @@
+#!/bin/bash
+DHCP_PRE_HOOKS="/config/scripts/dhcp-client/pre-hooks.d/"
+if [ -d "${DHCP_PRE_HOOKS}" ] ; then
+ run_hookdir "${DHCP_PRE_HOOKS}"
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup
new file mode 100644
index 0000000..da1bda1
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/01-vyos-cleanup
@@ -0,0 +1,115 @@
+##
+## VyOS cleanup
+##
+# NOTE: here we use 'ip' wrapper, therefore a route will be actually deleted via /usr/sbin/ip or vtysh, according to the system state
+hostsd_client="/usr/bin/vyos-hostsd-client"
+hostsd_changes=
+# check vyos-hostsd status
+/usr/bin/systemctl -q is-active vyos-hostsd
+hostsd_status=$?
+
+if [[ $reason =~ ^(EXPIRE|FAIL|RELEASE|STOP)$ ]]; then
+ if [[ $hostsd_status -eq 0 ]]; then
+ # delete search domains and nameservers via vyos-hostsd
+ logmsg info "Deleting search domains with tag \"dhcp-$interface\" via vyos-hostsd-client"
+ $hostsd_client --delete-search-domains --tag "dhcp-$interface"
+ logmsg info "Deleting nameservers with tag \"dhcp-${interface}\" via vyos-hostsd-client"
+ $hostsd_client --delete-name-servers --tag "dhcp-${interface}"
+ hostsd_changes=y
+ fi
+
+ if_metric="$IF_METRIC"
+
+ # try to delete default ip route
+ for router in $old_routers; do
+ # check if we are bound to a VRF
+ local vrf_name=$(basename /sys/class/net/${interface}/upper_* | sed -e 's/upper_//')
+ if [ "$vrf_name" != "*" ]; then
+ vrf="vrf $vrf_name"
+ fi
+
+ logmsg info "Deleting default route: via $router dev ${interface} ${if_metric:+metric $if_metric} ${vrf}"
+ ip -4 route del default via $router dev ${interface} ${if_metric:+metric $if_metric} ${vrf}
+
+ if_metric=$((if_metric+1))
+ done
+
+ # delete rfc3442 routes
+ if [ -n "$old_rfc3442_classless_static_routes" ]; then
+ set -- $old_rfc3442_classless_static_routes
+ while [ $# -gt 0 ]; do
+ net_length=$1
+ via_arg=''
+ case $net_length in
+ 32|31|30|29|28|27|26|25)
+ if [ $# -lt 9 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.${4}.${5}"
+ gateway="${6}.${7}.${8}.${9}"
+ shift 9
+ ;;
+ 24|23|22|21|20|19|18|17)
+ if [ $# -lt 8 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.${4}.0"
+ gateway="${5}.${6}.${7}.${8}"
+ shift 8
+ ;;
+ 16|15|14|13|12|11|10|9)
+ if [ $# -lt 7 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.0.0"
+ gateway="${4}.${5}.${6}.${7}"
+ shift 7
+ ;;
+ 8|7|6|5|4|3|2|1)
+ if [ $# -lt 6 ]; then
+ return 1
+ fi
+ net_address="${2}.0.0.0"
+ gateway="${3}.${4}.${5}.${6}"
+ shift 6
+ ;;
+ 0) # default route
+ if [ $# -lt 5 ]; then
+ return 1
+ fi
+ net_address="0.0.0.0"
+ gateway="${2}.${3}.${4}.${5}"
+ shift 5
+ ;;
+ *) # error
+ return 1
+ ;;
+ esac
+ # take care of link-local routes
+ if [ "${gateway}" != '0.0.0.0' ]; then
+ via_arg="via ${gateway}"
+ fi
+ # delete route (ip detects host routes automatically)
+ ip -4 route del "${net_address}/${net_length}" \
+ ${via_arg} dev "${interface}" >/dev/null 2>&1
+ done
+ fi
+fi
+
+if [[ $reason =~ ^(EXPIRE6|RELEASE6|STOP6)$ ]]; then
+ if [[ $hostsd_status -eq 0 ]]; then
+ # delete search domains and nameservers via vyos-hostsd
+ logmsg info "Deleting search domains with tag \"dhcpv6-$interface\" via vyos-hostsd-client"
+ $hostsd_client --delete-search-domains --tag "dhcpv6-$interface"
+ logmsg info "Deleting nameservers with tag \"dhcpv6-${interface}\" via vyos-hostsd-client"
+ $hostsd_client --delete-name-servers --tag "dhcpv6-${interface}"
+ hostsd_changes=y
+ fi
+fi
+
+if [ $hostsd_changes ]; then
+ logmsg info "Applying changes via vyos-hostsd-client"
+ $hostsd_client --apply
+else
+ logmsg info "No changes to apply via vyos-hostsd-client"
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/02-vyos-dhcp-renew-rfc3442 b/src/etc/dhcp/dhclient-exit-hooks.d/02-vyos-dhcp-renew-rfc3442
new file mode 100644
index 0000000..9202fe7
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/02-vyos-dhcp-renew-rfc3442
@@ -0,0 +1,148 @@
+# support for RFC3442 routes in DHCP RENEW
+
+function convert_to_cidr () {
+ cidr=""
+ set -- $1
+ while [ $# -gt 0 ]; do
+ net_length=$1
+
+ case $net_length in
+ 32|31|30|29|28|27|26|25)
+ if [ $# -lt 9 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.${4}.${5}"
+ gateway="${6}.${7}.${8}.${9}"
+ shift 9
+ ;;
+ 24|23|22|21|20|19|18|17)
+ if [ $# -lt 8 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.${4}.0"
+ gateway="${5}.${6}.${7}.${8}"
+ shift 8
+ ;;
+ 16|15|14|13|12|11|10|9)
+ if [ $# -lt 7 ]; then
+ return 1
+ fi
+ net_address="${2}.${3}.0.0"
+ gateway="${4}.${5}.${6}.${7}"
+ shift 7
+ ;;
+ 8|7|6|5|4|3|2|1)
+ if [ $# -lt 6 ]; then
+ return 1
+ fi
+ net_address="${2}.0.0.0"
+ gateway="${3}.${4}.${5}.${6}"
+ shift 6
+ ;;
+ 0) # default route
+ if [ $# -lt 5 ]; then
+ return 1
+ fi
+ net_address="0.0.0.0"
+ gateway="${2}.${3}.${4}.${5}"
+ shift 5
+ ;;
+ *) # error
+ return 1
+ ;;
+ esac
+
+ cidr+="${net_address}/${net_length}:${gateway} "
+ done
+}
+
+# main script starts here
+
+RUN="yes"
+
+if [ "$RUN" = "yes" ]; then
+ convert_to_cidr "$old_rfc3442_classless_static_routes"
+ old_cidr=$cidr
+ convert_to_cidr "$new_rfc3442_classless_static_routes"
+ new_cidr=$cidr
+
+ if [ "$reason" = "RENEW" ]; then
+ if [ "$new_rfc3442_classless_static_routes" != "$old_rfc3442_classless_static_routes" ]; then
+ logmsg info "RFC3442 route change detected, old_routes: $old_rfc3442_classless_static_routes"
+ logmsg info "RFC3442 route change detected, new_routes: $new_rfc3442_classless_static_routes"
+ if [ -z "$new_rfc3442_classless_static_routes" ]; then
+ # delete all routes from the old_rfc3442_classless_static_routes
+ for route in $old_cidr; do
+ network=$(printf "${route}" | awk -F ":" '{print $1}')
+ gateway=$(printf "${route}" | awk -F ":" '{print $2}')
+ # take care of link-local routes
+ if [ "${gateway}" != '0.0.0.0' ]; then
+ via_arg="via ${gateway}"
+ else
+ via_arg=""
+ fi
+ ip -4 route del "${network}" "${via_arg}" dev "${interface}" >/dev/null 2>&1
+ done
+ elif [ -z "$old_rfc3442_classless_static_routes" ]; then
+ # add all routes from the new_rfc3442_classless_static_routes
+ for route in $new_cidr; do
+ network=$(printf "${route}" | awk -F ":" '{print $1}')
+ gateway=$(printf "${route}" | awk -F ":" '{print $2}')
+ # take care of link-local routes
+ if [ "${gateway}" != '0.0.0.0' ]; then
+ via_arg="via ${gateway}"
+ else
+ via_arg=""
+ fi
+ ip -4 route add "${network}" "${via_arg}" dev "${interface}" >/dev/null 2>&1
+ done
+ else
+ # update routes
+ # delete old
+ for old_route in $old_cidr; do
+ match="false"
+ for new_route in $new_cidr; do
+ if [[ "$old_route" == "$new_route" ]]; then
+ match="true"
+ break
+ fi
+ done
+ if [[ "$match" == "false" ]]; then
+ # delete old_route
+ network=$(printf "${old_route}" | awk -F ":" '{print $1}')
+ gateway=$(printf "${old_route}" | awk -F ":" '{print $2}')
+ # take care of link-local routes
+ if [ "${gateway}" != '0.0.0.0' ]; then
+ via_arg="via ${gateway}"
+ else
+ via_arg=""
+ fi
+ ip -4 route del "${network}" "${via_arg}" dev "${interface}" >/dev/null 2>&1
+ fi
+ done
+ # add new
+ for new_route in $new_cidr; do
+ match="false"
+ for old_route in $old_cidr; do
+ if [[ "$new_route" == "$old_route" ]]; then
+ match="true"
+ break
+ fi
+ done
+ if [[ "$match" == "false" ]]; then
+ # add new_route
+ network=$(printf "${new_route}" | awk -F ":" '{print $1}')
+ gateway=$(printf "${new_route}" | awk -F ":" '{print $2}')
+ # take care of link-local routes
+ if [ "${gateway}" != '0.0.0.0' ]; then
+ via_arg="via ${gateway}"
+ else
+ via_arg=""
+ fi
+ ip -4 route add "${network}" "${via_arg}" dev "${interface}" >/dev/null 2>&1
+ fi
+ done
+ fi
+ fi
+ fi
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook
new file mode 100644
index 0000000..d5e6462
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/03-vyos-dhclient-hook
@@ -0,0 +1,46 @@
+#!/bin/sh
+
+# Author: Stig Thormodsrud <stig@vyatta.com>
+# Date: 2007
+# Description: dhcp client hook
+
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2006, 2007, 2008 Vyatta, Inc.
+# All Rights Reserved.
+# **** End License ****
+
+# To enable this script set the following variable to "yes"
+RUN="yes"
+
+proto=""
+if [[ $reason =~ ^(REBOOT6|INIT6|EXPIRE6|RELEASE6|STOP6|INFORM6|BOUND6|REBIND6|DELEGATED6)$ ]]; then
+ proto="v6"
+fi
+
+if [ "$RUN" = "yes" ]; then
+ BASE_PATH=$(python3 -c "from vyos.defaults import directories; print(directories['isc_dhclient_dir'])")
+ mkdir -p ${BASE_PATH}
+ LOG=${BASE_PATH}/dhclient_"$interface"."$proto"lease
+ echo `date` > $LOG
+
+ for i in reason interface new_expiry new_dhcp_lease_time medium \
+ alias_ip_address new_ip_address new_broadcast_address \
+ new_subnet_mask new_domain_name new_network_number \
+ new_domain_name_servers new_routers new_static_routes \
+ new_dhcp_server_identifier new_dhcp_message_type \
+ old_ip_address old_subnet_mask old_domain_name \
+ old_domain_name_servers old_routers \
+ old_static_routes; do
+ echo $i=\'${!i}\' >> $LOG
+ done
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks b/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks
new file mode 100644
index 0000000..910b586
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks
@@ -0,0 +1,5 @@
+#!/bin/bash
+DHCP_POST_HOOKS="/config/scripts/dhcp-client/post-hooks.d/"
+if [ -d "${DHCP_POST_HOOKS}" ] ; then
+ run_hookdir "${DHCP_POST_HOOKS}"
+fi
diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook
new file mode 100644
index 0000000..57f8030
--- /dev/null
+++ b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook
@@ -0,0 +1,45 @@
+#!/bin/bash
+#
+# Copyright (C) 2021 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/>.
+
+DHCP_HOOK_IFLIST="/tmp/ipsec_dhcp_interfaces"
+
+if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then
+ return 0
+fi
+
+# Re-generate the config on the following events:
+# - BOUND: always re-generate
+# - RENEW: re-generate if the IP address changed
+# - REBIND: re-generate if the IP address changed
+if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then
+ if [ "$old_ip_address" == "$new_ip_address" ]; then
+ return 0
+ fi
+elif [ "$reason" != "BOUND" ]; then
+ return 0
+fi
+
+# Best effort wait for any active commit to finish
+sudo python3 - <<PYEND
+from vyos.utils.commit import wait_for_commit_lock
+
+if __name__ == '__main__':
+ wait_for_commit_lock()
+ exit(0)
+PYEND
+
+# Now re-generate the config
+sudo /usr/libexec/vyos/conf_mode/vpn_ipsec.py
diff --git a/src/etc/ipsec.d/key-pair.template b/src/etc/ipsec.d/key-pair.template
new file mode 100644
index 0000000..56be975
--- /dev/null
+++ b/src/etc/ipsec.d/key-pair.template
@@ -0,0 +1,67 @@
+[ req ]
+ default_bits = 2048
+ default_keyfile = privkey.pem
+ distinguished_name = req_distinguished_name
+ string_mask = utf8only
+ attributes = req_attributes
+ dirstring_type = nobmp
+# SHA-1 is deprecated, so use SHA-2 instead.
+ default_md = sha256
+# Extension to add when the -x509 option is used.
+ x509_extensions = v3_ca
+
+[ req_distinguished_name ]
+ countryName = Country Name (2 letter code)
+ countryName_min = 2
+ countryName_max = 2
+ ST = State Name
+ localityName = Locality Name (eg, city)
+ organizationName = Organization Name (eg, company)
+ organizationalUnitName = Organizational Unit Name (eg, department)
+ commonName = Common Name (eg, Device hostname)
+ commonName_max = 64
+ emailAddress = Email Address
+ emailAddress_max = 40
+[ req_attributes ]
+ challengePassword = A challenge password (optional)
+ challengePassword_min = 4
+ challengePassword_max = 20
+[ v3_ca ]
+ subjectKeyIdentifier=hash
+ authorityKeyIdentifier=keyid:always,issuer:always
+ basicConstraints = critical, CA:true
+ keyUsage = critical, digitalSignature, cRLSign, keyCertSign
+[ v3_intermediate_ca ]
+# Extensions for a typical intermediate CA (`man x509v3_config`).
+ subjectKeyIdentifier = hash
+ authorityKeyIdentifier = keyid:always,issuer
+ basicConstraints = critical, CA:true, pathlen:0
+ keyUsage = critical, digitalSignature, cRLSign, keyCertSign
+[ usr_cert ]
+# Extensions for client certificates (`man x509v3_config`).
+ basicConstraints = CA:FALSE
+ nsCertType = client, email
+ nsComment = "OpenSSL Generated Client Certificate"
+ subjectKeyIdentifier = hash
+ authorityKeyIdentifier = keyid,issuer
+ keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment
+ extendedKeyUsage = clientAuth, emailProtection
+[ server_cert ]
+# Extensions for server certificates (`man x509v3_config`).
+ basicConstraints = CA:FALSE
+ nsCertType = server
+ nsComment = "OpenSSL Generated Server Certificate"
+ subjectKeyIdentifier = hash
+ authorityKeyIdentifier = keyid,issuer:always
+ keyUsage = critical, digitalSignature, keyEncipherment
+ extendedKeyUsage = serverAuth
+[ crl_ext ]
+# Extension for CRLs (`man x509v3_config`).
+ authorityKeyIdentifier=keyid:always
+[ ocsp ]
+# Extension for OCSP signing certificates (`man ocsp`).
+ basicConstraints = CA:FALSE
+ subjectKeyIdentifier = hash
+ authorityKeyIdentifier = keyid,issuer
+ keyUsage = critical, digitalSignature
+ extendedKeyUsage = critical, OCSPSigning
diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down
new file mode 100644
index 0000000..e1765ae
--- /dev/null
+++ b/src/etc/ipsec.d/vti-up-down
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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/>.
+
+# Script called up strongswan to bring the VTI interface up/down based on
+# the state of the IPSec tunnel. Called as vti_up_down vti_intf_name
+
+import os
+import sys
+
+from syslog import syslog
+from syslog import openlog
+from syslog import LOG_PID
+from syslog import LOG_INFO
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.configdict import get_interface_dict
+from vyos.utils.commit import wait_for_commit_lock
+from vyos.utils.process import call
+from vyos.utils.vti_updown_db import open_vti_updown_db_for_update
+
+def supply_interface_dict(interface):
+ # Lazy-load the running config on first invocation
+ try:
+ conf = supply_interface_dict.cached_config
+ except AttributeError:
+ conf = ConfigTreeQuery()
+ supply_interface_dict.cached_config = conf
+
+ _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface)
+ return vti
+
+if __name__ == '__main__':
+ verb = os.getenv('PLUTO_VERB')
+ connection = os.getenv('PLUTO_CONNECTION')
+ interface = sys.argv[1]
+
+ if verb.endswith('-v6'):
+ protocol = 'v6'
+ else:
+ protocol = 'v4'
+
+ openlog(ident=f'vti-up-down', logoption=LOG_PID, facility=LOG_INFO)
+ syslog(f'Interface {interface} {verb} {connection}')
+
+ wait_for_commit_lock()
+
+ if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']:
+ with open_vti_updown_db_for_update() as db:
+ db.add(interface, connection, protocol)
+ db.commit(supply_interface_dict)
+ elif verb in ['down-client', 'down-client-v6', 'down-host', 'down-host-v6']:
+ with open_vti_updown_db_for_update() as db:
+ db.remove(interface, connection, protocol)
+ db.commit(supply_interface_dict)
diff --git a/src/etc/logrotate.d/conntrackd b/src/etc/logrotate.d/conntrackd
new file mode 100644
index 0000000..b0b09de
--- /dev/null
+++ b/src/etc/logrotate.d/conntrackd
@@ -0,0 +1,9 @@
+/var/log/conntrackd-stats.log {
+ weekly
+ rotate 2
+ missingok
+
+ postrotate
+ systemctl restart conntrackd.service > /dev/null
+ endscript
+}
diff --git a/src/etc/logrotate.d/vyos-atop b/src/etc/logrotate.d/vyos-atop
new file mode 100644
index 0000000..0c8359c
--- /dev/null
+++ b/src/etc/logrotate.d/vyos-atop
@@ -0,0 +1,20 @@
+/var/log/atop/atop.log {
+ daily
+ dateext
+ dateformat _%Y-%m-%d_%H-%M-%S
+ maxsize 10M
+ missingok
+ nocompress
+ nocreate
+ nomail
+ rotate 10
+ prerotate
+ # stop the service
+ systemctl stop atop.service
+ endscript
+ postrotate
+ # start atop service again
+ systemctl start atop.service
+ endscript
+}
+
diff --git a/src/etc/logrotate.d/vyos-rsyslog b/src/etc/logrotate.d/vyos-rsyslog
new file mode 100644
index 0000000..3c087b9
--- /dev/null
+++ b/src/etc/logrotate.d/vyos-rsyslog
@@ -0,0 +1,12 @@
+/var/log/messages {
+ create
+ missingok
+ nomail
+ notifempty
+ rotate 10
+ size 1M
+ postrotate
+ # inform rsyslog service about rotation
+ /usr/lib/rsyslog/rsyslog-rotate
+ endscript
+}
diff --git a/src/etc/modprobe.d/ifb.conf b/src/etc/modprobe.d/ifb.conf
new file mode 100644
index 0000000..2dcfb6a
--- /dev/null
+++ b/src/etc/modprobe.d/ifb.conf
@@ -0,0 +1 @@
+options ifb numifbs=0
diff --git a/src/etc/modprobe.d/openvpn.conf b/src/etc/modprobe.d/openvpn.conf
new file mode 100644
index 0000000..a9259fe
--- /dev/null
+++ b/src/etc/modprobe.d/openvpn.conf
@@ -0,0 +1 @@
+blacklist ovpn-dco-v2
diff --git a/src/etc/netplug/linkup.d/vyos-python-helper b/src/etc/netplug/linkup.d/vyos-python-helper
new file mode 100644
index 0000000..9c59c58
--- /dev/null
+++ b/src/etc/netplug/linkup.d/vyos-python-helper
@@ -0,0 +1,4 @@
+#!/bin/sh
+PYTHON3=$(which python3)
+# Call the real python script and forward commandline arguments
+$PYTHON3 /etc/netplug/vyos-netplug-dhcp-client "${@:1}"
diff --git a/src/etc/netplug/netplug b/src/etc/netplug/netplug
new file mode 100644
index 0000000..60b65e8
--- /dev/null
+++ b/src/etc/netplug/netplug
@@ -0,0 +1,41 @@
+#!/bin/sh
+#
+# Copyright 2023 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/>.
+
+dev="$1"
+action="$2"
+
+case "$action" in
+in)
+ run-parts --arg $dev --arg in /etc/netplug/linkup.d
+ ;;
+out)
+ run-parts --arg $dev --arg out /etc/netplug/linkdown.d
+ ;;
+
+# probe loads and initialises the driver for the interface and brings the
+# interface into the "up" state, so that it can generate netlink(7) events.
+# This interferes with "admin down" for an interface. Thus, commented out. An
+# "admin up" is treated as a "link up" and thus, "link up" action is executed.
+# To execute "link down" action on "admin down", run appropriate script in
+# /etc/netplug/linkdown.d
+#probe)
+# ;;
+
+*)
+ exit 1
+ ;;
+esac
diff --git a/src/etc/netplug/netplugd.conf b/src/etc/netplug/netplugd.conf
new file mode 100644
index 0000000..7da3c67
--- /dev/null
+++ b/src/etc/netplug/netplugd.conf
@@ -0,0 +1,4 @@
+eth*
+br*
+bond*
+wlan*
diff --git a/src/etc/netplug/vyos-netplug-dhcp-client b/src/etc/netplug/vyos-netplug-dhcp-client
new file mode 100644
index 0000000..55d15a1
--- /dev/null
+++ b/src/etc/netplug/vyos-netplug-dhcp-client
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 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/>.
+
+import sys
+
+from time import sleep
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.ifconfig import Section
+from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.process import call
+from vyos import airbag
+airbag.enable()
+
+if len(sys.argv) < 3:
+ airbag.noteworthy("Must specify both interface and link status!")
+ sys.exit(1)
+
+if not boot_configuration_complete():
+ airbag.noteworthy("System bootup not yet finished...")
+ sys.exit(1)
+
+while commit_in_progress():
+ sleep(1)
+
+interface = sys.argv[1]
+in_out = sys.argv[2]
+config = ConfigTreeQuery()
+
+interface_path = ['interfaces'] + Section.get_config_path(interface).split()
+
+for _, interface_config in config.get_config_dict(interface_path).items():
+ # Bail out early if we do not have an IP address configured
+ if 'address' not in interface_config:
+ continue
+ # Bail out early if interface ist administrative down
+ if 'disable' in interface_config:
+ continue
+ systemd_action = 'start'
+ if in_out == 'out':
+ systemd_action = 'stop'
+ # Start/Stop DHCP service
+ if 'dhcp' in interface_config['address']:
+ call(f'systemctl {systemd_action} dhclient@{interface}.service')
+ # Start/Stop DHCPv6 service
+ if 'dhcpv6' in interface_config['address']:
+ call(f'systemctl {systemd_action} dhcp6c@{interface}.service')
diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py
new file mode 100644
index 0000000..f6f6d07
--- /dev/null
+++ b/src/etc/opennhrp/opennhrp-script.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 os
+import re
+import sys
+import vyos.ipsec
+
+from json import loads
+from pathlib import Path
+
+from vyos.logger import getLogger
+from vyos.utils.process import cmd
+from vyos.utils.process import process_named_running
+
+NHRP_CONFIG: str = '/run/opennhrp/opennhrp.conf'
+
+
+def vici_get_ipsec_uniqueid(conn: str, src_nbma: str,
+ dst_nbma: str) -> list[str]:
+ """ Find and return IKE SAs by src nbma and dst nbma
+
+ Args:
+ conn (str): a connection name
+ src_nbma (str): an IP address of NBMA source
+ dst_nbma (str): an IP address of NBMA destination
+
+ Returns:
+ list: a list of IKE connections that match a criteria
+ """
+ if not conn or not src_nbma or not dst_nbma:
+ logger.error(
+ f'Incomplete input data for resolving IKE unique ids: '
+ f'conn: {conn}, src_nbma: {src_nbma}, dst_nbma: {dst_nbma}')
+ return []
+
+ try:
+ logger.info(
+ f'Resolving IKE unique ids for: conn: {conn}, '
+ f'src_nbma: {src_nbma}, dst_nbma: {dst_nbma}')
+ list_ikeid: list[str] = []
+ list_sa: list = vyos.ipsec.get_vici_sas_by_name(conn, None)
+ for sa in list_sa:
+ if sa[conn]['local-host'].decode('ascii') == src_nbma \
+ and sa[conn]['remote-host'].decode('ascii') == dst_nbma:
+ list_ikeid.append(sa[conn]['uniqueid'].decode('ascii'))
+ return list_ikeid
+ except Exception as err:
+ logger.error(f'Unable to find unique ids for IKE: {err}')
+ return []
+
+
+def vici_ike_terminate(list_ikeid: list[str]) -> bool:
+ """Terminating IKE SAs by list of IKE IDs
+
+ Args:
+ list_ikeid (list[str]): a list of IKE ids to terminate
+
+ Returns:
+ bool: result of termination action
+ """
+ if not list:
+ logger.warning('An empty list for termination was provided')
+ return False
+
+ try:
+ vyos.ipsec.terminate_vici_ikeid_list(list_ikeid)
+ return True
+ except Exception as err:
+ logger.error(f'Failed to terminate SA for IKE ids {list_ikeid}: {err}')
+ return False
+
+
+def parse_type_ipsec(interface: str) -> tuple[str, str]:
+ """Get DMVPN Type and NHRP Profile from the configuration
+
+ Args:
+ interface (str): a name of interface
+
+ Returns:
+ tuple[str, str]: `peer_type` and `profile_name`
+ """
+ if not interface:
+ logger.error('Cannot find peer type - no input provided')
+ return '', ''
+
+ config_file: str = Path(NHRP_CONFIG).read_text()
+ regex: str = rf'^interface {interface} #(?P<peer_type>hub|spoke) ?(?P<profile_name>[^\n]*)$'
+ match = re.search(regex, config_file, re.M)
+ if match:
+ return match.groupdict()['peer_type'], match.groupdict()[
+ 'profile_name']
+ return '', ''
+
+
+def add_peer_route(nbma_src: str, nbma_dst: str, mtu: str) -> None:
+ """Add a route to a NBMA peer
+
+ Args:
+ nbma_src (str): a local IP address
+ nbma_dst (str): a remote IP address
+ mtu (str): a MTU for a route
+ """
+ logger.info(f'Adding route from {nbma_src} to {nbma_dst} with MTU {mtu}')
+ # Find routes to a peer
+ route_get_cmd: str = f'sudo ip --json route get {nbma_dst} from {nbma_src}'
+ try:
+ route_info_data = loads(cmd(route_get_cmd))
+ except Exception as err:
+ logger.error(f'Unable to find a route to {nbma_dst}: {err}')
+ return
+
+ # Check if an output has an expected format
+ if not isinstance(route_info_data, list):
+ logger.error(
+ f'Garbage returned from the "{route_get_cmd}" '
+ f'command: {route_info_data}')
+ return
+
+ # Add static routes to a peer
+ for route_item in route_info_data:
+ route_dev = route_item.get('dev')
+ route_dst = route_item.get('dst')
+ route_gateway = route_item.get('gateway')
+ # Prepare a command to add a route
+ route_add_cmd = 'sudo ip route add'
+ if route_dst:
+ route_add_cmd = f'{route_add_cmd} {route_dst}'
+ if route_gateway:
+ route_add_cmd = f'{route_add_cmd} via {route_gateway}'
+ if route_dev:
+ route_add_cmd = f'{route_add_cmd} dev {route_dev}'
+ route_add_cmd = f'{route_add_cmd} proto 42 mtu {mtu}'
+ # Add a route
+ try:
+ cmd(route_add_cmd)
+ except Exception as err:
+ logger.error(
+ f'Unable to add a route using command "{route_add_cmd}": '
+ f'{err}')
+
+
+def vici_initiate(conn: str, child_sa: str, src_addr: str,
+ dest_addr: str) -> bool:
+ """Initiate IKE SA connection with specific peer
+
+ Args:
+ conn (str): an IKE connection name
+ child_sa (str): a child SA profile name
+ src_addr (str): NBMA local address
+ dest_addr (str): NBMA address of a peer
+
+ Returns:
+ bool: a result of initiation command
+ """
+ logger.info(
+ f'Trying to initiate connection. Name: {conn}, child sa: {child_sa}, '
+ f'src_addr: {src_addr}, dst_addr: {dest_addr}')
+ try:
+ vyos.ipsec.vici_initiate(conn, child_sa, src_addr, dest_addr)
+ return True
+ except Exception as err:
+ logger.error(f'Unable to initiate connection {err}')
+ return False
+
+
+def vici_terminate(conn: str, src_addr: str, dest_addr: str) -> None:
+ """Find and terminate IKE SAs by local NBMA and remote NBMA addresses
+
+ Args:
+ conn (str): IKE connection name
+ src_addr (str): NBMA local address
+ dest_addr (str): NBMA address of a peer
+ """
+ logger.info(
+ f'Terminating IKE connection {conn} between {src_addr} '
+ f'and {dest_addr}')
+
+ ikeid_list: list[str] = vici_get_ipsec_uniqueid(conn, src_addr, dest_addr)
+
+ if not ikeid_list:
+ logger.warning(
+ f'No active sessions found for IKE profile {conn}, '
+ f'local NBMA {src_addr}, remote NBMA {dest_addr}')
+ else:
+ try:
+ vyos.ipsec.terminate_vici_ikeid_list(ikeid_list)
+ except Exception as err:
+ logger.error(
+ f'Failed to terminate SA for IKE ids {ikeid_list}: {err}')
+
+def iface_up(interface: str) -> None:
+ """Proceed tunnel interface UP event
+
+ Args:
+ interface (str): an interface name
+ """
+ if not interface:
+ logger.warning('No interface name provided for UP event')
+
+ logger.info(f'Turning up interface {interface}')
+ try:
+ cmd(f'sudo ip route flush proto 42 dev {interface}')
+ cmd(f'sudo ip neigh flush dev {interface}')
+ except Exception as err:
+ logger.error(
+ f'Unable to flush route on interface "{interface}": {err}')
+
+
+def peer_up(dmvpn_type: str, conn: str) -> None:
+ """Proceed NHRP peer UP event
+
+ Args:
+ dmvpn_type (str): a type of peer
+ conn (str): an IKE profile name
+ """
+ logger.info(f'Peer UP event for {dmvpn_type} using IKE profile {conn}')
+ src_nbma = os.getenv('NHRP_SRCNBMA')
+ dest_nbma = os.getenv('NHRP_DESTNBMA')
+ dest_mtu = os.getenv('NHRP_DESTMTU')
+
+ if not src_nbma or not dest_nbma:
+ logger.error(
+ f'Can not get NHRP NBMA addresses: local {src_nbma}, '
+ f'remote {dest_nbma}')
+ return
+
+ logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}')
+ if dest_mtu:
+ add_peer_route(src_nbma, dest_nbma, dest_mtu)
+ if conn and dmvpn_type == 'spoke' and process_named_running('charon'):
+ vici_terminate(conn, src_nbma, dest_nbma)
+ vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma)
+
+
+def peer_down(dmvpn_type: str, conn: str) -> None:
+ """Proceed NHRP peer DOWN event
+
+ Args:
+ dmvpn_type (str): a type of peer
+ conn (str): an IKE profile name
+ """
+ logger.info(f'Peer DOWN event for {dmvpn_type} using IKE profile {conn}')
+
+ src_nbma = os.getenv('NHRP_SRCNBMA')
+ dest_nbma = os.getenv('NHRP_DESTNBMA')
+
+ if not src_nbma or not dest_nbma:
+ logger.error(
+ f'Can not get NHRP NBMA addresses: local {src_nbma}, '
+ f'remote {dest_nbma}')
+ return
+
+ logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}')
+ if conn and dmvpn_type == 'spoke' and process_named_running('charon'):
+ vici_terminate(conn, src_nbma, dest_nbma)
+ try:
+ cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42')
+ except Exception as err:
+ logger.error(
+ f'Unable to del route from {src_nbma} to {dest_nbma}: {err}')
+
+
+def route_up(interface: str) -> None:
+ """Proceed NHRP route UP event
+
+ Args:
+ interface (str): an interface name
+ """
+ logger.info(f'Route UP event for interface {interface}')
+
+ dest_addr = os.getenv('NHRP_DESTADDR')
+ dest_prefix = os.getenv('NHRP_DESTPREFIX')
+ next_hop = os.getenv('NHRP_NEXTHOP')
+
+ if not dest_addr or not dest_prefix or not next_hop:
+ logger.error(
+ f'Can not get route details: dest_addr {dest_addr}, '
+ f'dest_prefix {dest_prefix}, next_hop {next_hop}')
+ return
+
+ logger.info(
+ f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}, '
+ f'next_hop {next_hop}')
+
+ try:
+ cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 \
+ via {next_hop} dev {interface}')
+ cmd('sudo ip route flush cache')
+ except Exception as err:
+ logger.error(
+ f'Unable replace or flush route to {dest_addr}/{dest_prefix} '
+ f'via {next_hop} dev {interface}: {err}')
+
+
+def route_down(interface: str) -> None:
+ """Proceed NHRP route DOWN event
+
+ Args:
+ interface (str): an interface name
+ """
+ logger.info(f'Route DOWN event for interface {interface}')
+
+ dest_addr = os.getenv('NHRP_DESTADDR')
+ dest_prefix = os.getenv('NHRP_DESTPREFIX')
+
+ if not dest_addr or not dest_prefix:
+ logger.error(
+ f'Can not get route details: dest_addr {dest_addr}, '
+ f'dest_prefix {dest_prefix}')
+ return
+
+ logger.info(
+ f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}')
+ try:
+ cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42')
+ cmd('sudo ip route flush cache')
+ except Exception as err:
+ logger.error(
+ f'Unable delete or flush route to {dest_addr}/{dest_prefix}: '
+ f'{err}')
+
+
+if __name__ == '__main__':
+ logger = getLogger('opennhrp-script', syslog=True)
+ logger.debug(
+ f'Running script with arguments: {sys.argv}, '
+ f'environment: {os.environ}')
+
+ action = sys.argv[1]
+ interface = os.getenv('NHRP_INTERFACE')
+
+ if not interface:
+ logger.error('Can not get NHRP interface name')
+ sys.exit(1)
+
+ dmvpn_type, profile_name = parse_type_ipsec(interface)
+ if not dmvpn_type:
+ logger.info(f'Interface {interface} is not NHRP tunnel')
+ sys.exit()
+
+ dmvpn_conn: str = ''
+ if profile_name:
+ dmvpn_conn: str = f'dmvpn-{profile_name}-{interface}'
+ if action == 'interface-up':
+ iface_up(interface)
+ elif action == 'peer-register':
+ pass
+ elif action == 'peer-up':
+ peer_up(dmvpn_type, dmvpn_conn)
+ elif action == 'peer-down':
+ peer_down(dmvpn_type, dmvpn_conn)
+ elif action == 'route-up':
+ route_up(interface)
+ elif action == 'route-down':
+ route_down(interface)
+
+ sys.exit()
diff --git a/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers
new file mode 100644
index 0000000..5157469
--- /dev/null
+++ b/src/etc/ppp/ip-down.d/98-vyos-pppoe-cleanup-nameservers
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+interface=$6
+if [ -z "$interface" ]; then
+ exit
+fi
+
+if ! /usr/bin/systemctl -q is-active vyos-hostsd; then
+ exit # vyos-hostsd is not running
+fi
+
+hostsd_client="/usr/bin/vyos-hostsd-client"
+$hostsd_client --delete-name-servers --tag "dhcp-$interface"
+$hostsd_client --apply
diff --git a/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback
new file mode 100644
index 0000000..4e8804f
--- /dev/null
+++ b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+
+# This is a Python hook script which is invoked whenever a SSTP client session
+# goes "ip-up". It will call into our vyos.ifconfig library and will then
+# execute common tasks for the SSTP interface. The reason we have to "hook" this
+# is that we can not create a sstpcX interface in advance in linux and then
+# connect pppd to this already existing interface.
+
+from sys import argv
+from sys import exit
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.configdict import get_interface_dict
+from vyos.ifconfig import SSTPCIf
+
+# When the ppp link comes up, this script is called with the following
+# parameters
+# $1 the interface name used by pppd (e.g. ppp3)
+# $2 the tty device name
+# $3 the tty device speed
+# $4 the local IP address for the interface
+# $5 the remote IP address
+# $6 the parameter specified by the 'ipparam' option to pppd
+
+if (len(argv) < 7):
+ exit(1)
+
+interface = argv[6]
+
+conf = ConfigTreeQuery()
+_, sstpc = get_interface_dict(conf.config, ['interfaces', 'sstpc'], interface)
+
+# Update the config
+p = SSTPCIf(interface)
+p.update(sstpc)
diff --git a/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers
new file mode 100644
index 0000000..4affaeb
--- /dev/null
+++ b/src/etc/ppp/ip-up.d/98-vyos-pppoe-setup-nameservers
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+interface=$6
+if [ -z "$interface" ]; then
+ exit
+fi
+
+if ! /usr/bin/systemctl -q is-active vyos-hostsd; then
+ exit # vyos-hostsd is not running
+fi
+
+hostsd_client="/usr/bin/vyos-hostsd-client"
+
+$hostsd_client --delete-name-servers --tag "dhcp-$interface"
+
+if [ "$USEPEERDNS" ] && [ -n "$DNS1" ]; then
+$hostsd_client --add-name-servers "$DNS1" --tag "dhcp-$interface"
+fi
+if [ "$USEPEERDNS" ] && [ -n "$DNS2" ]; then
+$hostsd_client --add-name-servers "$DNS2" --tag "dhcp-$interface"
+fi
+
+$hostsd_client --apply
diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback
new file mode 100644
index 0000000..fa1917a
--- /dev/null
+++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-2022 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/>.
+
+# This is a Python hook script which is invoked whenever a PPPoE session goes
+# "ip-up". It will call into our vyos.ifconfig library and will then execute
+# common tasks for the PPPoE interface. The reason we have to "hook" this is
+# that we can not create a pppoeX interface in advance in linux and then connect
+# pppd to this already existing interface.
+
+from sys import argv
+from sys import exit
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.configdict import get_interface_dict
+from vyos.ifconfig import PPPoEIf
+
+# When the ppp link comes up, this script is called with the following
+# parameters
+# $1 the interface name used by pppd (e.g. ppp3)
+# $2 the tty device name
+# $3 the tty device speed
+# $4 the local IP address for the interface
+# $5 the remote IP address
+# $6 the parameter specified by the 'ipparam' option to pppd
+
+if (len(argv) < 7):
+ exit(1)
+
+interface = argv[6]
+
+conf = ConfigTreeQuery()
+_, pppoe = get_interface_dict(conf.config, ['interfaces', 'pppoe'], interface)
+
+# Update the config
+p = PPPoEIf(interface)
+p.update(pppoe)
diff --git a/src/etc/rsyslog.conf b/src/etc/rsyslog.conf
new file mode 100644
index 0000000..b3f41ac
--- /dev/null
+++ b/src/etc/rsyslog.conf
@@ -0,0 +1,67 @@
+#################
+#### MODULES ####
+#################
+
+$ModLoad imuxsock # provides support for local system logging
+$ModLoad imklog # provides kernel logging support (previously done by rklogd)
+#$ModLoad immark # provides --MARK-- message capability
+
+$OmitLocalLogging off
+$SystemLogSocketName /run/systemd/journal/syslog
+
+$KLogPath /proc/kmsg
+
+###########################
+#### GLOBAL DIRECTIVES ####
+###########################
+
+# Use traditional timestamp format.
+# To enable high precision timestamps, comment out the following line.
+# A modern-style logfile format similar to TraditionalFileFormat, buth with high-precision timestamps and timezone information
+#$ActionFileDefaultTemplate RSYSLOG_FileFormat
+# The "old style" default log file format with low-precision timestamps
+$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat
+
+# Filter duplicated messages
+$RepeatedMsgReduction on
+
+#
+# Set the default permissions for all log files.
+#
+$FileOwner root
+$FileGroup adm
+$FileCreateMode 0640
+$DirCreateMode 0755
+$Umask 0022
+
+#
+# Stop excessive logging of sudo
+#
+:msg, contains, " pam_unix(sudo:session): session opened for user root(uid=0) by" stop
+:msg, contains, "pam_unix(sudo:session): session closed for user root" stop
+
+#
+# Include all config files in /etc/rsyslog.d/
+#
+$IncludeConfig /etc/rsyslog.d/*.conf
+
+# The lines below cause all listed daemons/processes to be logged into
+# /var/log/auth.log, then drops the message so it does not also go to the
+# regular syslog so that messages are not duplicated
+
+$outchannel auth_log,/var/log/auth.log
+if $programname == 'CRON' or
+ $programname == 'sudo' or
+ $programname == 'su'
+ then :omfile:$auth_log
+
+if $programname == 'CRON' or
+ $programname == 'sudo' or
+ $programname == 'su'
+ then stop
+
+###############
+#### RULES ####
+###############
+# Emergencies are sent to everybody logged in.
+*.emerg :omusrmsg:* \ No newline at end of file
diff --git a/src/etc/securetty b/src/etc/securetty
new file mode 100644
index 0000000..17d8610
--- /dev/null
+++ b/src/etc/securetty
@@ -0,0 +1,83 @@
+# /etc/securetty: list of terminals on which root is allowed to login.
+# See securetty(5) and login(1).
+console
+
+# Standard serial ports
+ttyS0
+ttyS1
+
+# USB dongles
+ttyUSB0
+ttyUSB1
+ttyUSB2
+
+# Standard hypervisor virtual console
+hvc0
+
+# Oldstyle Xen console
+xvc0
+
+# Standard consoles
+tty1
+tty2
+tty3
+tty4
+tty5
+tty6
+tty7
+tty8
+tty9
+tty10
+tty11
+tty12
+tty13
+tty14
+tty15
+tty16
+tty17
+tty18
+tty19
+tty20
+tty21
+tty22
+tty23
+tty24
+tty25
+tty26
+tty27
+tty28
+tty29
+tty30
+tty31
+tty32
+tty33
+tty34
+tty35
+tty36
+tty37
+tty38
+tty39
+tty40
+tty41
+tty42
+tty43
+tty44
+tty45
+tty46
+tty47
+tty48
+tty49
+tty50
+tty51
+tty52
+tty53
+tty54
+tty55
+tty56
+tty57
+tty58
+tty59
+tty60
+tty61
+tty62
+tty63
diff --git a/src/etc/security/capability.conf b/src/etc/security/capability.conf
new file mode 100644
index 0000000..0a7235f
--- /dev/null
+++ b/src/etc/security/capability.conf
@@ -0,0 +1,10 @@
+# this is a capability file (used in conjunction with the pam_cap.so module)
+
+# Special capability for Vyatta admin
+all %vyattacfg
+
+# Vyatta Operator
+cap_net_admin,cap_sys_boot,cap_audit_write %vyattaop
+
+## 'everyone else' gets no inheritable capabilities
+none *
diff --git a/src/etc/skel/.bashrc b/src/etc/skel/.bashrc
new file mode 100644
index 0000000..ba7d500
--- /dev/null
+++ b/src/etc/skel/.bashrc
@@ -0,0 +1,119 @@
+# ~/.bashrc: executed by bash(1) for non-login shells.
+# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
+# for examples
+
+# If not running interactively, don't do anything
+case $- in
+ *i*) ;;
+ *) return;;
+esac
+
+# don't put duplicate lines or lines starting with space in the history.
+# See bash(1) for more options
+HISTCONTROL=ignoreboth
+
+# append to the history file, don't overwrite it
+shopt -s histappend
+
+# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
+HISTSIZE=1000
+HISTFILESIZE=2000
+
+# check the window size after each command and, if necessary,
+# update the values of LINES and COLUMNS.
+shopt -s checkwinsize
+
+# If set, the pattern "**" used in a pathname expansion context will
+# match all files and zero or more directories and subdirectories.
+#shopt -s globstar
+
+# make less more friendly for non-text input files, see lesspipe(1)
+#[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
+
+# set variable identifying the chroot you work in (used in the prompt below)
+if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
+ debian_chroot=$(cat /etc/debian_chroot)
+fi
+
+# set a fancy prompt (non-color, unless we know we "want" color)
+case "$TERM" in
+ xterm-color) color_prompt=yes;;
+esac
+
+# uncomment for a colored prompt, if the terminal has the capability; turned
+# off by default to not distract the user: the focus in a terminal window
+# should be on the output of commands, not on the prompt
+#force_color_prompt=yes
+
+if [ -n "$force_color_prompt" ]; then
+ if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
+ # We have color support; assume it's compliant with Ecma-48
+ # (ISO/IEC-6429). (Lack of such support is extremely rare, and such
+ # a case would tend to support setf rather than setaf.)
+ color_prompt=yes
+ else
+ color_prompt=
+ fi
+fi
+
+if [ "$color_prompt" = yes ]; then
+ PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\H${VRF:+(vrf:$VRF)}${NETNS:+(ns:$NETNS)}\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
+else
+ PS1='${debian_chroot:+($debian_chroot)}\u@\H${VRF:+:$VRF}${NETNS:+(ns:$NETNS)}:\w\$ '
+fi
+unset color_prompt force_color_prompt
+
+# If this is an xterm set the title to user@host:dir
+case "$TERM" in
+xterm*|rxvt*)
+ PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\H: \w\a\]$PS1"
+ ;;
+*)
+ ;;
+esac
+
+# enable color support of ls and also add handy aliases
+if [ -x /usr/bin/dircolors ]; then
+ test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
+ alias ls='ls --color=auto'
+ #alias dir='dir --color=auto'
+ #alias vdir='vdir --color=auto'
+
+ #alias grep='grep --color=auto'
+ #alias fgrep='fgrep --color=auto'
+ #alias egrep='egrep --color=auto'
+fi
+
+# colored GCC warnings and errors
+#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
+
+# some more ls aliases
+#alias ll='ls -l'
+#alias la='ls -A'
+#alias l='ls -CF'
+
+# Alias definitions.
+# You may want to put all your additions into a separate file like
+# ~/.bash_aliases, instead of adding them here directly.
+# See /usr/share/doc/bash-doc/examples in the bash-doc package.
+
+if [ -f ~/.bash_aliases ]; then
+ . ~/.bash_aliases
+fi
+
+# enable programmable completion features (you don't need to enable
+# this, if it's already enabled in /etc/bash.bashrc and /etc/profile
+# sources /etc/bash.bashrc).
+if ! shopt -oq posix; then
+ if [ -f /usr/share/bash-completion/bash_completion ]; then
+ . /usr/share/bash-completion/bash_completion
+ elif [ -f /etc/bash_completion ]; then
+ . /etc/bash_completion
+ fi
+fi
+OPAMROOT='/opt/opam'; export OPAMROOT;
+OPAM_SWITCH_PREFIX='/opt/opam/4.07.0'; export OPAM_SWITCH_PREFIX;
+CAML_LD_LIBRARY_PATH='/opt/opam/4.07.0/lib/stublibs:/opt/opam/4.07.0/lib/ocaml/stublibs:/opt/opam/4.07.0/lib/ocaml'; export CAML_LD_LIBRARY_PATH;
+OCAML_TOPLEVEL_PATH='/opt/opam/4.07.0/lib/toplevel'; export OCAML_TOPLEVEL_PATH;
+MANPATH=':/opt/opam/4.07.0/man'; export MANPATH;
+PATH='/opt/opam/4.07.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; export PATH;
diff --git a/src/etc/skel/.profile b/src/etc/skel/.profile
new file mode 100644
index 0000000..c9db459
--- /dev/null
+++ b/src/etc/skel/.profile
@@ -0,0 +1,22 @@
+# ~/.profile: executed by the command interpreter for login shells.
+# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login
+# exists.
+# see /usr/share/doc/bash/examples/startup-files for examples.
+# the files are located in the bash-doc package.
+
+# the default umask is set in /etc/profile; for setting the umask
+# for ssh logins, install and configure the libpam-umask package.
+#umask 022
+
+# if running bash
+if [ -n "$BASH_VERSION" ]; then
+ # include .bashrc if it exists
+ if [ -f "$HOME/.bashrc" ]; then
+ . "$HOME/.bashrc"
+ fi
+fi
+
+# set PATH so it includes user's private bin if it exists
+if [ -d "$HOME/bin" ] ; then
+ PATH="$HOME/bin:$PATH"
+fi
diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos
new file mode 100644
index 0000000..67d7bab
--- /dev/null
+++ b/src/etc/sudoers.d/vyos
@@ -0,0 +1,63 @@
+#
+# VyOS modifications to sudo configuration
+#
+Defaults syslog_goodpri=info
+Defaults env_keep+=VYATTA_*
+
+#
+# Command groups allowed for operator users
+#
+Cmnd_Alias IPTABLES = /sbin/iptables --list -n,\
+ /sbin/iptables -L -vn,\
+ /sbin/iptables -L * -vn,\
+ /sbin/iptables -t * -L *, \
+ /sbin/iptables -Z *,\
+ /sbin/iptables -Z -t nat, \
+ /sbin/iptables -t * -Z *
+Cmnd_Alias IP6TABLES = /sbin/ip6tables -t * -Z *, \
+ /sbin/ip6tables -t * -L *
+Cmnd_Alias CONNTRACK = /usr/sbin/conntrack -L *, \
+ /usr/sbin/conntrack -G *, \
+ /usr/sbin/conntrack -E *
+Cmnd_Alias IPFLUSH = /sbin/ip route flush cache, \
+ /sbin/ip route flush cache *,\
+ /sbin/ip neigh flush to *, \
+ /sbin/ip neigh flush dev *, \
+ /sbin/ip -f inet6 route flush cache, \
+ /sbin/ip -f inet6 route flush cache *,\
+ /sbin/ip -f inet6 neigh flush to *, \
+ /sbin/ip -f inet6 neigh flush dev *
+Cmnd_Alias ETHTOOL = /sbin/ethtool -p *, \
+ /sbin/ethtool -S *, \
+ /sbin/ethtool -a *, \
+ /sbin/ethtool -c *, \
+ /sbin/ethtool -i *
+Cmnd_Alias DMIDECODE = /usr/sbin/dmidecode
+Cmnd_Alias DISK = /usr/bin/lsof, /sbin/fdisk -l *, /sbin/sfdisk -d *
+Cmnd_Alias DATE = /bin/date, /usr/sbin/ntpdate
+Cmnd_Alias PPPOE_CMDS = /sbin/pppd, /sbin/poff, /usr/sbin/pppstats
+Cmnd_Alias PCAPTURE = /usr/bin/tcpdump
+Cmnd_Alias HWINFO = /usr/bin/lspci
+Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \
+ /usr/share/heartbeat/hb_standby
+Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \
+ /bin/ip vrf exec * /bin/traceroute *, \
+ /bin/ip vrf exec * /usr/bin/mtr *, \
+ /usr/libexec/vyos/op_mode/*
+Cmnd_Alias KEA_IP6_ROUTES = /sbin/ip -6 route replace *,\
+ /sbin/ip -6 route del *
+%operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \
+ PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \
+ DMIDECODE, DISK, CONNTRACK, IP6TABLES, \
+ FORCE_CLUSTER, DIAGNOSTICS
+
+# Allow any user to run files in sudo-users
+%users ALL=NOPASSWD: /opt/vyatta/bin/sudo-users/
+
+# Allow members of group sudo to execute any command
+%sudo ALL=NOPASSWD: ALL
+
+# Allow any user to query Machine Owner Key status
+%sudo ALL=NOPASSWD: /usr/bin/mokutil
+
+_kea ALL=NOPASSWD: KEA_IP6_ROUTES
diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf
new file mode 100644
index 0000000..76be41d
--- /dev/null
+++ b/src/etc/sysctl.d/30-vyos-router.conf
@@ -0,0 +1,117 @@
+#
+# VyOS specific sysctl settings, see sysctl.conf (5) for information.
+#
+
+# Panic on OOPS
+kernel.panic_on_oops=1
+
+# Timeout before rebooting on panic
+kernel.panic=60
+
+# Send all core files to /var/core/core.program.pid.time
+kernel.core_pattern=/var/core/core-%e-%p-%t
+
+# ARP configuration
+# arp_filter - allow multiple network interfaces on same subnet
+# arp_announce - avoid local addresses no on target's subnet
+# arp_ignore - reply only if target IP is local_address on the interface
+
+# arp_filter defaults to 1 so set all to 0 so vrrp interfaces can override it.
+net.ipv4.conf.all.arp_filter=0
+
+# https://vyos.dev/T300
+net.ipv4.conf.all.arp_ignore=0
+net.ipv4.conf.all.arp_announce=2
+
+# Enable packet forwarding for IPv4
+net.ipv4.ip_forward=1
+
+# Enable directed broadcast forwarding feature described in rfc1812#section-5.3.5.2 and rfc2644.
+# Note that setting the 'all' entry to 1 doesn't enable directed broadcast forwarding on all interfaces.
+# To enable directed broadcast forwarding on an interface, both the 'all' entry and the input interface entry should be set to 1.
+net.ipv4.conf.all.bc_forwarding=1
+net.ipv4.conf.default.bc_forwarding=0
+
+# if a primary address is removed from an interface promote the
+# secondary address if available
+net.ipv4.conf.all.promote_secondaries=1
+
+# Ignore ICMP broadcasts sent to broadcast/multicast
+net.ipv4.icmp_echo_ignore_broadcasts=1
+
+# Ignore bogus ICMP errors
+net.ipv4.icmp_ignore_bogus_error_responses=1
+
+# Send ICMP responses with primary address of exiting interface
+net.ipv4.icmp_errors_use_inbound_ifaddr=1
+
+# Log packets with impossible addresses to kernel log
+net.ipv4.conf.all.log_martians=1
+
+# Do not ignore all ICMP ECHO requests by default
+net.ipv4.icmp_echo_ignore_all=0
+
+# Disable source validation by default
+net.ipv4.conf.all.rp_filter=0
+net.ipv4.conf.default.rp_filter=0
+
+# Enable tcp syn-cookies by default
+net.ipv4.tcp_syncookies=1
+
+# Disable accept_redirects by default for any interface
+net.ipv4.conf.all.accept_redirects=0
+net.ipv4.conf.default.accept_redirects=0
+net.ipv6.conf.all.accept_redirects=0
+net.ipv6.conf.default.accept_redirects=0
+
+# Disable accept_source_route by default
+net.ipv4.conf.all.accept_source_route=0
+net.ipv4.conf.default.accept_source_route=0
+net.ipv6.conf.all.accept_source_route=0
+net.ipv6.conf.default.accept_source_route=0
+
+# Enable send_redirects by default
+net.ipv4.conf.all.send_redirects=1
+net.ipv4.conf.default.send_redirects=1
+
+# Increase size of buffer for netlink
+net.core.rmem_max=2097152
+
+# Remove IPv4 and IPv6 routes from forward information base when link goes down
+net.ipv4.conf.all.ignore_routes_with_linkdown=1
+net.ipv4.conf.default.ignore_routes_with_linkdown=1
+net.ipv6.conf.all.ignore_routes_with_linkdown=1
+net.ipv6.conf.default.ignore_routes_with_linkdown=1
+
+# Enable packet forwarding for IPv6
+net.ipv6.conf.all.forwarding=1
+
+# Increase route table limit
+net.ipv6.route.max_size = 262144
+
+# Do not forget IPv6 addresses when a link goes down
+net.ipv6.conf.default.keep_addr_on_down=1
+net.ipv6.conf.all.keep_addr_on_down=1
+net.ipv6.route.skip_notify_on_dev_down=1
+
+# Default value of 20 seems to interfere with larger OSPF and VRRP setups
+net.ipv4.igmp_max_memberships = 512
+
+# Enable global RFS (Receive Flow Steering) configuration. RFS is inactive
+# until explicitly configured at the interface level
+net.core.rps_sock_flow_entries = 32768
+
+# Congestion control
+net.core.default_qdisc=fq_codel
+net.ipv4.tcp_congestion_control=bbr
+
+# Disable IPv6 Segment Routing packets by default
+net.ipv6.conf.all.seg6_enabled = 0
+net.ipv6.conf.default.seg6_enabled = 0
+
+net.vrf.strict_mode = 1
+
+# https://vyos.dev/T6570
+# By default, do not forward traffic from bridge to IPvX layer
+net.bridge.bridge-nf-call-iptables = 0
+net.bridge.bridge-nf-call-ip6tables = 0 \ No newline at end of file
diff --git a/src/etc/sysctl.d/31-vyos-addr_gen_mode.conf b/src/etc/sysctl.d/31-vyos-addr_gen_mode.conf
new file mode 100644
index 0000000..07a0d15
--- /dev/null
+++ b/src/etc/sysctl.d/31-vyos-addr_gen_mode.conf
@@ -0,0 +1,14 @@
+### Added by vyos-1x ###
+#
+# addr_gen_mode - INTEGER
+# Defines how link-local and autoconf addresses are generated.
+#
+# 0: generate address based on EUI64 (default)
+# 1: do no generate a link-local address, use EUI64 for addresses generated
+# from autoconf
+# 2: generate stable privacy addresses, using the secret from
+# stable_secret (RFC7217)
+# 3: generate stable privacy addresses, using a random secret if unset
+#
+net.ipv6.conf.all.addr_gen_mode = 1
+net.ipv6.conf.default.addr_gen_mode = 1
diff --git a/src/etc/sysctl.d/32-vyos-podman.conf b/src/etc/sysctl.d/32-vyos-podman.conf
new file mode 100644
index 0000000..7068bf8
--- /dev/null
+++ b/src/etc/sysctl.d/32-vyos-podman.conf
@@ -0,0 +1,5 @@
+# Increase inotify watchers as per https://bugzilla.redhat.com/show_bug.cgi?id=1829596
+fs.inotify.max_queued_events = 1048576
+fs.inotify.max_user_instances = 1048576
+fs.inotify.max_user_watches = 1048576
+
diff --git a/src/etc/systemd/system-generators/vyos-generator b/src/etc/systemd/system-generators/vyos-generator
new file mode 100644
index 0000000..34faab6
--- /dev/null
+++ b/src/etc/systemd/system-generators/vyos-generator
@@ -0,0 +1,94 @@
+#!/bin/sh
+set -f
+
+LOG=""
+DEBUG_LEVEL=1
+LOG_D="/run/vyos-router"
+ENABLE="enabled"
+DISABLE="disabled"
+FOUND="found"
+NOTFOUND="notfound"
+RUN_ENABLED_FILE="$LOG_D/$ENABLE"
+VYOS_SYSTEM_TARGET="/lib/systemd/system/vyos.target"
+VYOS_TARGET_NAME="vyos.target"
+
+debug() {
+ local lvl="$1"
+ shift
+ [ "$lvl" -gt "$DEBUG_LEVEL" ] && return
+ if [ -z "$LOG" ]; then
+ local log="$LOG_D/${0##*/}.log"
+ { [ -d "$LOG_D" ] || mkdir -p "$LOG_D"; } &&
+ { : > "$log"; } >/dev/null 2>&1 && LOG="$log" ||
+ LOG="/dev/kmsg"
+ fi
+ echo "$@" >> "$LOG"
+}
+
+default() {
+ _RET="$ENABLE"
+}
+
+main() {
+ local normal_d="$1" early_d="$2" late_d="$3"
+ local target_name="multi-user.target" gen_d="$early_d"
+ local link_path="$gen_d/${target_name}.wants/${VYOS_TARGET_NAME}"
+ local ds="$NOTFOUND"
+
+ debug 1 "$0 normal=$normal_d early=$early_d late=$late_d"
+ debug 2 "$0 $*"
+
+ local search result="error" ret=""
+ for search in default; do
+ if $search; then
+ debug 1 "$search found $_RET"
+ [ "$_RET" = "$ENABLE" -o "$_RET" = "$DISABLE" ] &&
+ result=$_RET && break
+ else
+ ret=$?
+ debug 0 "search $search returned $ret"
+ fi
+ done
+
+ # enable AND ds=found == enable
+ # enable AND ds=notfound == disable
+ # disable || <any> == disabled
+ if [ "$result" = "$ENABLE" ]; then
+ if [ -e "$link_path" ]; then
+ debug 1 "already enabled: no change needed"
+ else
+ [ -d "${link_path%/*}" ] || mkdir -p "${link_path%/*}" ||
+ debug 0 "failed to make dir $link_path"
+ if ln -snf "$VYOS_SYSTEM_TARGET" "$link_path"; then
+ debug 1 "enabled via $link_path -> $VYOS_SYSTEM_TARGET"
+ else
+ ret=$?
+ debug 0 "[$ret] enable failed:" \
+ "ln $VYOS_SYSTEM_TARGET $link_path"
+ fi
+ fi
+ : > "$RUN_ENABLED_FILE"
+ elif [ "$result" = "$DISABLE" ]; then
+ if [ -f "$link_path" ]; then
+ if rm -f "$link_path"; then
+ debug 1 "disabled. removed existing $link_path"
+ else
+ ret=$?
+ debug 0 "[$ret] disable failed, remove $link_path"
+ fi
+ else
+ debug 1 "already disabled: no change needed [no $link_path]"
+ fi
+ if [ -e "$RUN_ENABLED_FILE" ]; then
+ rm -f "$RUN_ENABLED_FILE"
+ fi
+ else
+ debug 0 "unexpected result '$result' 'ds=$ds'"
+ ret=3
+ fi
+ return $ret
+}
+
+main "$@"
+
+# vi: ts=4 expandtab
diff --git a/src/etc/systemd/system/ModemManager.service.d/override.conf b/src/etc/systemd/system/ModemManager.service.d/override.conf
new file mode 100644
index 0000000..07a1846
--- /dev/null
+++ b/src/etc/systemd/system/ModemManager.service.d/override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/ModemManager --filter-policy=strict --log-level=INFO --log-timestamps --log-journal
diff --git a/src/etc/systemd/system/atop.service.d/10-override.conf b/src/etc/systemd/system/atop.service.d/10-override.conf
new file mode 100644
index 0000000..10df158
--- /dev/null
+++ b/src/etc/systemd/system/atop.service.d/10-override.conf
@@ -0,0 +1,6 @@
+[Service]
+ExecStartPre=
+ExecStart=
+ExecStart=/bin/sh -c 'exec /usr/bin/atop ${LOGOPTS} -w "${LOGPATH}/atop.log" ${LOGINTERVAL}'
+ExecStartPost=
+
diff --git a/src/etc/systemd/system/certbot.service.d/10-override.conf b/src/etc/systemd/system/certbot.service.d/10-override.conf
new file mode 100644
index 0000000..542f77e
--- /dev/null
+++ b/src/etc/systemd/system/certbot.service.d/10-override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/bin/certbot renew --config-dir /config/auth/letsencrypt --no-random-sleep-on-renew --post-hook "/usr/libexec/vyos/vyos-certbot-renew-pki.sh"
diff --git a/src/etc/systemd/system/conntrackd.service.d/override.conf b/src/etc/systemd/system/conntrackd.service.d/override.conf
new file mode 100644
index 0000000..eb611e0
--- /dev/null
+++ b/src/etc/systemd/system/conntrackd.service.d/override.conf
@@ -0,0 +1,8 @@
+[Unit]
+After=
+After=vyos-router.service
+ConditionPathExists=/run/conntrackd/conntrackd.conf
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/conntrackd -C /run/conntrackd/conntrackd.conf
diff --git a/src/etc/systemd/system/conserver-server.service.d/override.conf b/src/etc/systemd/system/conserver-server.service.d/override.conf
new file mode 100644
index 0000000..3c753f5
--- /dev/null
+++ b/src/etc/systemd/system/conserver-server.service.d/override.conf
@@ -0,0 +1,10 @@
+[Unit]
+After=
+After=vyos-router.service
+ConditionPathExists=/run/conserver/conserver.cf
+
+[Service]
+Type=simple
+ExecStart=
+ExecStart=/usr/sbin/conserver -M localhost -C /run/conserver/conserver.cf
+Restart=on-failure
diff --git a/src/etc/systemd/system/fastnetmon.service.d/override.conf b/src/etc/systemd/system/fastnetmon.service.d/override.conf
new file mode 100644
index 0000000..8416660
--- /dev/null
+++ b/src/etc/systemd/system/fastnetmon.service.d/override.conf
@@ -0,0 +1,12 @@
+[Unit]
+RequiresMountsFor=/run
+ConditionPathExists=/run/fastnetmon/fastnetmon.conf
+After=
+After=vyos-router.service
+
+[Service]
+Type=simple
+WorkingDirectory=/run/fastnetmon
+PIDFile=/run/fastnetmon.pid
+ExecStart=
+ExecStart=/usr/sbin/fastnetmon --configuration_file /run/fastnetmon/fastnetmon.conf
diff --git a/src/etc/systemd/system/frr.service.d/override.conf b/src/etc/systemd/system/frr.service.d/override.conf
new file mode 100644
index 0000000..614b4f7
--- /dev/null
+++ b/src/etc/systemd/system/frr.service.d/override.conf
@@ -0,0 +1,11 @@
+[Unit]
+After=vyos-router.service
+
+[Service]
+LimitNOFILE=4096
+ExecStartPre=/bin/bash -c 'mkdir -p /run/frr/config; \
+ echo "log syslog" > /run/frr/config/frr.conf; \
+ echo "log facility local7" >> /run/frr/config/frr.conf; \
+ chown frr:frr /run/frr/config/frr.conf; \
+ chmod 664 /run/frr/config/frr.conf; \
+ mount --bind /run/frr/config/frr.conf /etc/frr/frr.conf'
diff --git a/src/etc/systemd/system/getty@.service.d/aftervyos.conf b/src/etc/systemd/system/getty@.service.d/aftervyos.conf
new file mode 100644
index 0000000..c575390
--- /dev/null
+++ b/src/etc/systemd/system/getty@.service.d/aftervyos.conf
@@ -0,0 +1,3 @@
+[Service]
+ExecStartPre=-/usr/libexec/vyos/init/vyos-config
+StandardOutput=journal+console
diff --git a/src/etc/systemd/system/hostapd@.service.d/override.conf b/src/etc/systemd/system/hostapd@.service.d/override.conf
new file mode 100644
index 0000000..926c07f
--- /dev/null
+++ b/src/etc/systemd/system/hostapd@.service.d/override.conf
@@ -0,0 +1,12 @@
+[Unit]
+After=
+After=vyos-router.service
+ConditionFileNotEmpty=
+ConditionFileNotEmpty=/run/hostapd/%i.conf
+
+[Service]
+WorkingDirectory=/run/hostapd
+EnvironmentFile=
+ExecStart=
+ExecStart=/usr/sbin/hostapd -B -P /run/hostapd/%i.pid /run/hostapd/%i.conf
+PIDFile=/run/hostapd/%i.pid
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
new file mode 100644
index 0000000..0f5bf80
--- /dev/null
+++ b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
@@ -0,0 +1,9 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-ctrl-agent -c /run/kea/kea-ctrl-agent.conf
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
diff --git a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
new file mode 100644
index 0000000..682e5bb
--- /dev/null
+++ b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-dhcp4 -c /run/kea/kea-dhcp4.conf
diff --git a/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf
new file mode 100644
index 0000000..cb33fc0
--- /dev/null
+++ b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-dhcp6 -c /run/kea/kea-dhcp6.conf
diff --git a/src/etc/systemd/system/logrotate.timer.d/10-override.conf b/src/etc/systemd/system/logrotate.timer.d/10-override.conf
new file mode 100644
index 0000000..f50c2b0
--- /dev/null
+++ b/src/etc/systemd/system/logrotate.timer.d/10-override.conf
@@ -0,0 +1,2 @@
+[Timer]
+OnCalendar=hourly
diff --git a/src/etc/systemd/system/nginx.service.d/10-override.conf b/src/etc/systemd/system/nginx.service.d/10-override.conf
new file mode 100644
index 0000000..1be5cec
--- /dev/null
+++ b/src/etc/systemd/system/nginx.service.d/10-override.conf
@@ -0,0 +1,3 @@
+[Unit]
+After=
+After=vyos-router.service
diff --git a/src/etc/systemd/system/ocserv.service.d/override.conf b/src/etc/systemd/system/ocserv.service.d/override.conf
new file mode 100644
index 0000000..89dbb15
--- /dev/null
+++ b/src/etc/systemd/system/ocserv.service.d/override.conf
@@ -0,0 +1,14 @@
+[Unit]
+RequiresMountsFor=/run
+ConditionPathExists=/run/ocserv/ocserv.conf
+After=
+After=vyos-router.service
+After=dbus.service
+
+[Service]
+WorkingDirectory=/run/ocserv
+PIDFile=
+PIDFile=/run/ocserv/ocserv.pid
+ExecStart=
+ExecStart=/usr/sbin/ocserv --foreground --pid-file /run/ocserv/ocserv.pid --config /run/ocserv/ocserv.conf
+
diff --git a/src/etc/systemd/system/openvpn@.service.d/10-override.conf b/src/etc/systemd/system/openvpn@.service.d/10-override.conf
new file mode 100644
index 0000000..775a2d7
--- /dev/null
+++ b/src/etc/systemd/system/openvpn@.service.d/10-override.conf
@@ -0,0 +1,14 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+WorkingDirectory=
+WorkingDirectory=/run/openvpn
+ExecStart=
+ExecStart=/usr/sbin/openvpn --daemon openvpn-%i --config %i.conf --status %i.status 30 --writepid %i.pid
+ExecReload=/bin/kill -HUP $MAINPID
+User=openvpn
+Group=openvpn
+AmbientCapabilities=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_AUDIT_WRITE
+CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_AUDIT_WRITE
diff --git a/src/etc/systemd/system/radvd.service.d/override.conf b/src/etc/systemd/system/radvd.service.d/override.conf
new file mode 100644
index 0000000..812446d
--- /dev/null
+++ b/src/etc/systemd/system/radvd.service.d/override.conf
@@ -0,0 +1,19 @@
+[Unit]
+ConditionPathExists=
+ConditionPathExists=/run/radvd/radvd.conf
+After=
+After=vyos-router.service
+
+[Service]
+WorkingDirectory=
+WorkingDirectory=/run/radvd
+ExecStartPre=
+ExecStartPre=/usr/sbin/radvd --logmethod stderr_clean --configtest --config /run/radvd/radvd.conf
+ExecStart=
+ExecStart=/usr/sbin/radvd --logmethod stderr_clean --config /run/radvd/radvd.conf --pidfile /run/radvd/radvd.pid
+ExecReload=
+ExecReload=/usr/sbin/radvd --logmethod stderr_clean --configtest --config /run/radvd/radvd.conf
+ExecReload=/bin/kill -HUP $MAINPID
+PIDFile=
+PIDFile=/run/radvd/radvd.pid
+Restart=always
diff --git a/src/etc/systemd/system/serial-getty@.service.d/aftervyos.conf b/src/etc/systemd/system/serial-getty@.service.d/aftervyos.conf
new file mode 100644
index 0000000..8ba4277
--- /dev/null
+++ b/src/etc/systemd/system/serial-getty@.service.d/aftervyos.conf
@@ -0,0 +1,3 @@
+[Service]
+ExecStartPre=-/usr/libexec/vyos/init/vyos-config SERIAL
+StandardOutput=journal+console
diff --git a/src/etc/systemd/system/ssh@.service.d/vrf-override.conf b/src/etc/systemd/system/ssh@.service.d/vrf-override.conf
new file mode 100644
index 0000000..b8952d8
--- /dev/null
+++ b/src/etc/systemd/system/ssh@.service.d/vrf-override.conf
@@ -0,0 +1,13 @@
+[Unit]
+StartLimitIntervalSec=0
+After=vyos-router.service
+ConditionPathExists=/run/sshd/sshd_config
+
+[Service]
+EnvironmentFile=
+ExecStart=
+ExecStart=ip vrf exec %i /usr/sbin/sshd -f /run/sshd/sshd_config
+Restart=always
+RestartPreventExitStatus=
+RestartSec=10
+RuntimeDirectoryPreserve=yes
diff --git a/src/etc/systemd/system/suricata.service.d/10-override.conf b/src/etc/systemd/system/suricata.service.d/10-override.conf
new file mode 100644
index 0000000..781256c
--- /dev/null
+++ b/src/etc/systemd/system/suricata.service.d/10-override.conf
@@ -0,0 +1,9 @@
+[Service]
+ExecStart=
+ExecStart=/usr/bin/suricata -D --af-packet -c /run/suricata/suricata.yaml --pidfile /run/suricata/suricata.pid
+PIDFile=
+PIDFile=/run/suricata/suricata.pid
+ExecReload=
+ExecReload=/usr/bin/suricatasc -c reload-rules /run/suricata/suricata.socket ; /bin/kill -HUP $MAINPID
+ExecStop=
+ExecStop=/usr/bin/suricatasc -c shutdown /run/suricata/suricata.socket
diff --git a/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf b/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf
new file mode 100644
index 0000000..030b89a
--- /dev/null
+++ b/src/etc/systemd/system/wpa_supplicant-wired@.service.d/override.conf
@@ -0,0 +1,11 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+WorkingDirectory=
+WorkingDirectory=/run/wpa_supplicant
+PIDFile=/run/wpa_supplicant/%I.pid
+ExecStart=
+ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dwired -P/run/wpa_supplicant/%I.pid -i%I
+ExecReload=/bin/kill -HUP $MAINPID
diff --git a/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf
new file mode 100644
index 0000000..5cffb79
--- /dev/null
+++ b/src/etc/systemd/system/wpa_supplicant@.service.d/override.conf
@@ -0,0 +1,11 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+WorkingDirectory=
+WorkingDirectory=/run/wpa_supplicant
+PIDFile=/run/wpa_supplicant/%I.pid
+ExecStart=
+ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dnl80211,wext -P/run/wpa_supplicant/%I.pid -i%I
+ExecReload=/bin/kill -HUP $MAINPID
diff --git a/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py
new file mode 100644
index 0000000..bb7515a
--- /dev/null
+++ b/src/etc/telegraf/custom_scripts/show_firewall_input_filter.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+
+import json
+import re
+import time
+
+from vyos.utils.process import cmd
+
+
+def get_nft_filter_chains():
+ """
+ Get list of nft chains for table filter
+ """
+ try:
+ nft = cmd('/usr/sbin/nft --json list table ip vyos_filter')
+ except Exception:
+ return []
+ nft = json.loads(nft)
+ chain_list = []
+
+ for output in nft['nftables']:
+ if 'chain' in output:
+ chain = output['chain']['name']
+ chain_list.append(chain)
+
+ return chain_list
+
+
+def get_nftables_details(name):
+ """
+ Get dict, counters packets and bytes for chain
+ """
+ command = f'/usr/sbin/nft list chain ip vyos_filter {name}'
+ try:
+ results = cmd(command)
+ except:
+ return {}
+
+ # Trick to remove 'NAME_' from chain name in the comment
+ # It was added to any chain T4218
+ # counter packets 0 bytes 0 return comment "FOO default-action accept"
+ comment_name = name.replace("NAME_", "")
+ out = {}
+ for line in results.split('\n'):
+ comment_search = re.search(rf'{comment_name}[\- ](\d+|default-action)', line)
+ if not comment_search:
+ continue
+
+ rule = {}
+ rule_id = comment_search[1]
+ counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line)
+ if counter_search:
+ rule['packets'] = counter_search[1]
+ rule['bytes'] = counter_search[2]
+
+ rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip()
+ out[rule_id] = rule
+ return out
+
+
+def get_nft_telegraf(name):
+ """
+ Get data for telegraf in influxDB format
+ """
+ for rule, rule_config in get_nftables_details(name).items():
+ print(f'nftables,table=vyos_filter,chain={name},'
+ f'ruleid={rule} '
+ f'pkts={rule_config["packets"]}i,'
+ f'bytes={rule_config["bytes"]}i '
+ f'{str(int(time.time()))}000000000')
+
+
+chains = get_nft_filter_chains()
+
+for chain in chains:
+ get_nft_telegraf(chain)
diff --git a/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py
new file mode 100644
index 0000000..6f14d6a
--- /dev/null
+++ b/src/etc/telegraf/custom_scripts/show_interfaces_input_filter.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+from vyos.ifconfig import Section
+from vyos.ifconfig import Interface
+
+import time
+
+def get_interface_addresses(iface, link_local_v6=False):
+ """
+ Get IP and IPv6 addresses from interface in one string
+ By default don't get IPv6 link-local addresses
+ If interface doesn't have address, return "-"
+ """
+ addresses = []
+ addrs = Interface(iface).get_addr()
+
+ for addr in addrs:
+ if link_local_v6 == False:
+ if addr.startswith('fe80::'):
+ continue
+ addresses.append(addr)
+
+ if not addresses:
+ return "-"
+
+ return (" ".join(addresses))
+
+def get_interface_description(iface):
+ """
+ Get interface description
+ If none return "empty"
+ """
+ description = Interface(iface).get_alias()
+
+ if not description:
+ return "empty"
+
+ return description
+
+def get_interface_admin_state(iface):
+ """
+ Interface administrative state
+ up => 0, down => 2
+ """
+ state = Interface(iface).get_admin_state()
+ if state == 'up':
+ admin_state = 0
+ if state == 'down':
+ admin_state = 2
+
+ return admin_state
+
+def get_interface_oper_state(iface):
+ """
+ Interface operational state
+ up => 0, down => 1
+ """
+ state = Interface(iface).operational.get_state()
+ if state == 'down':
+ oper_state = 1
+ else:
+ oper_state = 0
+
+ return oper_state
+
+interfaces = Section.interfaces('')
+
+for iface in interfaces:
+ print(f'show_interfaces,interface={iface} '
+ f'ip_addresses="{get_interface_addresses(iface)}",'
+ f'state={get_interface_admin_state(iface)}i,'
+ f'link={get_interface_oper_state(iface)}i,'
+ f'description="{get_interface_description(iface)}" '
+ f'{str(int(time.time()))}000000000')
diff --git a/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py
new file mode 100644
index 0000000..00f2f18
--- /dev/null
+++ b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 time
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.process import process_named_running
+
+# Availible services and prouceses
+# 1 - service
+# 2 - process
+services = {
+ "protocols bgp" : "bgpd",
+ "protocols ospf" : "ospfd",
+ "protocols ospfv3" : "ospf6d",
+ "protocols rip" : "ripd",
+ "protocols ripng" : "ripngd",
+ "protocols isis" : "isisd",
+ "service pppoe" : "accel-ppp@pppoe.service",
+ "vpn l2tp remote-access" : "accel-ppp@l2tp.service",
+ "vpn pptp remote-access" : "accel-ppp@pptp.service",
+ "vpn sstp" : "accel-ppp@sstp.service",
+ "vpn ipsec" : "charon"
+}
+
+# Configured services
+conf_services = {
+ 'zebra' : 0,
+ 'staticd' : 0,
+}
+# Get configured service and create list to check if process running
+config = ConfigTreeQuery()
+for service in services:
+ if config.exists(service):
+ conf_services[services[service]] = 0
+
+for conf_service in conf_services:
+ status = 0
+ if ".service" in conf_service:
+ # Check systemd service
+ if is_systemd_service_running(conf_service):
+ status = 1
+ else:
+ # Check process
+ if process_named_running(conf_service):
+ status = 1
+ print(f'vyos_services,service="{conf_service}" '
+ f'status={str(status)}i {str(int(time.time()))}000000000')
diff --git a/src/etc/udev/rules.d/42-qemu-usb.rules b/src/etc/udev/rules.d/42-qemu-usb.rules
new file mode 100644
index 0000000..a79543d
--- /dev/null
+++ b/src/etc/udev/rules.d/42-qemu-usb.rules
@@ -0,0 +1,14 @@
+#
+# Enable autosuspend for qemu emulated usb hid devices.
+#
+# Note that there are buggy qemu versions which advertise remote
+# wakeup support but don't actually implement it correctly. This
+# is the reason why we need a match for the serial number here.
+# The serial number "42" is used to tag the implementations where
+# remote wakeup is working.
+#
+# Gerd Hoffmann <kraxel@xxxxxxxxxx>
+
+ACTION=="add", SUBSYSTEM=="usb", ATTR{product}=="QEMU USB Mouse", ATTR{serial}=="42", TEST=="power/control", ATTR{power/control}="auto"
+ACTION=="add", SUBSYSTEM=="usb", ATTR{product}=="QEMU USB Tablet", ATTR{serial}=="42", TEST=="power/control", ATTR{power/control}="auto"
+ACTION=="add", SUBSYSTEM=="usb", ATTR{product}=="QEMU USB Keyboard", ATTR{serial}=="42", TEST=="power/control", ATTR{power/control}="auto"
diff --git a/src/etc/udev/rules.d/62-temporary-interface-rename.rules b/src/etc/udev/rules.d/62-temporary-interface-rename.rules
new file mode 100644
index 0000000..4a579dc
--- /dev/null
+++ b/src/etc/udev/rules.d/62-temporary-interface-rename.rules
@@ -0,0 +1 @@
+SUBSYSTEM=="net", ACTION=="add", KERNEL=="eth*", DRIVERS=="?*", NAME="e$env{IFINDEX}"
diff --git a/src/etc/udev/rules.d/63-hyperv-vf-net.rules b/src/etc/udev/rules.d/63-hyperv-vf-net.rules
new file mode 100644
index 0000000..b4dcb5a
--- /dev/null
+++ b/src/etc/udev/rules.d/63-hyperv-vf-net.rules
@@ -0,0 +1,5 @@
+ATTR{[dmi/id]sys_vendor}!="Microsoft Corporation", GOTO="end_hyperv_nic"
+
+ACTION=="add", SUBSYSTEM=="net", DRIVERS=="hv_pci", NAME="vf_%k"
+
+LABEL="end_hyperv_nic"
diff --git a/src/etc/udev/rules.d/64-vyos-vmware-net.rules b/src/etc/udev/rules.d/64-vyos-vmware-net.rules
new file mode 100644
index 0000000..66a4a06
--- /dev/null
+++ b/src/etc/udev/rules.d/64-vyos-vmware-net.rules
@@ -0,0 +1,14 @@
+ATTR{[dmi/id]sys_vendor}!="VMware, Inc.", GOTO="end_vmware_nic"
+
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet0", ENV{VYOS_IFNAME}="eth0"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet1", ENV{VYOS_IFNAME}="eth1"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet2", ENV{VYOS_IFNAME}="eth2"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet3", ENV{VYOS_IFNAME}="eth3"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet4", ENV{VYOS_IFNAME}="eth4"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet5", ENV{VYOS_IFNAME}="eth5"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet6", ENV{VYOS_IFNAME}="eth6"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet7", ENV{VYOS_IFNAME}="eth7"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet8", ENV{VYOS_IFNAME}="eth8"
+ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet9", ENV{VYOS_IFNAME}="eth9"
+
+LABEL="end_vmware_nic"
diff --git a/src/etc/udev/rules.d/65-vyos-net.rules b/src/etc/udev/rules.d/65-vyos-net.rules
new file mode 100644
index 0000000..32ae352
--- /dev/null
+++ b/src/etc/udev/rules.d/65-vyos-net.rules
@@ -0,0 +1,23 @@
+# These rules use vyos_net_name to persistently name network interfaces
+# per "hwid" association in the VyOS configuration file.
+
+ACTION!="add", GOTO="vyos_net_end"
+SUBSYSTEM!="net", GOTO="vyos_net_end"
+
+# Do name change for ethernet and wireless devices only
+KERNEL!="eth*|wlan*|e*", GOTO="vyos_net_end"
+
+# ignore "secondary" monitor interfaces of mac80211 drivers
+KERNEL=="wlan*", ATTRS{type}=="803", GOTO="vyos_net_end"
+
+# If using VyOS predefined names
+ENV{VYOS_IFNAME}!="eth*", GOTO="end_vyos_predef_names"
+
+DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address} $env{VYOS_IFNAME}", NAME="%c", GOTO="vyos_net_end"
+
+LABEL="end_vyos_predef_names"
+
+# ignore interfaces without a driver link like bridges and VLANs
+DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address}", NAME="%c"
+
+LABEL="vyos_net_end"
diff --git a/src/etc/udev/rules.d/90-vyos-serial.rules b/src/etc/udev/rules.d/90-vyos-serial.rules
new file mode 100644
index 0000000..30c1d31
--- /dev/null
+++ b/src/etc/udev/rules.d/90-vyos-serial.rules
@@ -0,0 +1,28 @@
+# do not edit this file, it will be overwritten on update
+
+ACTION=="remove", GOTO="serial_end"
+SUBSYSTEM!="tty", GOTO="serial_end"
+
+SUBSYSTEMS=="pci", ENV{ID_BUS}="pci", ENV{ID_VENDOR_ID}="$attr{vendor}", ENV{ID_MODEL_ID}="$attr{device}"
+SUBSYSTEMS=="pci", IMPORT{builtin}="hwdb --subsystem=pci"
+SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id", IMPORT{builtin}="hwdb --subsystem=usb"
+
+# /dev/serial/by-path/, /dev/serial/by-id/ for USB devices
+KERNEL!="ttyUSB[0-9]*", GOTO="serial_end"
+
+SUBSYSTEMS=="usb-serial", ENV{.ID_PORT}="$attr{port_number}"
+
+IMPORT{builtin}="path_id", IMPORT{builtin}="usb_id"
+
+# Change the name of the usb id to a "more" human redable format.
+#
+# - $env{ID_PATH} usually is a name like: "pci-0000:00:10.0-usb-0:2.3.3.4:1.0-port0" so we strip the "pci-*"
+# portion and only use the usb part
+# - Transform the USB "speech" to the tree like structure so we start with "usb0" as root-complex 0.
+# (tr -d -) does the replacement
+# - Replace the first group after ":" to represent the bus relation (sed -e 0,/:/s//b/) indicated by "b"
+# - Replace the next group after ":" to represent the port relation (sed -e 0,/:/s//p/) indicated by "p"
+ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="", PROGRAM="/bin/sh -c 'echo $env{ID_PATH} | cut -d- -f3- | tr -d - | sed -e 0,/:/s//b/ | sed -e 0,/:/s//p/'", SYMLINK+="serial/by-bus/$result"
+ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="?*", PROGRAM="/bin/sh -c 'echo $env{ID_PATH} | cut -d- -f3- | tr -d - | sed -e 0,/:/s//b/ | sed -e 0,/:/s//p/'", SYMLINK+="serial/by-bus/$result"
+
+LABEL="serial_end"
diff --git a/src/etc/udev/rules.d/99-vyos-systemd.rules b/src/etc/udev/rules.d/99-vyos-systemd.rules
new file mode 100644
index 0000000..54aea66
--- /dev/null
+++ b/src/etc/udev/rules.d/99-vyos-systemd.rules
@@ -0,0 +1,79 @@
+# The main reason that we store this file is systemd-udevd interfaces excludes
+# /lib/systemd/systemd-sysctl for dynamic interfaces (ppp|ipoe|l2tp etc)
+
+ACTION=="remove", GOTO="systemd_end"
+
+SUBSYSTEM=="tty", KERNEL=="tty[a-zA-Z]*|hvc*|xvc*|hvsi*|ttysclp*|sclp_line*|3270/tty[0-9]*", TAG+="systemd"
+KERNEL=="vport*", TAG+="systemd"
+
+SUBSYSTEM=="ptp", TAG+="systemd"
+
+SUBSYSTEM=="ubi", TAG+="systemd"
+
+SUBSYSTEM=="block", TAG+="systemd"
+
+# We can't make any conclusions about suspended DM devices so let's just import previous SYSTEMD_READY state and skip other rules
+SUBSYSTEM=="block", ENV{DM_SUSPENDED}=="1", IMPORT{db}="SYSTEMD_READY", GOTO="systemd_end"
+SUBSYSTEM=="block", ACTION=="add", ENV{DM_UDEV_DISABLE_OTHER_RULES_FLAG}=="1", ENV{SYSTEMD_READY}="0"
+
+# Ignore encrypted devices with no identified superblock on it, since
+# we are probably still calling mke2fs or mkswap on it.
+SUBSYSTEM=="block", ENV{DM_UUID}=="CRYPT-*", ENV{ID_PART_TABLE_TYPE}=="", ENV{ID_FS_USAGE}=="", ENV{SYSTEMD_READY}="0"
+
+# Explicitly set SYSTEMD_READY=1 for DM devices that don't have it set yet, so that we always have something to import above
+SUBSYSTEM=="block", ENV{DM_UUID}=="?*", ENV{SYSTEMD_READY}=="", ENV{SYSTEMD_READY}="1"
+
+# add symlink to GPT root disk
+SUBSYSTEM=="block", ENV{ID_PART_GPT_AUTO_ROOT}=="1", ENV{ID_FS_TYPE}!="crypto_LUKS", SYMLINK+="gpt-auto-root"
+SUBSYSTEM=="block", ENV{ID_PART_GPT_AUTO_ROOT}=="1", ENV{ID_FS_TYPE}=="crypto_LUKS", SYMLINK+="gpt-auto-root-luks"
+SUBSYSTEM=="block", ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root", SYMLINK+="gpt-auto-root"
+
+# Ignore raid devices that are not yet assembled and started
+SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="md*", TEST!="md/array_state", ENV{SYSTEMD_READY}="0"
+SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="md*", ATTR{md/array_state}=="|clear|inactive", ENV{SYSTEMD_READY}="0"
+
+# Ignore loop devices that don't have any file attached
+SUBSYSTEM=="block", KERNEL=="loop[0-9]*", ENV{DEVTYPE}=="disk", TEST!="loop/backing_file", ENV{SYSTEMD_READY}="0"
+
+# Ignore nbd devices until the PID file exists (which signals a connected device)
+SUBSYSTEM=="block", KERNEL=="nbd*", ENV{DEVTYPE}=="disk", TEST!="pid", ENV{SYSTEMD_READY}="0"
+
+# We need a hardware independent way to identify network devices. We
+# use the /sys/subsystem/ path for this. Kernel "bus" and "class" names
+# should be treated as one namespace, like udev handles it. This is mostly
+# just an identification string for systemd, so whether the path actually is
+# accessible or not does not matter as long as it is unique and in the
+# filesystem namespace.
+
+SUBSYSTEM=="net", KERNEL!="lo", TAG+="systemd", ENV{SYSTEMD_ALIAS}+="/sys/subsystem/net/devices/$name"
+SUBSYSTEM=="bluetooth", TAG+="systemd", ENV{SYSTEMD_ALIAS}+="/sys/subsystem/bluetooth/devices/%k", \
+ ENV{SYSTEMD_WANTS}+="bluetooth.target", ENV{SYSTEMD_USER_WANTS}+="bluetooth.target"
+
+ENV{ID_SMARTCARD_READER}=="?*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="smartcard.target", ENV{SYSTEMD_USER_WANTS}+="smartcard.target"
+SUBSYSTEM=="sound", KERNEL=="controlC*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sound.target", ENV{SYSTEMD_USER_WANTS}+="sound.target"
+
+SUBSYSTEM=="printer", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target"
+SUBSYSTEM=="usb", KERNEL=="lp*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target"
+SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="*:0701??:*", TAG+="systemd", ENV{SYSTEMD_WANTS}+="printer.target", ENV{SYSTEMD_USER_WANTS}+="printer.target"
+
+SUBSYSTEM=="udc", ACTION=="add", TAG+="systemd", ENV{SYSTEMD_WANTS}+="usb-gadget.target"
+
+# Apply sysctl variables to network devices (and only to those) as they appear.
+# T5706. Exclude: lo, dummy*, ppp*, ipoe*, l2tp*, pptp*, sslvpn* and sstp*.
+ACTION=="add", SUBSYSTEM=="net", KERNEL!="lo|dummy*|ppp*|ipoe*|l2tp*|pptp*|sslvpn*|sstp*", RUN+="/lib/systemd/systemd-sysctl --prefix=/net/ipv4/conf/$name --prefix=/net/ipv4/neigh/$name --prefix=/net/ipv6/conf/$name --prefix=/net/ipv6/neigh/$name"
+
+# Pull in backlight save/restore for all backlight devices and
+# keyboard backlights
+SUBSYSTEM=="backlight", TAG+="systemd", IMPORT{builtin}="path_id", ENV{SYSTEMD_WANTS}+="systemd-backlight@backlight:$name.service"
+SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", TAG+="systemd", IMPORT{builtin}="path_id", ENV{SYSTEMD_WANTS}+="systemd-backlight@leds:$name.service"
+
+# Pull in rfkill save/restore for all rfkill devices
+SUBSYSTEM=="rfkill", ENV{SYSTEMD_RFKILL}="1"
+SUBSYSTEM=="rfkill", IMPORT{builtin}="path_id"
+SUBSYSTEM=="misc", KERNEL=="rfkill", TAG+="systemd", ENV{SYSTEMD_WANTS}+="systemd-rfkill.socket"
+
+# Asynchronously mount file systems implemented by these modules as soon as they are loaded.
+SUBSYSTEM=="module", KERNEL=="fuse", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sys-fs-fuse-connections.mount"
+SUBSYSTEM=="module", KERNEL=="configfs", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sys-kernel-config.mount"
+
+LABEL="systemd_end"
diff --git a/src/etc/update-motd.d/99-reboot b/src/etc/update-motd.d/99-reboot
new file mode 100644
index 0000000..718be1a
--- /dev/null
+++ b/src/etc/update-motd.d/99-reboot
@@ -0,0 +1,7 @@
+#!/bin/vbash
+source /opt/vyatta/etc/functions/script-template
+if [ -f /run/systemd/shutdown/scheduled ]; then
+ echo
+ run show reboot
+fi
+exit
diff --git a/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py b/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py
new file mode 100644
index 0000000..7da57bc
--- /dev/null
+++ b/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 syslog
+
+from vyos import ConfigError
+from vyos.config import Config
+from vyos.utils.process import run
+
+def get_config():
+ c = Config()
+ interfaces = dict()
+ for intf in c.list_effective_nodes('interfaces ethernet'):
+ # skip interfaces that are disabled
+ check_disable = f'interfaces ethernet {intf} disable'
+ if c.exists_effective(check_disable):
+ continue
+
+ # get addresses configured on the interface
+ intf_addresses = c.return_effective_values(
+ f'interfaces ethernet {intf} address')
+ interfaces[intf] = [addr.strip("'") for addr in intf_addresses]
+ return interfaces
+
+def apply(config):
+ syslog.openlog(ident='ether-resume', logoption=syslog.LOG_PID,
+ facility=syslog.LOG_INFO)
+
+ for intf, addresses in config.items():
+ # bring the interface up
+ cmd = f'ip link set dev {intf} up'
+ syslog.syslog(cmd)
+ run(cmd)
+
+ # add configured addresses to interface
+ for addr in addresses:
+ # dhcp is handled by netplug
+ if addr in ['dhcp', 'dhcpv6']:
+ continue
+ cmd = f'ip address add {addr} dev {intf}'
+ syslog.syslog(cmd)
+ run(cmd)
+
+if __name__ == '__main__':
+ try:
+ config = get_config()
+ apply(config)
+ except ConfigError as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/etc/vmware-tools/tools.conf b/src/etc/vmware-tools/tools.conf
new file mode 100644
index 0000000..da98a4f
--- /dev/null
+++ b/src/etc/vmware-tools/tools.conf
@@ -0,0 +1,2 @@
+[guestinfo]
+ poll-interval=30
diff --git a/src/helpers/add-system-version.py b/src/helpers/add-system-version.py
new file mode 100644
index 0000000..5270ee7
--- /dev/null
+++ b/src/helpers/add-system-version.py
@@ -0,0 +1,20 @@
+#!/usr/bin/python3
+
+# Copyright 2019-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/>.
+
+from vyos.component_version import add_system_version
+
+add_system_version()
diff --git a/src/helpers/commit-confirm-notify.py b/src/helpers/commit-confirm-notify.py
new file mode 100644
index 0000000..8d7626c
--- /dev/null
+++ b/src/helpers/commit-confirm-notify.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+import os
+import sys
+import time
+
+# Minutes before reboot to trigger notification.
+intervals = [1, 5, 15, 60]
+
+def notify(interval):
+ s = "" if interval == 1 else "s"
+ time.sleep((minutes - interval) * 60)
+ message = ('"[commit-confirm] System is going to reboot in '
+ f'{interval} minute{s} to rollback the last commit.\n'
+ 'Confirm your changes to cancel the reboot."')
+ os.system("wall -n " + message)
+
+if __name__ == "__main__":
+ # Must be run as root to call wall(1) without a banner.
+ if len(sys.argv) != 2 or os.getuid() != 0:
+ print('This script requires superuser privileges.', file=sys.stderr)
+ exit(1)
+ minutes = int(sys.argv[1])
+ # Drop the argument from the list so that the notification
+ # doesn't kick in immediately.
+ if minutes in intervals:
+ intervals.remove(minutes)
+ for interval in sorted(intervals, reverse=True):
+ if minutes >= interval:
+ notify(interval)
+ minutes -= (minutes - interval)
+ exit(0)
diff --git a/src/helpers/config_dependency.py b/src/helpers/config_dependency.py
new file mode 100644
index 0000000..817bcc6
--- /dev/null
+++ b/src/helpers/config_dependency.py
@@ -0,0 +1,113 @@
+#!/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 os
+import sys
+import json
+from argparse import ArgumentParser
+from argparse import ArgumentTypeError
+from graphlib import TopologicalSorter, CycleError
+
+# addon packages will need to specify the dependency directory
+data_dir = '/usr/share/vyos/'
+dependency_dir = os.path.join(data_dir, 'config-mode-dependencies')
+
+def dict_merge(source, destination):
+ from copy import deepcopy
+ tmp = deepcopy(destination)
+
+ for key, value in source.items():
+ if key not in tmp:
+ tmp[key] = value
+ elif isinstance(source[key], dict):
+ tmp[key] = dict_merge(source[key], tmp[key])
+
+ return tmp
+
+def read_dependency_dict(dependency_dir: str = dependency_dir) -> dict:
+ res = {}
+ for dep_file in os.listdir(dependency_dir):
+ if not dep_file.endswith('.json'):
+ continue
+ path = os.path.join(dependency_dir, dep_file)
+ with open(path) as f:
+ d = json.load(f)
+ if dep_file == 'vyos-1x.json':
+ res = dict_merge(res, d)
+ else:
+ res = dict_merge(d, res)
+
+ return res
+
+def graph_from_dependency_dict(d: dict) -> dict:
+ g = {}
+ for k in list(d):
+ g[k] = set()
+ # add the dependencies for every sub-case; should there be cases
+ # that are mutally exclusive in the future, the graphs will be
+ # distinguished
+ for el in list(d[k]):
+ g[k] |= set(d[k][el])
+
+ return g
+
+def is_acyclic(d: dict) -> bool:
+ g = graph_from_dependency_dict(d)
+ ts = TopologicalSorter(g)
+ try:
+ # get node iterator
+ order = ts.static_order()
+ # try iteration
+ _ = [*order]
+ except CycleError:
+ return False
+
+ return True
+
+def check_dependency_graph(dependency_dir: str = dependency_dir,
+ supplement: str = None) -> bool:
+ d = read_dependency_dict(dependency_dir=dependency_dir)
+ if supplement is not None:
+ with open(supplement) as f:
+ d = dict_merge(json.load(f), d)
+
+ return is_acyclic(d)
+
+def path_exists(s):
+ if not os.path.exists(s):
+ raise ArgumentTypeError("Must specify a valid vyos-1x dependency directory")
+ return s
+
+def main():
+ parser = ArgumentParser(description='generate and save dict from xml defintions')
+ parser.add_argument('--dependency-dir', type=path_exists,
+ default=dependency_dir,
+ help='location of vyos-1x dependency directory')
+ parser.add_argument('--supplement', type=str,
+ help='supplemental dependency file')
+ args = vars(parser.parse_args())
+
+ if not check_dependency_graph(**args):
+ print("dependency error: cycle exists")
+ sys.exit(1)
+
+ print("dependency graph acyclic")
+ sys.exit(0)
+
+if __name__ == '__main__':
+ main()
diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py
new file mode 100644
index 0000000..34accf2
--- /dev/null
+++ b/src/helpers/geoip-update.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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 sys
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.firewall import geoip_update
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = ConfigTreeQuery()
+ base = ['firewall']
+
+ if not conf.exists(base):
+ return None
+
+ return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--force", help="Force update", action="store_true")
+ args = parser.parse_args()
+
+ firewall = get_config()
+
+ if not geoip_update(firewall, force=args.force):
+ sys.exit(1)
diff --git a/src/helpers/priority.py b/src/helpers/priority.py
new file mode 100644
index 0000000..0418610
--- /dev/null
+++ b/src/helpers/priority.py
@@ -0,0 +1,42 @@
+#!/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 sys
+from argparse import ArgumentParser
+from tabulate import tabulate
+
+from vyos.priority import get_priority_data
+
+if __name__ == '__main__':
+ parser = ArgumentParser()
+ parser.add_argument('--legacy-format', action='store_true',
+ help="format output for comparison with legacy 'priority.pl'")
+ args = parser.parse_args()
+
+ prio_list = get_priority_data()
+ if args.legacy_format:
+ for p in prio_list:
+ print(f'{p[2]} {"/".join(p[0])}')
+ sys.exit(0)
+
+ l = []
+ for p in prio_list:
+ l.append((p[2], p[1], p[0]))
+ headers = ['priority', 'owner', 'path']
+ out = tabulate(l, headers, numalign='right')
+ print(out)
diff --git a/src/helpers/read-saved-value.py b/src/helpers/read-saved-value.py
new file mode 100644
index 0000000..1463e9f
--- /dev/null
+++ b/src/helpers/read-saved-value.py
@@ -0,0 +1,30 @@
+#!/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/>.
+#
+#
+
+from argparse import ArgumentParser
+from vyos.utils.config import read_saved_value
+
+if __name__ == '__main__':
+ parser = ArgumentParser()
+ parser.add_argument('--path', nargs='*')
+ args = parser.parse_args()
+
+ out = read_saved_value(args.path) if args.path else ''
+ if isinstance(out, list):
+ out = ' '.join(out)
+ print(out)
diff --git a/src/helpers/run-config-activation.py b/src/helpers/run-config-activation.py
new file mode 100644
index 0000000..5829370
--- /dev/null
+++ b/src/helpers/run-config-activation.py
@@ -0,0 +1,83 @@
+#!/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 re
+import logging
+from pathlib import Path
+from argparse import ArgumentParser
+
+from vyos.compose_config import ComposeConfig
+from vyos.compose_config import ComposeConfigError
+from vyos.defaults import directories
+
+parser = ArgumentParser()
+parser.add_argument('config_file', type=str,
+ help="configuration file to modify with system-specific settings")
+parser.add_argument('--test-script', type=str,
+ help="test effect of named script")
+
+args = parser.parse_args()
+
+checkpoint_file = '/run/vyos-activate-checkpoint'
+log_file = Path(directories['config']).joinpath('vyos-activate.log')
+
+logger = logging.getLogger(__name__)
+fh = logging.FileHandler(log_file)
+formatter = logging.Formatter('%(message)s')
+fh.setFormatter(formatter)
+logger.addHandler(fh)
+
+if 'vyos-activate-debug' in Path('/proc/cmdline').read_text():
+ print(f'\nactivate-debug enabled: file {checkpoint_file}_* on error')
+ debug = checkpoint_file
+ logger.setLevel(logging.DEBUG)
+else:
+ debug = None
+ logger.setLevel(logging.INFO)
+
+def sort_key(s: Path):
+ s = s.stem
+ pre, rem = re.match(r'(\d*)(?:-)?(.+)', s).groups()
+ return int(pre or 0), rem
+
+def file_ext(file_name: str) -> str:
+ """Return an identifier from file name for checkpoint file extension.
+ """
+ return Path(file_name).stem
+
+script_dir = Path(directories['activate'])
+
+if args.test_script:
+ script_list = [script_dir.joinpath(args.test_script)]
+else:
+ script_list = sorted(script_dir.glob('*.py'), key=sort_key)
+
+config_file = args.config_file
+config_str = Path(config_file).read_text()
+
+compose = ComposeConfig(config_str, checkpoint_file=debug)
+
+for file in script_list:
+ file = file.as_posix()
+ logger.info(f'calling {file}')
+ try:
+ compose.apply_file(file, func_name='activate')
+ except ComposeConfigError as e:
+ if debug:
+ compose.write(f'{compose.checkpoint_file}_{file_ext(file)}')
+ logger.error(f'config-activation error in {file}: {e}')
+
+compose.write(config_file, with_version=True)
diff --git a/src/helpers/run-config-migration.py b/src/helpers/run-config-migration.py
new file mode 100644
index 0000000..e6ce973
--- /dev/null
+++ b/src/helpers/run-config-migration.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 sys
+import time
+from argparse import ArgumentParser
+from shutil import copyfile
+
+from vyos.migrate import ConfigMigrate
+from vyos.migrate import ConfigMigrateError
+
+parser = ArgumentParser()
+parser.add_argument('config_file', type=str,
+ help="configuration file to migrate")
+parser.add_argument('--test-script', type=str,
+ help="test named script")
+parser.add_argument('--output-file', type=str,
+ help="write to named output file instead of config file")
+parser.add_argument('--force', action='store_true',
+ help="force run of all migration scripts")
+
+args = parser.parse_args()
+
+config_file = args.config_file
+out_file = args.output_file
+test_script = args.test_script
+force = args.force
+
+if not os.access(config_file, os.R_OK):
+ print(f"Config file '{config_file}' not readable")
+ sys.exit(1)
+
+if out_file is None:
+ if not os.access(config_file, os.W_OK):
+ print(f"Config file '{config_file}' not writeable")
+ sys.exit(1)
+else:
+ try:
+ open(out_file, 'w').close()
+ except OSError:
+ print(f"Output file '{out_file}' not writeable")
+ sys.exit(1)
+
+config_migrate = ConfigMigrate(config_file, force=force, output_file=out_file)
+
+if test_script:
+ # run_script and exit
+ config_migrate.run_script(test_script)
+ sys.exit(0)
+
+backup = None
+if out_file is None:
+ timestr = time.strftime("%Y%m%d-%H%M%S")
+ backup = f'{config_file}.{timestr}.pre-migration'
+ copyfile(config_file, backup)
+
+try:
+ config_migrate.run()
+except ConfigMigrateError as e:
+ print(f'Error: {e}')
+ sys.exit(1)
+
+if backup is not None and not config_migrate.config_modified:
+ os.unlink(backup)
diff --git a/src/helpers/simple-download.py b/src/helpers/simple-download.py
new file mode 100644
index 0000000..501af75
--- /dev/null
+++ b/src/helpers/simple-download.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+import sys
+from argparse import ArgumentParser
+from vyos.remote import download
+
+parser = ArgumentParser()
+parser.add_argument('--local-file', help='local file', required=True)
+parser.add_argument('--remote-path', help='remote path', required=True)
+
+args = parser.parse_args()
+
+try:
+ download(args.local_file, args.remote_path,
+ check_space=True, raise_error=True)
+except Exception as e:
+ print(e)
+ sys.exit(1)
+
+sys.exit()
diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py
new file mode 100644
index 0000000..cb29069
--- /dev/null
+++ b/src/helpers/strip-private.py
@@ -0,0 +1,153 @@
+#!/usr/bin/python3
+
+# Copyright 2021-2023 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/>.
+
+import argparse
+import re
+import sys
+
+from netaddr import IPNetwork, AddrFormatError
+
+parser = argparse.ArgumentParser(description='strip off private information from VyOS config')
+
+strictness = parser.add_mutually_exclusive_group()
+strictness.add_argument('--loose', action='store_true', help='remove only information specified as arguments')
+strictness.add_argument('--strict', action='store_true', help='remove any private information (implies all arguments below). This is the default behavior.')
+
+parser.add_argument('--mac', action='store_true', help='strip off MAC addresses')
+parser.add_argument('--hostname', action='store_true', help='strip off system host and domain names')
+parser.add_argument('--username', action='store_true', help='strip off user names')
+parser.add_argument('--dhcp', action='store_true', help='strip off DHCP shared network and static mapping names')
+parser.add_argument('--domain', action='store_true', help='strip off domain names')
+parser.add_argument('--asn', action='store_true', help='strip off BGP ASNs')
+parser.add_argument('--snmp', action='store_true', help='strip off SNMP location information')
+parser.add_argument('--lldp', action='store_true', help='strip off LLDP location information')
+
+address_preserval = parser.add_mutually_exclusive_group()
+address_preserval.add_argument('--address', action='store_true', help='strip off all IPv4 and IPv6 addresses')
+address_preserval.add_argument('--public-address', action='store_true', help='only strip off public IPv4 and IPv6 addresses')
+address_preserval.add_argument('--keep-address', action='store_true', help='preserve all IPv4 and IPv6 addresses')
+
+# Censor the first half of the address.
+ipv4_re = re.compile(r'(\d{1,3}\.){2}(\d{1,3}\.\d{1,3})')
+ipv4_subst = r'xxx.xxx.\2'
+
+# Censor all but the first two fields.
+ipv6_re = re.compile(r'([0-9a-fA-F]{1,4}\:){2}([0-9a-fA-F:]+)')
+ipv6_subst = r'xxxx:xxxx:\2'
+
+def ip_match(match: re.Match, subst: str) -> str:
+ """
+ Take a Match and a substitution pattern, check if the match contains a valid IP address, strip
+ information if it is. This routine is intended to be passed to `re.sub' as a replacement pattern.
+ """
+ result = match.group(0)
+ # Is this a valid IP address?
+ try:
+ addr = IPNetwork(result).ip
+ # No? Then we've got nothing to do with it.
+ except AddrFormatError:
+ return result
+ # Should we strip it?
+ if args.address or (args.public_address and not addr.is_private()):
+ return match.expand(subst)
+ # No? Then we'll leave it as is.
+ else:
+ return result
+
+def strip_address(line: str) -> str:
+ """
+ Strip IPv4 and IPv6 addresses from the given string.
+ """
+ return ipv4_re.sub(lambda match: ip_match(match, ipv4_subst), ipv6_re.sub(lambda match: ip_match(match, ipv6_subst), line))
+
+def strip_lines(rules: tuple) -> None:
+ """
+ Read stdin line by line and apply the given stripping rules.
+ """
+ try:
+ for line in sys.stdin:
+ if not args.keep_address:
+ line = strip_address(line)
+ for (condition, regexp, subst) in rules:
+ if condition:
+ line = regexp.sub(subst, line)
+ print(line, end='')
+ # stdin can be cut for any reason, such as user interrupt or the pager terminating before the text can be read.
+ # All we can do is gracefully exit.
+ except (BrokenPipeError, EOFError, KeyboardInterrupt):
+ sys.exit(1)
+
+if __name__ == "__main__":
+ args = parser.parse_args()
+ # Strict mode is the default and the absence of loose mode implies presence of strict mode.
+ if not args.loose:
+ args.mac = args.domain = args.hostname = args.username = args.dhcp = args.asn = args.snmp = args.lldp = True
+ if not args.public_address and not args.keep_address:
+ args.address = True
+ elif not args.address and not args.public_address:
+ args.keep_address = True
+
+ # (condition, precompiled regexp, substitution string)
+ stripping_rules = [
+ # Strip passwords
+ (True, re.compile(r'password \S+'), 'password xxxxxx'),
+ (True, re.compile(r'cisco-authentication \S+'), 'cisco-authentication xxxxxx'),
+ # Strip public key information
+ (True, re.compile(r'public-keys \S+'), 'public-keys xxxx@xxx.xxx'),
+ (True, re.compile(r'type \'ssh-(rsa|dss)\''), 'type ssh-xxx'),
+ (True, re.compile(r' key \S+'), ' key xxxxxx'),
+ # Strip bucket
+ (True, re.compile(r' bucket \S+'), ' bucket xxxxxx'),
+ # Strip tokens
+ (True, re.compile(r' token \S+'), ' token xxxxxx'),
+ # Strip OpenVPN secrets
+ (True, re.compile(r'(shared-secret-key-file|ca-cert-file|cert-file|dh-file|key-file|client) (\S+)'), r'\1 xxxxxx'),
+ # Strip IPSEC secrets
+ (True, re.compile(r'pre-shared-secret \S+'), 'pre-shared-secret xxxxxx'),
+ (True, re.compile(r'secret \S+'), 'secret xxxxxx'),
+ # Strip OSPF md5-key
+ (True, re.compile(r'md5-key \S+'), 'md5-key xxxxxx'),
+ # Strip WireGuard private-key
+ (True, re.compile(r'private-key \S+'), 'private-key xxxxxx'),
+
+ # Strip MAC addresses
+ (args.mac, re.compile(r'([0-9a-fA-F]{2}\:){5}([0-9a-fA-F]{2}((\:{0,1})){3})'), r'xx:xx:xx:xx:xx:\2'),
+
+ # Strip host-name, domain-name, domain-search and url
+ (args.hostname, re.compile(r'(host-name|domain-name|domain-search|url) \S+'), r'\1 xxxxxx'),
+
+ # Strip user-names
+ (args.username, re.compile(r'(user|username|user-id) \S+'), r'\1 xxxxxx'),
+ # Strip full-name
+ (args.username, re.compile(r'(full-name) [ -_A-Z a-z]+'), r'\1 xxxxxx'),
+
+ # Strip DHCP static-mapping and shared network names
+ (args.dhcp, re.compile(r'(shared-network-name|static-mapping) \S+'), r'\1 xxxxxx'),
+
+ # Strip host/domain names
+ (args.domain, re.compile(r' (peer|remote-host|local-host|server) ([\w-]+\.)+[\w-]+'), r' \1 xxxxx.tld'),
+
+ # Strip BGP ASNs
+ (args.asn, re.compile(r'(bgp|remote-as) (\d+)'), r'\1 XXXXXX'),
+
+ # Strip LLDP location parameters
+ (args.lldp, re.compile(r'(altitude|datum|latitude|longitude|ca-value|country-code) (\S+)'), r'\1 xxxxxx'),
+
+ # Strip SNMP location
+ (args.snmp, re.compile(r'(location) \S+'), r'\1 xxxxxx'),
+ ]
+ strip_lines(stripping_rules)
diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py
new file mode 100644
index 0000000..42de696
--- /dev/null
+++ b/src/helpers/vyos-boot-config-loader.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 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 sys
+import pwd
+import grp
+import traceback
+from datetime import datetime
+
+from vyos.defaults import directories, config_status
+from vyos.configsession import ConfigSession, ConfigSessionError
+from vyos.configtree import ConfigTree
+from vyos.utils.process import cmd
+
+STATUS_FILE = config_status
+TRACE_FILE = '/tmp/boot-config-trace'
+
+CFG_GROUP = 'vyattacfg'
+
+trace_config = False
+
+if 'log' in directories:
+ LOG_DIR = directories['log']
+else:
+ LOG_DIR = '/var/log/vyatta'
+
+LOG_FILE = LOG_DIR + '/vyos-boot-config-loader.log'
+
+try:
+ with open('/proc/cmdline', 'r') as f:
+ cmdline = f.read()
+ if 'vyos-debug' in cmdline:
+ os.environ['VYOS_DEBUG'] = 'yes'
+ if 'vyos-config-debug' in cmdline:
+ os.environ['VYOS_DEBUG'] = 'yes'
+ trace_config = True
+except Exception as e:
+ print('{0}'.format(e))
+
+def write_config_status(status):
+ try:
+ with open(STATUS_FILE, 'w') as f:
+ f.write('{0}\n'.format(status))
+ except Exception as e:
+ print('{0}'.format(e))
+
+def trace_to_file(trace_file_name):
+ try:
+ with open(trace_file_name, 'w') as trace_file:
+ traceback.print_exc(file=trace_file)
+ except Exception as e:
+ print('{0}'.format(e))
+
+def failsafe(config_file_name):
+ fail_msg = """
+ !!!!!
+ There were errors loading the configuration
+ Please examine the errors in
+ {0}
+ and correct
+ !!!!!
+ """.format(TRACE_FILE)
+
+ print(fail_msg, file=sys.stderr)
+
+ users = [x[0] for x in pwd.getpwall()]
+ if 'vyos' in users:
+ return
+
+ try:
+ with open(config_file_name, 'r') as f:
+ config_file = f.read()
+ except Exception as e:
+ print("Catastrophic: no default config file "
+ "'{0}'".format(config_file_name))
+ sys.exit(1)
+
+ config = ConfigTree(config_file)
+ if not config.exists(['system', 'login', 'user', 'vyos',
+ 'authentication', 'encrypted-password']):
+ print("No password entry in default config file;")
+ print("unable to recover password for user 'vyos'.")
+ sys.exit(1)
+ else:
+ passwd = config.return_value(['system', 'login', 'user', 'vyos',
+ 'authentication',
+ 'encrypted-password'])
+
+ cmd(f"useradd --create-home --no-user-group --shell /bin/vbash --password '{passwd}' "\
+ "--groups frr,frrvty,vyattacfg,sudo,adm,dip,disk vyos")
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Must specify boot config file.")
+ sys.exit(1)
+ else:
+ file_name = sys.argv[1]
+
+ # Set user and group options, so that others will be able to commit
+ # Currently, the only caller does 'sg CFG_GROUP', but that may change
+ cfg_group = grp.getgrnam(CFG_GROUP)
+ os.setgid(cfg_group.gr_gid)
+
+ # Need to set file permissions to 775 so that every vyattacfg group
+ # member has write access to the running config
+ os.umask(0o002)
+
+ session = ConfigSession(os.getpid(), 'vyos-boot-config-loader')
+ env = session.get_session_env()
+
+ default_file_name = env['vyatta_sysconfdir'] + '/config.boot.default'
+
+ try:
+ with open(file_name, 'r') as f:
+ config_file = f.read()
+ except Exception:
+ write_config_status(1)
+ if trace_config:
+ failsafe(default_file_name)
+ trace_to_file(TRACE_FILE)
+ sys.exit(1)
+
+ try:
+ time_begin_load = datetime.now()
+ load_out = session.load_config(file_name)
+ time_end_load = datetime.now()
+ time_begin_commit = datetime.now()
+ commit_out = session.commit()
+ time_end_commit = datetime.now()
+ write_config_status(0)
+ except ConfigSessionError:
+ # If here, there is no use doing session.discard, as we have no
+ # recoverable config environment, and will only throw an error
+ write_config_status(1)
+ if trace_config:
+ failsafe(default_file_name)
+ trace_to_file(TRACE_FILE)
+ sys.exit(1)
+
+ time_elapsed_load = time_end_load - time_begin_load
+ time_elapsed_commit = time_end_commit - time_begin_commit
+
+ try:
+ if not os.path.exists(LOG_DIR):
+ os.mkdir(LOG_DIR)
+ with open(LOG_FILE, 'a') as f:
+ f.write('\n\n')
+ f.write('{0} Begin config load\n'
+ ''.format(time_begin_load))
+ f.write(load_out)
+ f.write('{0} End config load\n'
+ ''.format(time_end_load))
+ f.write('Elapsed time for config load: {0}\n'
+ ''.format(time_elapsed_load))
+ f.write('{0} Begin config commit\n'
+ ''.format(time_begin_commit))
+ f.write(commit_out)
+ f.write('{0} End config commit\n'
+ ''.format(time_end_commit))
+ f.write('Elapsed time for config commit: {0}\n'
+ ''.format(time_elapsed_commit))
+ except Exception as e:
+ print('{0}'.format(e))
diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh
new file mode 100644
index 0000000..d0b663f
--- /dev/null
+++ b/src/helpers/vyos-certbot-renew-pki.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+source /opt/vyatta/etc/functions/script-template
+/usr/libexec/vyos/conf_mode/pki.py certbot_renew
diff --git a/src/helpers/vyos-check-wwan.py b/src/helpers/vyos-check-wwan.py
new file mode 100644
index 0000000..334f08d
--- /dev/null
+++ b/src/helpers/vyos-check-wwan.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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/>.
+
+from vyos.configquery import VbashOpRun
+from vyos.configquery import ConfigTreeQuery
+
+from vyos.utils.network import is_wwan_connected
+
+conf = ConfigTreeQuery()
+dict = conf.get_config_dict(['interfaces', 'wwan'], key_mangling=('-', '_'),
+ get_first_key=True)
+
+for interface, interface_config in dict.items():
+ if not is_wwan_connected(interface):
+ if 'disable' in interface_config:
+ # do not restart this interface as it's disabled by the user
+ continue
+
+ op = VbashOpRun()
+ op.run(['connect', 'interface', interface])
+
+exit(0)
diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py
new file mode 100644
index 0000000..84860bd
--- /dev/null
+++ b/src/helpers/vyos-config-encrypt.py
@@ -0,0 +1,273 @@
+#!/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 shutil
+import sys
+
+from argparse import ArgumentParser
+from cryptography.fernet import Fernet
+from tempfile import NamedTemporaryFile
+from tempfile import TemporaryDirectory
+
+from vyos.tpm import clear_tpm_key
+from vyos.tpm import read_tpm_key
+from vyos.tpm import write_tpm_key
+from vyos.utils.io import ask_input, ask_yes_no
+from vyos.utils.process import cmd
+
+persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath'
+mount_paths = ['/config', '/opt/vyatta/etc/config']
+dm_device = '/dev/mapper/vyos_config'
+
+def is_opened():
+ return os.path.exists(dm_device)
+
+def get_current_image():
+ with open('/proc/cmdline', 'r') as f:
+ args = f.read().split(" ")
+ for arg in args:
+ if 'vyos-union' in arg:
+ k, v = arg.split("=")
+ path_split = v.split("/")
+ return path_split[-1]
+ return None
+
+def load_config(key):
+ if not key:
+ return
+
+ persist_path = cmd(persistpath_cmd).strip()
+ image_name = get_current_image()
+ image_path = os.path.join(persist_path, 'luks', image_name)
+
+ if not os.path.exists(image_path):
+ raise Exception("Encrypted config volume doesn't exist")
+
+ if is_opened():
+ print('Encrypted config volume is already mounted')
+ return
+
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+
+ for path in mount_paths:
+ cmd(f'mount /dev/mapper/vyos_config {path}')
+ cmd(f'chgrp -R vyattacfg {path}')
+
+ os.unlink(key_file)
+
+ return True
+
+def encrypt_config(key, recovery_key):
+ if is_opened():
+ raise Exception('An encrypted config volume is already mapped')
+
+ # Clear and write key to TPM
+ try:
+ clear_tpm_key()
+ except:
+ pass
+ write_tpm_key(key)
+
+ persist_path = cmd(persistpath_cmd).strip()
+ size = ask_input('Enter size of encrypted config partition (MB): ', numeric_only=True, default=512)
+
+ luks_folder = os.path.join(persist_path, 'luks')
+
+ if not os.path.isdir(luks_folder):
+ os.mkdir(luks_folder)
+
+ image_name = get_current_image()
+ image_path = os.path.join(luks_folder, image_name)
+
+ # Create file for encrypted config
+ cmd(f'fallocate -l {size}M {image_path}')
+
+ # Write TPM key for slot #1
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ # Format and add main key to volume
+ cmd(f'cryptsetup -q luksFormat {image_path} {key_file}')
+
+ if recovery_key:
+ # Write recovery key for slot 2
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(recovery_key)
+ recovery_key_file = f.name
+
+ cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}')
+
+ # Open encrypted volume and format with ext4
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+ cmd('mkfs.ext4 /dev/mapper/vyos_config')
+
+ with TemporaryDirectory() as d:
+ cmd(f'mount /dev/mapper/vyos_config {d}')
+
+ # Move /config to encrypted volume
+ shutil.copytree('/config', d, copy_function=shutil.move, dirs_exist_ok=True)
+
+ cmd(f'umount {d}')
+
+ os.unlink(key_file)
+
+ if recovery_key:
+ os.unlink(recovery_key_file)
+
+ for path in mount_paths:
+ cmd(f'mount /dev/mapper/vyos_config {path}')
+ cmd(f'chgrp vyattacfg {path}')
+
+ return True
+
+def decrypt_config(key):
+ if not key:
+ return
+
+ persist_path = cmd(persistpath_cmd).strip()
+ image_name = get_current_image()
+ image_path = os.path.join(persist_path, 'luks', image_name)
+
+ if not os.path.exists(image_path):
+ raise Exception("Encrypted config volume doesn't exist")
+
+ key_file = None
+
+ if not is_opened():
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+
+ # unmount encrypted volume mount points
+ for path in mount_paths:
+ if os.path.ismount(path):
+ cmd(f'umount {path}')
+
+ # If /config is populated, move to /config.old
+ if len(os.listdir('/config')) > 0:
+ print('Moving existing /config folder to /config.old')
+ shutil.move('/config', '/config.old')
+
+ # Temporarily mount encrypted volume and migrate files to /config on rootfs
+ with TemporaryDirectory() as d:
+ cmd(f'mount /dev/mapper/vyos_config {d}')
+
+ # Move encrypted volume to /config
+ shutil.copytree(d, '/config', copy_function=shutil.move, dirs_exist_ok=True)
+ cmd(f'chgrp -R vyattacfg /config')
+
+ cmd(f'umount {d}')
+
+ # Close encrypted volume
+ cmd('cryptsetup -q close vyos_config')
+
+ # Remove encrypted volume image file and key
+ if key_file:
+ os.unlink(key_file)
+ os.unlink(image_path)
+
+ try:
+ clear_tpm_key()
+ except:
+ pass
+
+ return True
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Must specify action.")
+ sys.exit(1)
+
+ parser = ArgumentParser(description='Config encryption')
+ parser.add_argument('--disable', help='Disable encryption', action="store_true")
+ parser.add_argument('--enable', help='Enable encryption', action="store_true")
+ parser.add_argument('--load', help='Load encrypted config volume', action="store_true")
+ args = parser.parse_args()
+
+ tpm_exists = os.path.exists('/sys/class/tpm/tpm0')
+
+ key = None
+ recovery_key = None
+ need_recovery = False
+
+ question_key_str = 'recovery key' if tpm_exists else 'key'
+
+ if tpm_exists:
+ if args.enable:
+ key = Fernet.generate_key()
+ elif args.disable or args.load:
+ try:
+ key = read_tpm_key()
+ need_recovery = False
+ except:
+ print('Failed to read key from TPM, recovery key required')
+ need_recovery = True
+ else:
+ need_recovery = True
+
+ if args.enable and not tpm_exists:
+ print('WARNING: VyOS will boot into a default config when encrypted without a TPM')
+ print('You will need to manually login with default credentials and use "encryption load"')
+ print('to mount the encrypted volume and use "load /config/config.boot"')
+
+ if not ask_yes_no('Are you sure you want to proceed?'):
+ sys.exit(0)
+
+ if need_recovery or (args.enable and not ask_yes_no(f'Automatically generate a {question_key_str}?', default=True)):
+ while True:
+ recovery_key = ask_input(f'Enter {question_key_str}:', default=None).encode()
+
+ if len(recovery_key) >= 32:
+ break
+
+ print('Invalid key - must be at least 32 characters, try again.')
+ else:
+ recovery_key = Fernet.generate_key()
+
+ try:
+ if args.disable:
+ decrypt_config(key or recovery_key)
+
+ print('Encrypted config volume has been disabled')
+ print('Contents have been migrated to /config on rootfs')
+ elif args.load:
+ load_config(key or recovery_key)
+
+ print('Encrypted config volume has been mounted')
+ print('Use "load /config/config.boot" to load configuration')
+ elif args.enable and tpm_exists:
+ encrypt_config(key, recovery_key)
+
+ print('Encrypted config volume has been enabled with TPM')
+ print('Backup the recovery key in a safe place!')
+ print('Recovery key: ' + recovery_key.decode())
+ elif args.enable:
+ encrypt_config(recovery_key)
+
+ print('Encrypted config volume has been enabled without TPM')
+ print('Backup the key in a safe place!')
+ print('Key: ' + recovery_key.decode())
+ except Exception as e:
+ word = 'decrypt' if args.disable or args.load else 'encrypt'
+ print(f'Failed to {word} config: {e}')
diff --git a/src/helpers/vyos-domain-resolver.py b/src/helpers/vyos-domain-resolver.py
new file mode 100644
index 0000000..57cfcab
--- /dev/null
+++ b/src/helpers/vyos-domain-resolver.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 json
+import time
+
+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.utils.commit import commit_in_progress
+from vyos.utils.dict import dict_search_args
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.xml_ref import get_defaults
+
+base = ['firewall']
+timeout = 300
+cache = False
+
+domain_state = {}
+
+ipv4_tables = {
+ 'ip vyos_mangle',
+ 'ip vyos_filter',
+ 'ip vyos_nat',
+ 'ip raw'
+}
+
+ipv6_tables = {
+ 'ip6 vyos_mangle',
+ 'ip6 vyos_filter',
+ 'ip6 raw'
+}
+
+def get_config(conf):
+ firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ default_values = get_defaults(base, get_first_key=True)
+
+ firewall = dict_merge(default_values, firewall)
+
+ global timeout, cache
+
+ if 'resolver_interval' in firewall:
+ timeout = int(firewall['resolver_interval'])
+
+ if 'resolver_cache' in firewall:
+ cache = True
+
+ fqdn_config_parse(firewall)
+
+ return firewall
+
+def resolve(domains, ipv6=False):
+ global domain_state
+
+ ip_list = set()
+
+ for domain in domains:
+ resolved = fqdn_resolve(domain, ipv6=ipv6)
+
+ if resolved and cache:
+ domain_state[domain] = resolved
+ elif not resolved:
+ if domain not in domain_state:
+ continue
+ resolved = domain_state[domain]
+
+ ip_list = ip_list | resolved
+ return ip_list
+
+def nft_output(table, set_name, ip_list):
+ output = [f'flush set {table} {set_name}']
+ if ip_list:
+ ip_str = ','.join(ip_list)
+ output.append(f'add element {table} {set_name} {{ {ip_str} }}')
+ return output
+
+def nft_valid_sets():
+ try:
+ valid_sets = []
+ sets_json = cmd('nft --json list sets')
+ sets_obj = json.loads(sets_json)
+
+ for obj in sets_obj['nftables']:
+ if 'set' in obj:
+ family = obj['set']['family']
+ table = obj['set']['table']
+ name = obj['set']['name']
+ valid_sets.append((f'{family} {table}', name))
+
+ return valid_sets
+ except:
+ return []
+
+def update(firewall):
+ conf_lines = []
+ count = 0
+
+ valid_sets = nft_valid_sets()
+
+ domain_groups = dict_search_args(firewall, 'group', 'domain_group')
+ if domain_groups:
+ for set_name, domain_config in domain_groups.items():
+ if 'address' not in domain_config:
+ continue
+
+ nft_set_name = f'D_{set_name}'
+ domains = domain_config['address']
+
+ ip_list = resolve(domains, ipv6=False)
+ for table in ipv4_tables:
+ if (table, nft_set_name) in valid_sets:
+ conf_lines += nft_output(table, nft_set_name, ip_list)
+
+ ip6_list = resolve(domains, ipv6=True)
+ for table in ipv6_tables:
+ if (table, nft_set_name) in valid_sets:
+ conf_lines += nft_output(table, nft_set_name, ip6_list)
+ count += 1
+
+ for set_name, domain in firewall['ip_fqdn'].items():
+ table = 'ip vyos_filter'
+ nft_set_name = f'FQDN_{set_name}'
+
+ ip_list = resolve([domain], ipv6=False)
+
+ if (table, nft_set_name) in valid_sets:
+ conf_lines += nft_output(table, nft_set_name, ip_list)
+ count += 1
+
+ for set_name, domain in firewall['ip6_fqdn'].items():
+ table = 'ip6 vyos_filter'
+ nft_set_name = f'FQDN_{set_name}'
+
+ ip_list = resolve([domain], ipv6=True)
+ if (table, nft_set_name) in valid_sets:
+ conf_lines += nft_output(table, nft_set_name, ip_list)
+ count += 1
+
+ nft_conf_str = "\n".join(conf_lines) + "\n"
+ code = run(f'nft --file -', input=nft_conf_str)
+
+ print(f'Updated {count} sets - result: {code}')
+
+if __name__ == '__main__':
+ print(f'VyOS domain resolver')
+
+ count = 1
+ while commit_in_progress():
+ if ( count % 60 == 0 ):
+ print(f'Commit still in progress after {count}s - waiting')
+ count += 1
+ time.sleep(1)
+
+ conf = ConfigTreeQuery()
+ firewall = get_config(conf)
+
+ print(f'interval: {timeout}s - cache: {cache}')
+
+ while True:
+ update(firewall)
+ time.sleep(timeout)
diff --git a/src/helpers/vyos-failover.py b/src/helpers/vyos-failover.py
new file mode 100644
index 0000000..3489743
--- /dev/null
+++ b/src/helpers/vyos-failover.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 argparse
+import json
+import socket
+import time
+
+from vyos.utils.process import rc_cmd
+from pathlib import Path
+from systemd import journal
+
+
+my_name = Path(__file__).stem
+
+
+def is_route_exists(route, gateway, interface, metric):
+ """Check if route with expected gateway, dev and metric exists"""
+ rc, data = rc_cmd(f'ip --json route show protocol failover {route} '
+ f'via {gateway} dev {interface} metric {metric}')
+ if rc == 0:
+ data = json.loads(data)
+ if len(data) > 0:
+ return True
+ return False
+
+
+def get_best_route_options(route, debug=False):
+ """
+ Return current best route ('gateway, interface, metric)
+
+ % get_best_route_options('203.0.113.1')
+ ('192.168.0.1', 'eth1', 1)
+
+ % get_best_route_options('203.0.113.254')
+ (None, None, None)
+ """
+ rc, data = rc_cmd(f'ip --detail --json route show protocol failover {route}')
+ if rc == 0:
+ data = json.loads(data)
+ if len(data) == 0:
+ print(f'\nRoute {route} for protocol failover was not found')
+ return None, None, None
+ # Fake metric 999 by default
+ # Search route with the lowest metric
+ best_metric = 999
+ for entry in data:
+ if debug: print('\n', entry)
+ metric = entry.get('metric')
+ gateway = entry.get('gateway')
+ iface = entry.get('dev')
+ if metric < best_metric:
+ best_metric = metric
+ best_gateway = gateway
+ best_interface = iface
+ if debug:
+ print(f'### Best_route exists: {route}, best_gateway: {best_gateway}, '
+ f'best_metric: {best_metric}, best_iface: {best_interface}')
+ return best_gateway, best_interface, best_metric
+
+
+def is_port_open(ip, port):
+ """
+ Check connection to remote host and port
+ Return True if host alive
+
+ % is_port_open('example.com', 8080)
+ True
+ """
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
+ s.settimeout(2)
+ try:
+ s.connect((ip, int(port)))
+ s.shutdown(socket.SHUT_RDWR)
+ return True
+ except:
+ return False
+ finally:
+ s.close()
+
+
+def is_target_alive(target_list=None,
+ iface='',
+ proto='icmp',
+ port=None,
+ debug=False,
+ policy='any-available') -> bool:
+ """Check the availability of each target in the target_list using
+ the specified protocol ICMP, ARP, TCP
+
+ Args:
+ target_list (list): A list of IP addresses or hostnames to check.
+ iface (str): The name of the network interface to use for the check.
+ proto (str): The protocol to use for the check. Options are 'icmp', 'arp', or 'tcp'.
+ port (int): The port number to use for the TCP check. Only applicable if proto is 'tcp'.
+ debug (bool): If True, print debug information during the check.
+ policy (str): The policy to use for the check. Options are 'any-available' or 'all-available'.
+
+ Returns:
+ bool: True if all targets are reachable according to the policy, False otherwise.
+
+ Example:
+ % is_target_alive(['192.0.2.1', '192.0.2.5'], 'eth1', proto='arp', policy='all-available')
+ True
+ """
+ if iface != '':
+ iface = f'-I {iface}'
+
+ num_reachable_targets = 0
+ for target in target_list:
+ match proto:
+ case 'icmp':
+ command = f'/usr/bin/ping -q {target} {iface} -n -c 2 -W 1'
+ rc, response = rc_cmd(command)
+ if debug:
+ print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]')
+ if rc == 0:
+ num_reachable_targets += 1
+ if policy == 'any-available':
+ return True
+
+ case 'arp':
+ command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface} {target}'
+ rc, response = rc_cmd(command)
+ if debug:
+ print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]')
+ if rc == 0:
+ num_reachable_targets += 1
+ if policy == 'any-available':
+ return True
+
+ case _ if proto == 'tcp' and port is not None:
+ if is_port_open(target, port):
+ num_reachable_targets += 1
+ if policy == 'any-available':
+ return True
+
+ case _:
+ return False
+
+ if policy == 'all-available' and num_reachable_targets == len(target_list):
+ return True
+
+ return False
+
+
+if __name__ == '__main__':
+ # Parse command arguments and get config
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-c',
+ '--config',
+ action='store',
+ help='Path to protocols failover configuration',
+ required=True,
+ type=Path)
+
+ args = parser.parse_args()
+ try:
+ config_path = Path(args.config)
+ config = json.loads(config_path.read_text())
+ except Exception as err:
+ print(
+ f'Configuration file "{config_path}" does not exist or malformed: {err}'
+ )
+ exit(1)
+
+ # Useful debug info to console, use debug = True
+ # sudo systemctl stop vyos-failover.service
+ # sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf
+ debug = False
+
+ while(True):
+
+ for route, route_config in config.get('route').items():
+
+ exists_gateway, exists_iface, exists_metric = get_best_route_options(route, debug=debug)
+
+ for next_hop, nexthop_config in route_config.get('next_hop').items():
+ conf_iface = nexthop_config.get('interface')
+ conf_metric = int(nexthop_config.get('metric'))
+ port = nexthop_config.get('check').get('port')
+ port_opt = f'port {port}' if port else ''
+ policy = nexthop_config.get('check').get('policy')
+ proto = nexthop_config.get('check').get('type')
+ target = nexthop_config.get('check').get('target')
+ timeout = nexthop_config.get('check').get('timeout')
+ onlink = 'onlink' if 'onlink' in nexthop_config else ''
+
+ # Route not found in the current routing table
+ if not is_route_exists(route, next_hop, conf_iface, conf_metric):
+ if debug: print(f" [NEW_ROUTE_DETECTED] route: [{route}]")
+ # Add route if check-target alive
+ if is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy):
+ if debug: print(f' [ ADD ] -- ip route add {route} via {next_hop} dev {conf_iface} '
+ f'metric {conf_metric} proto failover\n###')
+ rc, command = rc_cmd(f'ip route add {route} via {next_hop} dev {conf_iface} '
+ f'{onlink} metric {conf_metric} proto failover')
+ # If something is wrong and gateway not added
+ # Example: Error: Next-hop has invalid gateway.
+ if rc !=0:
+ if debug: print(f'{command} -- return-code [RC: {rc}] {next_hop} dev {conf_iface}')
+ else:
+ journal.send(f'ip route add {route} via {next_hop} dev {conf_iface} '
+ f'{onlink} metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name)
+ else:
+ if debug: print(f' [ TARGET_FAIL ] target checks fails for [{target}], do nothing')
+ journal.send(f'Check fail for route {route} target {target} proto {proto} '
+ f'{port_opt}', SYSLOG_IDENTIFIER=my_name)
+
+ # Route was added, check if the target is alive
+ # We should delete route if check fails only if route exists in the routing table
+ if not is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy) and \
+ is_route_exists(route, next_hop, conf_iface, conf_metric):
+ if debug:
+ print(f'Nexh_hop {next_hop} fail, target not response')
+ print(f' [ DEL ] -- ip route del {route} via {next_hop} dev {conf_iface} '
+ f'metric {conf_metric} proto failover [DELETE]')
+ rc_cmd(f'ip route del {route} via {next_hop} dev {conf_iface} metric {conf_metric} proto failover')
+ journal.send(f'ip route del {route} via {next_hop} dev {conf_iface} '
+ f'metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name)
+
+ time.sleep(int(timeout))
diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py
new file mode 100644
index 0000000..0123572
--- /dev/null
+++ b/src/helpers/vyos-interface-rescan.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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 stat
+import argparse
+import logging
+import netaddr
+
+from vyos.configtree import ConfigTree
+from vyos.defaults import directories
+from vyos.utils.permission import get_cfg_group_id
+
+debug = False
+
+vyos_udev_dir = directories['vyos_udev_dir']
+vyos_log_dir = directories['log']
+log_file = os.path.splitext(os.path.basename(__file__))[0]
+vyos_log_file = os.path.join(vyos_log_dir, log_file)
+
+logger = logging.getLogger(__name__)
+handler = logging.FileHandler(vyos_log_file, mode='a')
+formatter = logging.Formatter('%(levelname)s: %(message)s')
+handler.setFormatter(formatter)
+logger.addHandler(handler)
+
+passlist = {
+ '02:07:01' : 'Interlan',
+ '02:60:60' : '3Com',
+ '02:60:8c' : '3Com',
+ '02:a0:c9' : 'Intel',
+ '02:aa:3c' : 'Olivetti',
+ '02:cf:1f' : 'CMC',
+ '02:e0:3b' : 'Prominet',
+ '02:e6:d3' : 'BTI',
+ '52:54:00' : 'Realtek',
+ '52:54:4c' : 'Novell 2000',
+ '52:54:ab' : 'Realtec',
+ 'e2:0c:0f' : 'Kingston Technologies'
+}
+
+def is_multicast(addr: netaddr.eui.EUI) -> bool:
+ return bool(addr.words[0] & 0b1)
+
+def is_locally_administered(addr: netaddr.eui.EUI) -> bool:
+ return bool(addr.words[0] & 0b10)
+
+def is_on_passlist(hwid: str) -> bool:
+ top = hwid.rsplit(':', 3)[0]
+ if top in list(passlist):
+ return True
+ return False
+
+def is_persistent(hwid: str) -> bool:
+ addr = netaddr.EUI(hwid)
+ if is_multicast(addr):
+ return False
+ if is_locally_administered(addr) and not is_on_passlist(hwid):
+ return False
+ return True
+
+def get_wireless_physical_device(intf: str) -> str:
+ if 'wlan' not in intf:
+ return ''
+ try:
+ tmp = os.readlink(f'/sys/class/net/{intf}/phy80211')
+ except OSError:
+ logger.critical(f"Failed to read '/sys/class/net/{intf}/phy80211'")
+ return ''
+ phy = os.path.basename(tmp)
+ logger.info(f"wireless phy is {phy}")
+ return phy
+
+def get_interface_type(intf: str) -> str:
+ if 'eth' in intf:
+ intf_type = 'ethernet'
+ elif 'wlan' in intf:
+ intf_type = 'wireless'
+ else:
+ logger.critical('Unrecognized interface type!')
+ intf_type = ''
+ return intf_type
+
+def get_new_interfaces() -> dict:
+ """ Read any new interface data left in /run/udev/vyos by vyos_net_name
+ """
+ interfaces = {}
+
+ for intf in os.listdir(vyos_udev_dir):
+ path = os.path.join(vyos_udev_dir, intf)
+ try:
+ with open(path) as f:
+ hwid = f.read().rstrip()
+ except OSError as e:
+ logger.error(f"OSError {e}")
+ continue
+ interfaces[intf] = hwid
+
+ # reverse sort to simplify insertion in config
+ interfaces = {key: value for key, value in sorted(interfaces.items(),
+ reverse=True)}
+ return interfaces
+
+def filter_interfaces(intfs: dict) -> dict:
+ """ Ignore no longer existing interfaces or non-persistent mac addresses
+ """
+ filtered = {}
+
+ for intf, hwid in intfs.items():
+ if not os.path.isdir(os.path.join('/sys/class/net', intf)):
+ continue
+ if not is_persistent(hwid):
+ continue
+ filtered[intf] = hwid
+
+ return filtered
+
+def interface_rescan(config_path: str):
+ """ Read new data and update config file
+ """
+ interfaces = get_new_interfaces()
+
+ logger.debug(f"interfaces from udev: {interfaces}")
+
+ interfaces = filter_interfaces(interfaces)
+
+ logger.debug(f"filtered interfaces: {interfaces}")
+
+ try:
+ with open(config_path) as f:
+ config_file = f.read()
+ except OSError as e:
+ logger.critical(f"OSError {e}")
+ exit(1)
+
+ config = ConfigTree(config_file)
+
+ for intf, hwid in interfaces.items():
+ logger.info(f"Writing '{intf}' '{hwid}' to config file")
+ intf_type = get_interface_type(intf)
+ if not intf_type:
+ continue
+ if not config.exists(['interfaces', intf_type]):
+ config.set(['interfaces', intf_type])
+ config.set_tag(['interfaces', intf_type])
+ config.set(['interfaces', intf_type, intf, 'hw-id'], value=hwid)
+
+ if intf_type == 'wireless':
+ phy = get_wireless_physical_device(intf)
+ if not phy:
+ continue
+ config.set(['interfaces', intf_type, intf, 'physical-device'],
+ value=phy)
+
+ try:
+ with open(config_path, 'w') as f:
+ f.write(config.to_string())
+ except OSError as e:
+ logger.critical(f"OSError {e}")
+
+def main():
+ global debug
+
+ argparser = argparse.ArgumentParser(
+ formatter_class=argparse.RawTextHelpFormatter)
+ argparser.add_argument('configfile', type=str)
+ argparser.add_argument('--debug', action='store_true')
+ args = argparser.parse_args()
+
+ if args.debug:
+ debug = True
+ logger.setLevel(logging.DEBUG)
+ else:
+ logger.setLevel(logging.INFO)
+
+ configfile = args.configfile
+
+ # preserve vyattacfg group write access to running config
+ os.setgid(get_cfg_group_id())
+ os.umask(0o002)
+
+ # log file perms are not automatic; this could be cleaner by moving to a
+ # logging config file
+ os.chown(vyos_log_file, 0, get_cfg_group_id())
+ os.chmod(vyos_log_file,
+ stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH)
+
+ interface_rescan(configfile)
+
+if __name__ == '__main__':
+ main()
diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py
new file mode 100644
index 0000000..16083fd
--- /dev/null
+++ b/src/helpers/vyos-load-config.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+#
+#
+
+"""Load config file from within config session.
+Config file specified by URI or path (without scheme prefix).
+Example: load https://somewhere.net/some.config
+ or
+ load /tmp/some.config
+"""
+
+import os
+import sys
+import gzip
+import tempfile
+import vyos.defaults
+import vyos.remote
+from vyos.configsource import ConfigSourceSession, VyOSError
+from vyos.migrate import ConfigMigrate
+from vyos.migrate import ConfigMigrateError
+
+class LoadConfig(ConfigSourceSession):
+ """A subclass for calling 'loadFile'.
+ This does not belong in configsource.py, and only has a single caller.
+ """
+ def load_config(self, path):
+ return self._run(['/bin/cli-shell-api','loadFile',path])
+
+file_name = sys.argv[1] if len(sys.argv) > 1 else 'config.boot'
+configdir = vyos.defaults.directories['config']
+protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp']
+
+def get_local_config(filename):
+ if os.path.isfile(filename):
+ fname = filename
+ elif os.path.isfile(os.path.join(configdir, filename)):
+ fname = os.path.join(configdir, filename)
+ else:
+ sys.exit(f"No such file '{filename}'")
+
+ if fname.endswith('.gz'):
+ with gzip.open(fname, 'rb') as f:
+ try:
+ config_str = f.read().decode()
+ except OSError as e:
+ sys.exit(e)
+ else:
+ with open(fname, 'r') as f:
+ try:
+ config_str = f.read()
+ except OSError as e:
+ sys.exit(e)
+
+ return config_str
+
+if any(file_name.startswith(f'{x}://') for x in protocols):
+ config_string = vyos.remote.get_remote_config(file_name)
+ if not config_string:
+ sys.exit(f"No such config file at '{file_name}'")
+else:
+ config_string = get_local_config(file_name)
+
+config = LoadConfig()
+
+print(f"Loading configuration from '{file_name}'")
+
+with tempfile.NamedTemporaryFile() as fp:
+ with open(fp.name, 'w') as fd:
+ fd.write(config_string)
+
+ config_migrate = ConfigMigrate(fp.name)
+ try:
+ config_migrate.run()
+ except ConfigMigrateError as err:
+ sys.exit(err)
+
+ try:
+ config.load_config(fp.name)
+ except VyOSError as err:
+ sys.exit(err)
+
+if config.session_changed():
+ print("Load complete. Use 'commit' to make changes effective.")
+else:
+ print("No configuration changes to commit.")
diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py
new file mode 100644
index 0000000..5ef845a
--- /dev/null
+++ b/src/helpers/vyos-merge-config.py
@@ -0,0 +1,108 @@
+#!/usr/bin/python3
+
+# Copyright 2019-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/>.
+
+import sys
+import tempfile
+import vyos.defaults
+import vyos.remote
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.migrate import ConfigMigrate
+from vyos.migrate import ConfigMigrateError
+from vyos.utils.process import cmd
+from vyos.utils.process import DEVNULL
+
+if (len(sys.argv) < 2):
+ print("Need config file name to merge.")
+ print("Usage: merge <config file> [config path]")
+ sys.exit(0)
+
+file_name = sys.argv[1]
+
+configdir = vyos.defaults.directories['config']
+
+protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp']
+
+if any(x in file_name for x in protocols):
+ config_file = vyos.remote.get_remote_config(file_name)
+ if not config_file:
+ sys.exit("No config file by that name.")
+else:
+ canonical_path = "{0}/{1}".format(configdir, file_name)
+ first_err = None
+ try:
+ with open(canonical_path, 'r') as f:
+ config_file = f.read()
+ except Exception as err:
+ first_err = err
+ try:
+ with open(file_name, 'r') as f:
+ config_file = f.read()
+ except Exception as err:
+ print(first_err)
+ print(err)
+ sys.exit(1)
+
+with tempfile.NamedTemporaryFile() as file_to_migrate:
+ with open(file_to_migrate.name, 'w') as fd:
+ fd.write(config_file)
+
+ config_migrate = ConfigMigrate(file_to_migrate.name)
+ try:
+ config_migrate.run()
+ except ConfigMigrateError as e:
+ sys.exit(e)
+
+merge_config_tree = ConfigTree(config_file)
+
+effective_config = Config()
+effective_config_tree = effective_config._running_config
+
+effective_cmds = effective_config_tree.to_commands()
+merge_cmds = merge_config_tree.to_commands()
+
+effective_cmd_list = effective_cmds.splitlines()
+merge_cmd_list = merge_cmds.splitlines()
+
+effective_cmd_set = set(effective_cmd_list)
+add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ]
+
+path = None
+if (len(sys.argv) > 2):
+ path = sys.argv[2:]
+ if (not effective_config_tree.exists(path) and not
+ merge_config_tree.exists(path)):
+ print("path {} does not exist in either effective or merge"
+ " config; will use root.".format(path))
+ path = None
+ else:
+ path = " ".join(path)
+
+if path:
+ add_cmds = [ cmd for cmd in add_cmds if path in cmd ]
+
+for add in add_cmds:
+ try:
+ cmd(f'/opt/vyatta/sbin/my_{add}', shell=True, stderr=DEVNULL)
+ except OSError as err:
+ print(err)
+
+if effective_config.session_changed():
+ print("Merge complete. Use 'commit' to make changes effective.")
+else:
+ print("No configuration changes to commit.")
diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py
new file mode 100644
index 0000000..fa2ea0c
--- /dev/null
+++ b/src/helpers/vyos-save-config.py
@@ -0,0 +1,72 @@
+#!/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 os
+import re
+import sys
+from tempfile import NamedTemporaryFile
+from argparse import ArgumentParser
+
+from vyos.config import Config
+from vyos.remote import urlc
+from vyos.component_version import add_system_version
+from vyos.defaults import directories
+
+DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot')
+remote_save = None
+
+parser = ArgumentParser(description='Save configuration')
+parser.add_argument('file', type=str, nargs='?', help='Save configuration to file')
+parser.add_argument('--write-json-file', type=str, help='Save JSON of configuration to file')
+args = parser.parse_args()
+file = args.file
+json_file = args.write_json_file
+
+if file is not None:
+ save_file = file
+else:
+ save_file = DEFAULT_CONFIG_PATH
+
+if re.match(r'\w+:/', save_file):
+ try:
+ remote_save = urlc(save_file)
+ except ValueError as e:
+ sys.exit(e)
+
+config = Config()
+ct = config.get_config_tree(effective=True)
+
+# pylint: disable=consider-using-with
+write_file = save_file if remote_save is None else NamedTemporaryFile(delete=False).name
+
+# config_tree is None before boot configuration is complete;
+# automated saves should check boot_configuration_complete
+config_str = None if ct is None else ct.to_string()
+add_system_version(config_str, write_file)
+
+if json_file is not None and ct is not None:
+ try:
+ with open(json_file, 'w') as f:
+ f.write(ct.to_json())
+ except OSError as e:
+ print(f'failed to write JSON file: {e}')
+
+if remote_save is not None:
+ try:
+ remote_save.upload(write_file)
+ finally:
+ os.remove(write_file)
diff --git a/src/helpers/vyos-sudo.py b/src/helpers/vyos-sudo.py
new file mode 100644
index 0000000..75dd7f2
--- /dev/null
+++ b/src/helpers/vyos-sudo.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+
+# Copyright 2019 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/>.
+
+import os
+import sys
+
+from vyos.utils.permission import is_admin
+
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print('Missing command argument')
+ sys.exit(1)
+
+ if not is_admin():
+ print('This account is not authorized to run this command')
+ sys.exit(1)
+
+ os.execvp('sudo', ['sudo'] + sys.argv[1:])
diff --git a/src/helpers/vyos-vrrp-conntracksync.sh b/src/helpers/vyos-vrrp-conntracksync.sh
new file mode 100644
index 0000000..90fa77f
--- /dev/null
+++ b/src/helpers/vyos-vrrp-conntracksync.sh
@@ -0,0 +1,156 @@
+#!/bin/sh
+#
+# (C) 2008 by Pablo Neira Ayuso <pablo@netfilter.org>
+#
+# This software may be used and distributed according to the terms
+# of the GNU General Public License, incorporated herein by reference.
+#
+# Description:
+#
+# This is the script for primary-backup setups for keepalived
+# (http://www.keepalived.org). You may adapt it to make it work with other
+# high-availability managers.
+#
+# Modified by : Mohit Mehta <mohit@vyatta.com>
+# Slight modifications were made to this script for running with Vyatta
+# The original script came from 0.9.14 debian conntrack-tools package
+
+CONNTRACKD_BIN=/usr/sbin/conntrackd
+CONNTRACKD_LOCK=/var/lock/conntrack.lock
+CONNTRACKD_CONFIG=/run/conntrackd/conntrackd.conf
+FACILITY=daemon
+LEVEL=notice
+TAG=conntrack-tools
+LOGCMD="logger -t $TAG -p $FACILITY.$LEVEL"
+VRRP_GRP="VRRP sync-group [$2]"
+FAILOVER_STATE="/var/run/vyatta-conntrackd-failover-state"
+
+$LOGCMD "vyos-vrrp-conntracksync invoked at `date`"
+
+if ! systemctl is-active --quiet conntrackd.service; then
+ echo "conntrackd service not running"
+ exit 1
+fi
+
+if [ ! -e $FAILOVER_STATE ]; then
+ mkdir -p /var/run
+ touch $FAILOVER_STATE
+fi
+
+case "$1" in
+ master)
+ echo MASTER at `date` > $FAILOVER_STATE
+ $LOGCMD "`uname -n` transitioning to MASTER state for $VRRP_GRP"
+ #
+ # commit the external cache into the kernel table
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -c
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -c"
+ fi
+
+ #
+ # commit the expect entries to the kernel
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -c exp
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -ce exp"
+ fi
+
+ #
+ # flush the internal and the external caches
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -f
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -f"
+ fi
+
+ #
+ # resynchronize my internal cache to the kernel table
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -R
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -R"
+ fi
+
+ #
+ # send a bulk update to backups
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -B
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -B"
+ fi
+ ;;
+ backup)
+ echo BACKUP at `date` > $FAILOVER_STATE
+ $LOGCMD "`uname -n` transitioning to BACKUP state for $VRRP_GRP"
+ #
+ # is conntrackd running? request some statistics to check it
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -s
+ if [ $? -eq 1 ]
+ then
+ #
+ # something's wrong, do we have a lock file?
+ #
+ if [ -f $CONNTRACKD_LOCK ]
+ then
+ $LOGCMD "WARNING: conntrackd was not cleanly stopped."
+ $LOGCMD "If you suspect that it has crashed:"
+ $LOGCMD "1) Enable coredumps"
+ $LOGCMD "2) Try to reproduce the problem"
+ $LOGCMD "3) Post the coredump to netfilter-devel@vger.kernel.org"
+ rm -f $CONNTRACKD_LOCK
+ fi
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -d
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: cannot launch conntrackd"
+ exit 1
+ fi
+ fi
+ #
+ # shorten kernel conntrack timers to remove the zombie entries.
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -t
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -t"
+ fi
+
+ #
+ # request resynchronization with master firewall replica (if any)
+ # Note: this does nothing in the alarm approach.
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -n
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -n"
+ fi
+ ;;
+ fault)
+ echo FAULT at `date` > $FAILOVER_STATE
+ $LOGCMD "`uname -n` transitioning to FAULT state for $VRRP_GRP"
+ #
+ # shorten kernel conntrack timers to remove the zombie entries.
+ #
+ $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -t
+ if [ $? -eq 1 ]
+ then
+ $LOGCMD "ERROR: failed to invoke conntrackd -t"
+ fi
+ ;;
+ *)
+ echo UNKNOWN at `date` > $FAILOVER_STATE
+ $LOGCMD "ERROR: `uname -n` unknown state transition for $VRRP_GRP"
+ echo "Usage: vyos-vrrp-conntracksync.sh {master|backup|fault}"
+ exit 1
+ ;;
+esac
+
+exit 0
diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py
new file mode 100644
index 0000000..9d9aec3
--- /dev/null
+++ b/src/helpers/vyos_config_sync.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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
+import requests
+import urllib3
+import logging
+from typing import Optional, List, Tuple, Dict, Any
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.configtree import mask_inclusive
+from vyos.template import bracketize_ipv6
+
+
+CONFIG_FILE = '/run/config_sync_conf.conf'
+
+# Logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+logger.name = os.path.basename(__file__)
+
+# API
+API_HEADERS = {'Content-Type': 'application/json'}
+
+
+def post_request(url: str,
+ data: str,
+ headers: Dict[str, str]) -> requests.Response:
+ """Sends a POST request to the specified URL
+
+ Args:
+ url (str): The URL to send the POST request to.
+ data (Dict[str, Any]): The data to send with the POST request.
+ headers (Dict[str, str]): The headers to include with the POST request.
+
+ Returns:
+ requests.Response: The response object representing the server's response to the request
+ """
+
+ response = requests.post(url,
+ data=data,
+ headers=headers,
+ verify=False,
+ timeout=timeout)
+ return response
+
+
+
+def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
+ """Retrieves the configuration from the local server.
+
+ Args:
+ sections: List[list[str]]: The list of sections of the configuration
+ to retrieve, given as list of paths.
+
+ Returns:
+ Tuple[Dict[str, Any],Dict[str,Any]]: The tuple (mask, config) where:
+ - mask: The tree of paths of sections, as a dictionary.
+ - config: The subtree of masked config data, as a dictionary.
+ """
+
+ mask = ConfigTree('')
+ for section in sections:
+ mask.set(section)
+ mask_dict = json.loads(mask.to_json())
+
+ config = Config()
+ config_tree = config.get_config_tree()
+ masked = mask_inclusive(config_tree, mask)
+ config_dict = json.loads(masked.to_json())
+
+ return mask_dict, config_dict
+
+def set_remote_config(
+ address: str,
+ key: str,
+ op: str,
+ mask: Dict[str, Any],
+ config: Dict[str, Any],
+ port: int) -> Optional[Dict[str, Any]]:
+ """Loads the VyOS configuration in JSON format to a remote host.
+
+ Args:
+ address (str): The address of the remote host.
+ key (str): The key to use for loading the configuration.
+ op (str): The operation to perform (set or load).
+ mask (dict): The dict of paths in sections.
+ config (dict): The dict of masked config data.
+ port (int): The remote API port
+
+ Returns:
+ Optional[Dict[str, Any]]: The response from the remote host as a
+ dictionary, or None if a RequestException occurred.
+ """
+
+ headers = {'Content-Type': 'application/json'}
+
+ # Disable the InsecureRequestWarning
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+ url = f'https://{address}:{port}/configure-section'
+ data = json.dumps({
+ 'op': op,
+ 'mask': mask,
+ 'config': config,
+ 'key': key
+ })
+
+ try:
+ config = post_request(url, data, headers)
+ return config.json()
+ except requests.exceptions.RequestException as e:
+ print(f"An error occurred: {e}")
+ logger.error(f"An error occurred: {e}")
+ return None
+
+
+def is_section_revised(section: List[str]) -> bool:
+ from vyos.config_mgmt import is_node_revised
+ return is_node_revised(section)
+
+
+def config_sync(secondary_address: str,
+ secondary_key: str,
+ sections: List[list[str]],
+ mode: str,
+ secondary_port: int):
+ """Retrieve a config section from primary router in JSON format and send it to
+ secondary router
+ """
+ if not any(map(is_section_revised, sections)):
+ return
+
+ logger.info(
+ f"Config synchronization: Mode={mode}, Secondary={secondary_address}"
+ )
+
+ # Sync sections ("nat", "firewall", etc)
+ mask_dict, config_dict = retrieve_config(sections)
+ logger.debug(
+ f"Retrieved config for sections '{sections}': {config_dict}")
+
+ set_config = set_remote_config(address=secondary_address,
+ key=secondary_key,
+ op=mode,
+ mask=mask_dict,
+ config=config_dict,
+ port=secondary_port)
+
+ logger.debug(f"Set config for sections '{sections}': {set_config}")
+
+
+if __name__ == '__main__':
+ # Read configuration from file
+ if not os.path.exists(CONFIG_FILE):
+ logger.error(f"Post-commit: No config file '{CONFIG_FILE}' exists")
+ exit(0)
+
+ with open(CONFIG_FILE, 'r') as f:
+ config_data = f.read()
+
+ config = json.loads(config_data)
+
+ mode = config.get('mode')
+ secondary_address = config.get('secondary', {}).get('address')
+ secondary_address = bracketize_ipv6(secondary_address)
+ secondary_key = config.get('secondary', {}).get('key')
+ secondary_port = int(config.get('secondary', {}).get('port', 443))
+ sections = config.get('section')
+ timeout = int(config.get('secondary', {}).get('timeout'))
+
+ if not all([mode, secondary_address, secondary_key, sections]):
+ logger.error("Missing required configuration data for config synchronization.")
+ exit(0)
+
+ # Generate list_sections of sections/subsections
+ # [
+ # ['interfaces', 'pseudo-ethernet'], ['interfaces', 'virtual-ethernet'], ['nat'], ['nat66']
+ # ]
+ list_sections = []
+ for section, subsections in sections.items():
+ if subsections:
+ for subsection in subsections:
+ list_sections.append([section, subsection])
+ else:
+ list_sections.append([section])
+
+ config_sync(secondary_address, secondary_key, list_sections, mode, secondary_port)
diff --git a/src/helpers/vyos_net_name b/src/helpers/vyos_net_name
new file mode 100644
index 0000000..f5de182
--- /dev/null
+++ b/src/helpers/vyos_net_name
@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 re
+import time
+import logging
+import logging.handlers
+import tempfile
+from pathlib import Path
+from sys import argv
+
+from vyos.configtree import ConfigTree
+from vyos.defaults import directories
+from vyos.utils.process import cmd
+from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.locking import Lock
+from vyos.migrate import ConfigMigrate
+
+# Define variables
+vyos_udev_dir = directories['vyos_udev_dir']
+config_path = '/opt/vyatta/etc/config/config.boot'
+
+
+def is_available(intfs: dict, intf_name: str) -> bool:
+ """Check if interface name is already assigned"""
+ if intf_name in list(intfs.values()):
+ return False
+ return True
+
+
+def find_available(intfs: dict, prefix: str) -> str:
+ """Find lowest indexed iterface name that is not assigned"""
+ index_list = [
+ int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x
+ ]
+ index_list.sort()
+ # find 'holes' in list, if any
+ missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list))
+ if missing:
+ return f'{prefix}{missing[0]}'
+
+ return f'{prefix}{len(index_list)}'
+
+
+def mod_ifname(ifname: str) -> str:
+ """Check interface with names eX and return ifname on the next format eth{ifindex} - 2"""
+ if re.match('^e[0-9]+$', ifname):
+ intf = ifname.split('e')
+ if intf[1]:
+ if int(intf[1]) >= 2:
+ return 'eth' + str(int(intf[1]) - 2)
+ else:
+ return 'eth' + str(intf[1])
+
+ return ifname
+
+
+def get_biosdevname(ifname: str) -> str:
+ """Use legacy vyatta-biosdevname to query for name
+
+ This is carried over for compatability only, and will likely be dropped
+ going forward.
+ XXX: This throws an error, and likely has for a long time, unnoticed
+ since vyatta_net_name redirected stderr to /dev/null.
+ """
+ intf = mod_ifname(ifname)
+
+ if 'eth' not in intf:
+ return intf
+ if os.path.isdir('/proc/xen'):
+ return intf
+
+ time.sleep(1)
+
+ try:
+ biosname = cmd(f'/sbin/biosdevname --policy all_ethN -i {ifname}')
+ except Exception as e:
+ logger.error(f'biosdevname error: {e}')
+ biosname = ''
+
+ return intf if biosname == '' else biosname
+
+
+def leave_rescan_hint(intf_name: str, hwid: str):
+ """Write interface information reported by udev
+
+ This script is called while the root mount is still read-only. Leave
+ information in /run/udev: file name, the interface; contents, the
+ hardware id.
+ """
+ try:
+ os.mkdir(vyos_udev_dir)
+ except FileExistsError:
+ pass
+ except Exception as e:
+ logger.critical(f'Error creating rescan hint directory: {e}')
+ exit(1)
+
+ try:
+ with open(os.path.join(vyos_udev_dir, intf_name), 'w') as f:
+ f.write(hwid)
+ except OSError as e:
+ logger.critical(f'OSError {e}')
+
+
+def get_configfile_interfaces() -> dict:
+ """Read existing interfaces from config file"""
+ interfaces: dict = {}
+
+ if not os.path.isfile(config_path):
+ # If the case, then we are running off of livecd; return empty
+ return interfaces
+
+ try:
+ with open(config_path) as f:
+ config_file = f.read()
+ except OSError as e:
+ logger.critical(f'OSError {e}')
+ exit(1)
+
+ try:
+ config = ConfigTree(config_file)
+ except Exception:
+ try:
+ logger.debug('updating component version string syntax')
+ # this will update the component version string syntax,
+ # required for updates 1.2 --> 1.3/1.4
+ with tempfile.NamedTemporaryFile() as fp:
+ with open(fp.name, 'w') as fd:
+ fd.write(config_file)
+ config_migrate = ConfigMigrate(fp.name)
+ if config_migrate.syntax_update_needed():
+ config_migrate.update_syntax()
+ config_migrate.write_config()
+ with open(fp.name) as fd:
+ config_file = fd.read()
+
+ config = ConfigTree(config_file)
+
+ except Exception as e:
+ logger.critical(f'ConfigTree error: {e}')
+ exit(1)
+
+ base = ['interfaces', 'ethernet']
+ if config.exists(base):
+ eth_intfs = config.list_nodes(base)
+ for intf in eth_intfs:
+ path = base + [intf, 'hw-id']
+ if not config.exists(path):
+ logger.warning(f"no 'hw-id' entry for {intf}")
+ continue
+ hwid = config.return_value(path)
+ if hwid in list(interfaces):
+ logger.warning(
+ f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
+ )
+ continue
+ interfaces[hwid] = intf
+
+ base = ['interfaces', 'wireless']
+ if config.exists(base):
+ wlan_intfs = config.list_nodes(base)
+ for intf in wlan_intfs:
+ path = base + [intf, 'hw-id']
+ if not config.exists(path):
+ logger.warning(f"no 'hw-id' entry for {intf}")
+ continue
+ hwid = config.return_value(path)
+ if hwid in list(interfaces):
+ logger.warning(
+ f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
+ )
+ continue
+ interfaces[hwid] = intf
+
+ logger.debug(f'config file entries: {interfaces}')
+
+ return interfaces
+
+
+def add_assigned_interfaces(intfs: dict):
+ """Add interfaces found by previous invocation of udev rule"""
+ if not os.path.isdir(vyos_udev_dir):
+ return
+
+ for intf in os.listdir(vyos_udev_dir):
+ path = os.path.join(vyos_udev_dir, intf)
+ try:
+ with open(path) as f:
+ hwid = f.read().rstrip()
+ except OSError as e:
+ logger.error(f'OSError {e}')
+ continue
+ intfs[hwid] = intf
+
+
+def on_boot_event(intf_name: str, hwid: str, predefined: str = '') -> str:
+ """Called on boot by vyos-router: 'coldplug' in vyatta_net_name"""
+ logger.info(f'lookup {intf_name}, {hwid}')
+ interfaces = get_configfile_interfaces()
+ logger.debug(f'config file interfaces are {interfaces}')
+
+ if hwid in list(interfaces):
+ logger.info(f"use mapping from config file: '{hwid}' -> '{interfaces[hwid]}'")
+ return interfaces[hwid]
+
+ add_assigned_interfaces(interfaces)
+ logger.debug(f'adding assigned interfaces: {interfaces}')
+
+ if predefined:
+ newname = predefined
+ logger.info(f"predefined interface name for '{intf_name}' is '{newname}'")
+ else:
+ newname = get_biosdevname(intf_name)
+ logger.info(f"biosdevname returned '{newname}' for '{intf_name}'")
+
+ if not is_available(interfaces, newname):
+ prefix = re.sub(r'\d+$', '', newname)
+ newname = find_available(interfaces, prefix)
+
+ logger.info(f"new name for '{intf_name}' is '{newname}'")
+
+ leave_rescan_hint(newname, hwid)
+
+ return newname
+
+
+def hotplug_event():
+ # Not yet implemented, since interface-rescan will only be run on boot.
+ pass
+
+
+if __name__ == '__main__':
+ # Set up logging to syslog
+ syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
+ formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s')
+ syslog_handler.setFormatter(formatter)
+
+ logger = logging.getLogger()
+ logger.addHandler(syslog_handler)
+ logger.setLevel(logging.DEBUG)
+
+ logger.debug(f'Started with arguments: {argv}')
+
+ if len(argv) > 3:
+ predef_name = argv[3]
+ else:
+ predef_name = ''
+
+ lock = Lock('vyos_net_name')
+ # Wait 60 seconds for other running scripts to finish
+ lock.acquire(60)
+
+ if not boot_configuration_complete():
+ res = on_boot_event(argv[1], argv[2], predefined=predef_name)
+ logger.debug(f'on boot, returned name is {res}')
+ print(res)
+ else:
+ logger.debug('boot configuration complete')
+
+ lock.release()
+ logger.debug('Finished')
diff --git a/src/init/vyos-config b/src/init/vyos-config
new file mode 100644
index 0000000..3564270
--- /dev/null
+++ b/src/init/vyos-config
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+while [ ! -f /tmp/vyos-config-status ]
+do
+ sleep 1
+done
+
+status=$(cat /tmp/vyos-config-status)
+
+if [ -z "$1" ]; then
+ if [ $status -ne 0 ]; then
+ echo "Configuration error"
+ else
+ echo "Configuration success"
+ fi
+fi
diff --git a/src/init/vyos-router b/src/init/vyos-router
new file mode 100644
index 0000000..8825cc1
--- /dev/null
+++ b/src/init/vyos-router
@@ -0,0 +1,575 @@
+#!/bin/bash
+# Copyright (C) 2021-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/>.
+
+. /lib/lsb/init-functions
+
+: ${vyatta_env:=/etc/default/vyatta}
+source $vyatta_env
+
+declare progname=${0##*/}
+declare action=$1; shift
+
+declare -x BOOTFILE=$vyatta_sysconfdir/config/config.boot
+declare -x DEFAULT_BOOTFILE=$vyatta_sysconfdir/config.boot.default
+
+# If vyos-config= boot option is present, use that file instead
+for x in $(cat /proc/cmdline); do
+ [[ $x = vyos-config=* ]] || continue
+ VYOS_CONFIG="${x#vyos-config=}"
+done
+
+if [ ! -z "$VYOS_CONFIG" ]; then
+ if [ -r "$VYOS_CONFIG" ]; then
+ echo "Config selected manually: $VYOS_CONFIG"
+ declare -x BOOTFILE="$VYOS_CONFIG"
+ else
+ echo "WARNING: Could not read selected config file, using default!"
+ fi
+fi
+
+declare -a subinit
+declare -a all_subinits=( firewall )
+
+if [ $# -gt 0 ] ; then
+ for s in $@ ; do
+ [ -x ${vyatta_sbindir}/${s}.init ] && subinit[${#subinit}]=$s
+ done
+else
+ for s in ${all_subinits[@]} ; do
+ [ -x ${vyatta_sbindir}/${s}.init ] && subinit[${#subinit}]=$s
+ done
+fi
+
+GROUP=vyattacfg
+
+# easy way to make empty file without any command
+empty()
+{
+ >$1
+}
+
+# check if bootup of this portion is disabled
+disabled () {
+ grep -q -w no-vyos-$1 /proc/cmdline
+}
+
+# Load encrypted config volume
+mount_encrypted_config() {
+ persist_path=$(/opt/vyatta/sbin/vyos-persistpath)
+ if [ $? == 0 ]; then
+ if [ -e $persist_path/boot ]; then
+ image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//')
+
+ if [ -z "$image_name" ]; then
+ return
+ fi
+
+ if [ ! -f $persist_path/luks/$image_name ]; then
+ return
+ fi
+
+ vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null)
+
+ if [ $? -ne 0 ]; then
+ echo "ERROR: Failed to fetch encryption key from TPM. Encrypted config volume has not been mounted"
+ echo "Use 'encryption load' to load volume with recovery key"
+ echo "or 'encryption disable' to decrypt volume with recovery key"
+ return
+ fi
+
+ echo $vyos_tpm_key | tr -d '\r\n' | cryptsetup open $persist_path/luks/$image_name vyos_config --key-file=-
+
+ if [ $? -ne 0 ]; then
+ echo "ERROR: Failed to decrypt config volume. Encrypted config volume has not been mounted"
+ echo "Use 'encryption load' to load volume with recovery key"
+ echo "or 'encryption disable' to decrypt volume with recovery key"
+ return
+ fi
+
+ mount /dev/mapper/vyos_config /config
+ mount /dev/mapper/vyos_config $vyatta_sysconfdir/config
+
+ echo "Mounted encrypted config volume"
+ fi
+ fi
+}
+
+unmount_encrypted_config() {
+ persist_path=$(/opt/vyatta/sbin/vyos-persistpath)
+ if [ $? == 0 ]; then
+ if [ -e $persist_path/boot ]; then
+ image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//')
+
+ if [ -z "$image_name" ]; then
+ return
+ fi
+
+ if [ ! -f $persist_path/luks/$image_name ]; then
+ return
+ fi
+
+ umount /config
+ umount $vyatta_sysconfdir/config
+
+ cryptsetup close vyos_config
+ fi
+ fi
+}
+
+# if necessary, provide initial config
+init_bootfile () {
+ # define and version default boot config if not present
+ if [ ! -r $DEFAULT_BOOTFILE ]; then
+ if [ -f $vyos_data_dir/config.boot.default ]; then
+ cp $vyos_data_dir/config.boot.default $DEFAULT_BOOTFILE
+ $vyos_libexec_dir/add-system-version.py >> $DEFAULT_BOOTFILE
+ fi
+ fi
+ if [ ! -r $BOOTFILE ] ; then
+ if [ -f $DEFAULT_BOOTFILE ]; then
+ cp $DEFAULT_BOOTFILE $BOOTFILE
+ else
+ $vyos_libexec_dir/add-system-version.py > $BOOTFILE
+ fi
+ chgrp ${GROUP} $BOOTFILE
+ chmod 660 $BOOTFILE
+ fi
+}
+
+# if necessary, migrate initial config
+migrate_bootfile ()
+{
+ if [ -x $vyos_libexec_dir/run-config-migration.py ]; then
+ log_progress_msg migrate
+ sg ${GROUP} -c "$vyos_libexec_dir/run-config-migration.py $BOOTFILE"
+ fi
+}
+
+# configure system-specific settings
+system_config ()
+{
+ if [ -x $vyos_libexec_dir/run-config-activation.py ]; then
+ log_progress_msg system
+ sg ${GROUP} -c "$vyos_libexec_dir/run-config-activation.py $BOOTFILE"
+ fi
+}
+
+# load the initial config
+load_bootfile ()
+{
+ log_progress_msg configure
+ (
+ if [ -f /etc/default/vyatta-load-boot ]; then
+ # build-specific environment for boot-time config loading
+ source /etc/default/vyatta-load-boot
+ fi
+ if [ -x $vyos_libexec_dir/vyos-boot-config-loader.py ]; then
+ sg ${GROUP} -c "$vyos_libexec_dir/vyos-boot-config-loader.py $BOOTFILE"
+ fi
+ )
+}
+
+# restore if missing pre-config script
+restore_if_missing_preconfig_script ()
+{
+ if [ ! -x ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script ]; then
+ mkdir -p ${vyatta_sysconfdir}/config/scripts
+ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts
+ chmod 775 ${vyatta_sysconfdir}/config/scripts
+ cp ${vyos_rootfs_dir}/opt/vyatta/etc/config/scripts/vyos-preconfig-bootup.script ${vyatta_sysconfdir}/config/scripts/
+ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script
+ chmod 750 ${vyatta_sysconfdir}/config/scripts/vyos-preconfig-bootup.script
+ fi
+}
+
+# execute the pre-config script
+run_preconfig_script ()
+{
+ if [ -x $vyatta_sysconfdir/config/scripts/vyos-preconfig-bootup.script ]; then
+ $vyatta_sysconfdir/config/scripts/vyos-preconfig-bootup.script
+ fi
+}
+
+# restore if missing post-config script
+restore_if_missing_postconfig_script ()
+{
+ if [ ! -x ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script ]; then
+ mkdir -p ${vyatta_sysconfdir}/config/scripts
+ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts
+ chmod 775 ${vyatta_sysconfdir}/config/scripts
+ cp ${vyos_rootfs_dir}/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script ${vyatta_sysconfdir}/config/scripts/
+ chgrp ${GROUP} ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script
+ chmod 750 ${vyatta_sysconfdir}/config/scripts/vyos-postconfig-bootup.script
+ fi
+}
+
+# execute the post-config scripts
+run_postconfig_scripts ()
+{
+ if [ -x $vyatta_sysconfdir/config/scripts/vyatta-postconfig-bootup.script ]; then
+ $vyatta_sysconfdir/config/scripts/vyatta-postconfig-bootup.script
+ fi
+ if [ -x $vyatta_sysconfdir/config/scripts/vyos-postconfig-bootup.script ]; then
+ $vyatta_sysconfdir/config/scripts/vyos-postconfig-bootup.script
+ fi
+}
+
+run_postupgrade_script ()
+{
+ if [ -f $vyatta_sysconfdir/config/.upgraded ]; then
+ # Run the system script
+ /usr/libexec/vyos/system/post-upgrade
+
+ # Run user scripts
+ if [ -d $vyatta_sysconfdir/config/scripts/post-upgrade.d ]; then
+ run-parts $vyatta_sysconfdir/config/scripts/post-upgrade.d
+ fi
+ rm -f $vyatta_sysconfdir/config/.upgraded
+ fi
+}
+
+#
+# On image booted machines, we need to mount /boot from the image-specific
+# boot directory so that kernel package installation will put the
+# files in the right place. We also have to mount /boot/grub from the
+# system-wide grub directory so that tools that edit the grub.cfg
+# file will find it in the expected location.
+#
+bind_mount_boot ()
+{
+ persist_path=$(/opt/vyatta/sbin/vyos-persistpath)
+ if [ $? == 0 ]; then
+ if [ -e $persist_path/boot ]; then
+ image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//')
+
+ if [ -n "$image_name" ]; then
+ mount --bind $persist_path/boot/$image_name /boot
+ if [ $? -ne 0 ]; then
+ echo "Couldn't bind mount /boot"
+ fi
+
+ if [ ! -d /boot/grub ]; then
+ mkdir /boot/grub
+ fi
+
+ mount --bind $persist_path/boot/grub /boot/grub
+ if [ $? -ne 0 ]; then
+ echo "Couldn't bind mount /boot/grub"
+ fi
+ fi
+ fi
+ fi
+}
+
+clear_or_override_config_files ()
+{
+ for conf in snmp/snmpd.conf snmp/snmptrapd.conf snmp/snmp.conf \
+ keepalived/keepalived.conf cron.d/vyos-crontab \
+ ipvsadm.rules default/ipvsadm resolv.conf
+ do
+ if [ -s /etc/$conf ] ; then
+ empty /etc/$conf
+ chmod 0644 /etc/$conf
+ fi
+ done
+}
+
+update_interface_config ()
+{
+ if [ -d /run/udev/vyos ]; then
+ $vyos_libexec_dir/vyos-interface-rescan.py $BOOTFILE
+ fi
+}
+
+cleanup_post_commit_hooks () {
+ # Remove links from the post-commit hooks directory.
+ # note that this approach only supports hooks that are "configured",
+ # i.e., it does not support hooks that need to always be present.
+ cpostdir=$(cli-shell-api getPostCommitHookDir)
+ # exclude commit hooks that need to always be present
+ excluded="00vyos-sync 10vyatta-log-commit.pl 99vyos-user-postcommit-hooks"
+ if [ -d "$cpostdir" ]; then
+ for f in $cpostdir/*; do
+ if [[ ! $excluded =~ $(basename $f) ]]; then
+ rm -f $cpostdir/$(basename $f)
+ fi
+ done
+ fi
+}
+
+# These are all the default security setting which are later
+# overridden when configuration is read. These are the values the
+# system defaults.
+security_reset ()
+{
+
+ # restore NSS cofniguration back to sane system defaults
+ # will be overwritten later when configuration is loaded
+ cat <<EOF >/etc/nsswitch.conf
+passwd: files
+group: files
+shadow: files
+gshadow: files
+
+# Per T2678, commenting out myhostname
+hosts: files dns #myhostname
+networks: files
+
+protocols: db files
+services: db files
+ethers: db files
+rpc: db files
+
+netgroup: nis
+EOF
+
+ # restore PAM back to virgin state (no radius/tacacs services)
+ pam-auth-update --disable radius-mandatory radius-optional
+ rm -f /etc/pam_radius_auth.conf
+ pam-auth-update --disable tacplus-mandatory tacplus-optional
+ rm -f /etc/tacplus_nss.conf /etc/tacplus_servers
+ # and no Google authenticator for 2FA/MFA
+ pam-auth-update --disable mfa-google-authenticator
+
+ # Certain configuration files are re-generated by the configuration
+ # subsystem and must reside under /etc and can not easily be moved to /run.
+ # So on every boot we simply delete any remaining files and let the CLI
+ # regenearte them.
+
+ # PPPoE
+ rm -f /etc/ppp/peers/pppoe* /etc/ppp/peers/wlm*
+
+ # IPSec
+ rm -rf /etc/ipsec.conf /etc/ipsec.secrets
+ find /etc/swanctl -type f | xargs rm -f
+
+ # limit cleanup
+ rm -f /etc/security/limits.d/10-vyos.conf
+
+ # iproute2 cleanup
+ rm -f /etc/iproute2/rt_tables.d/vyos-*.conf
+
+ # Container
+ rm -f /etc/containers/storage.conf /etc/containers/registries.conf /etc/containers/containers.conf
+ # Clean all networks and re-create them from our CLI
+ rm -f /etc/containers/networks/*
+
+ # System Options (SSH/cURL)
+ rm -f /etc/ssh/ssh_config.d/*vyos*.conf
+ rm -f /etc/curlrc
+}
+
+# XXX: T3885 - generate persistend DHCPv6 DUID (Type4 - UUID based)
+gen_duid ()
+{
+ DUID_FILE="/var/lib/dhcpv6/dhcp6c_duid"
+ UUID_FILE="/sys/class/dmi/id/product_uuid"
+ UUID_FILE_ALT="/sys/class/dmi/id/product_serial"
+ if [ ! -f ${UUID_FILE} ] && [ ! -f ${UUID_FILE_ALT} ]; then
+ return 1
+ fi
+
+ # DUID is based on the BIOS/EFI UUID. We omit additional - characters
+ if [ -f ${UUID_FILE} ]; then
+ UUID=$(cat ${UUID_FILE} | tr -d -)
+ fi
+ if [ -z ${UUID} ]; then
+ UUID=$(uuidgen --sha1 --namespace @dns --name $(cat ${UUID_FILE_ALT}) | tr -d -)
+ fi
+ # Add DUID type4 (UUID) information
+ DUID_TYPE="0004"
+
+ # The length-information (as per RFC6355 UUID is 128 bits long) is in big-endian
+ # format - beware when porting to ARM64. The length field consists out of the
+ # UUID (128 bit + 16 bits DUID type) resulting in hex 12.
+ DUID_LEN="0012"
+ if [ "$(echo -n I | od -to2 | head -n1 | cut -f2 -d" " | cut -c6 )" -eq 1 ]; then
+ # true on little-endian (x86) systems
+ DUID_LEN="1200"
+ fi
+
+ for i in $(echo -n ${DUID_LEN}${DUID_TYPE}${UUID} | sed 's/../& /g'); do
+ echo -ne "\x$i"
+ done > ${DUID_FILE}
+}
+
+start ()
+{
+ # reset and clean config files
+ security_reset || log_failure_msg "security reset failed"
+
+ # some legacy directories migrated over from old rl-system.init
+ mkdir -p /var/run/vyatta /var/log/vyatta
+ chgrp vyattacfg /var/run/vyatta /var/log/vyatta
+ chmod 775 /var/run/vyatta /var/log/vyatta
+
+ log_daemon_msg "Waiting for NICs to settle down"
+ # On boot time udev migth take a long time to reorder nic's, this will ensure that
+ # all udev activity is completed and all nics presented at boot-time will have their
+ # final name before continuing with vyos-router initialization.
+ SECONDS=0
+ udevadm settle
+ STATUS=$?
+ log_progress_msg "settled in ${SECONDS}sec."
+ log_end_msg ${STATUS}
+
+ # mountpoint for bpf maps required by xdp
+ mount -t bpf none /sys/fs/bpf
+
+ # Clear out Debian APT source config file
+ empty /etc/apt/sources.list
+
+ # Generate DHCPv6 DUID
+ gen_duid || log_failure_msg "could not generate DUID"
+
+ # Mount a temporary filesystem for container networks.
+ # Configuration should be loaded from VyOS cli.
+ cni_dir="/etc/cni/net.d"
+ [ ! -d ${cni_dir} ] && mkdir -p ${cni_dir}
+ mount -t tmpfs none ${cni_dir}
+
+ # Init firewall
+ nfct helper add rpc inet tcp
+ nfct helper add rpc inet udp
+ nfct helper add tns inet tcp
+ nfct helper add rpc inet6 tcp
+ nfct helper add rpc inet6 udp
+ nfct helper add tns inet6 tcp
+ nft --file /usr/share/vyos/vyos-firewall-init.conf || log_failure_msg "could not initiate firewall rules"
+
+ # As VyOS does not execute commands that are not present in the CLI we call
+ # the script by hand to have a single source for the login banner and MOTD
+ ${vyos_conf_scripts_dir}/system_console.py || log_failure_msg "could not reset serial console"
+ ${vyos_conf_scripts_dir}/system_login_banner.py || log_failure_msg "could not reset motd and issue files"
+ ${vyos_conf_scripts_dir}/system_option.py || log_failure_msg "could not reset system option files"
+ ${vyos_conf_scripts_dir}/system_ip.py || log_failure_msg "could not reset system IPv4 options"
+ ${vyos_conf_scripts_dir}/system_ipv6.py || log_failure_msg "could not reset system IPv6 options"
+ ${vyos_conf_scripts_dir}/system_conntrack.py || log_failure_msg "could not reset conntrack subsystem"
+ ${vyos_conf_scripts_dir}/container.py || log_failure_msg "could not reset container subsystem"
+
+ clear_or_override_config_files || log_failure_msg "could not reset config files"
+
+ # enable some debugging before loading the configuration
+ if grep -q vyos-debug /proc/cmdline; then
+ log_action_begin_msg "Enable runtime debugging options"
+ touch /tmp/vyos.container.debug
+ touch /tmp/vyos.ifconfig.debug
+ touch /tmp/vyos.frr.debug
+ touch /tmp/vyos.container.debug
+ touch /tmp/vyos.smoketest.debug
+ fi
+
+ log_action_begin_msg "Mounting VyOS Config"
+ # ensure the vyatta_configdir supports a large number of inodes since
+ # the config hierarchy is often inode-bound (instead of size).
+ # impose a minimum and then scale up dynamically with the actual size
+ # of the system memory.
+ local tmem=$(sed -n 's/^MemTotal: \+\([0-9]\+\) kB$/\1/p' /proc/meminfo)
+ local tpages
+ local tmpfs_opts="nosuid,nodev,mode=775,nr_inodes=0" #automatically allocate inodes
+ mount -o $tmpfs_opts -t tmpfs none ${vyatta_configdir} \
+ && chgrp ${GROUP} ${vyatta_configdir}
+ log_action_end_msg $?
+
+ mount_encrypted_config
+
+ # T5239: early read of system hostname as this value is read-only once during
+ # FRR initialisation
+ tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "system host-name")
+ hostnamectl set-hostname --static "$tmp"
+
+ ${vyos_conf_scripts_dir}/system_frr.py || log_failure_msg "could not reset FRR config"
+ # If for any reason FRR was not started by system_frr.py - start it anyways.
+ # This is a safety net!
+ systemctl start frr.service
+
+ disabled bootfile || init_bootfile
+
+ cleanup_post_commit_hooks
+
+ log_daemon_msg "Starting VyOS router"
+ disabled migrate || migrate_bootfile
+
+ restore_if_missing_preconfig_script
+
+ run_preconfig_script
+
+ run_postupgrade_script
+
+ update_interface_config
+
+ disabled system_config || system_config
+
+ for s in ${subinit[@]} ; do
+ if ! disabled $s; then
+ log_progress_msg $s
+ if ! ${vyatta_sbindir}/${s}.init start
+ then log_failure_msg
+ exit 1
+ fi
+ fi
+ done
+
+ bind_mount_boot
+
+ disabled configure || load_bootfile
+ log_end_msg $?
+
+ telinit q
+ chmod g-w,o-w /
+
+ restore_if_missing_postconfig_script
+
+ run_postconfig_scripts
+ tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "protocols rpki cache")
+ if [[ ! -z "$tmp" ]]; then
+ vtysh -c "rpki start"
+ fi
+}
+
+stop()
+{
+ local -i status=0
+ log_daemon_msg "Stopping VyOS router"
+ for ((i=${#sub_inits[@]} - 1; i >= 0; i--)) ; do
+ s=${subinit[$i]}
+ log_progress_msg $s
+ ${vyatta_sbindir}/${s}.init stop
+ let status\|=$?
+ done
+ log_end_msg $status
+ log_action_begin_msg "Un-mounting VyOS Config"
+ umount ${vyatta_configdir}
+ log_action_end_msg $?
+
+ systemctl stop frr.service
+
+ unmount_encrypted_config
+}
+
+case "$action" in
+ start) start ;;
+ stop) stop ;;
+ restart|force-reload) stop && start ;;
+ *) log_failure_msg "usage: $progname [ start|stop|restart ] [ subinit ... ]" ;
+ false ;;
+esac
+
+exit $?
+
+# Local Variables:
+# mode: shell-script
+# sh-indentation: 4
+# End:
diff --git a/src/migration-scripts/bgp/0-to-1 b/src/migration-scripts/bgp/0-to-1
new file mode 100644
index 0000000..a2f3343
--- /dev/null
+++ b/src/migration-scripts/bgp/0-to-1
@@ -0,0 +1,40 @@
+# 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/>.
+
+# T3417: migrate BGP tagNode to node as we can only have one BGP process
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['protocols', 'bgp']
+
+ if not config.exists(base) or not config.is_tag(base):
+ # Nothing to do
+ return
+
+ # Only one BGP process is supported, thus this operation is savea
+ asn = config.list_nodes(base)
+ bgp_base = base + asn
+
+ # We need a temporary copy of the config
+ tmp_base = ['protocols', 'bgp2']
+ config.copy(bgp_base, tmp_base)
+
+ # Now it's save to delete the old configuration
+ config.delete(base)
+
+ # Rename temporary copy to new final config and set new "local-as" option
+ config.rename(tmp_base, 'bgp')
+ config.set(base + ['local-as'], value=asn[0])
diff --git a/src/migration-scripts/bgp/1-to-2 b/src/migration-scripts/bgp/1-to-2
new file mode 100644
index 0000000..c0fc3b0
--- /dev/null
+++ b/src/migration-scripts/bgp/1-to-2
@@ -0,0 +1,64 @@
+# 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/>.
+
+# T3741: no-ipv4-unicast is now enabled by default
+# T5937: Migrate IPv6 BGP Neighbor Peer Groups
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'bgp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # This is now a default option - simply delete it.
+ # As it was configured explicitly - we can also bail out early as we need to
+ # do nothing!
+ if config.exists(base + ['parameters', 'default', 'no-ipv4-unicast']):
+ config.delete(base + ['parameters', 'default', 'no-ipv4-unicast'])
+
+ # Check if the "default" node is now empty, if so - remove it
+ if len(config.list_nodes(base + ['parameters', 'default'])) == 0:
+ config.delete(base + ['parameters', 'default'])
+
+ # Check if the "default" node is now empty, if so - remove it
+ if len(config.list_nodes(base + ['parameters'])) == 0:
+ config.delete(base + ['parameters'])
+ else:
+ # As we now install a new default option into BGP we need to migrate all
+ # existing BGP neighbors and restore the old behavior
+ if config.exists(base + ['neighbor']):
+ for neighbor in config.list_nodes(base + ['neighbor']):
+ peer_group = base + ['neighbor', neighbor, 'peer-group']
+ if config.exists(peer_group):
+ peer_group_name = config.return_value(peer_group)
+ # peer group enables old behavior for neighbor - bail out
+ if config.exists(base + ['peer-group', peer_group_name, 'address-family', 'ipv4-unicast']):
+ continue
+
+ afi_ipv4 = base + ['neighbor', neighbor, 'address-family', 'ipv4-unicast']
+ if not config.exists(afi_ipv4):
+ config.set(afi_ipv4)
+
+ # Migrate IPv6 AFI peer-group
+ if config.exists(base + ['neighbor']):
+ for neighbor in config.list_nodes(base + ['neighbor']):
+ tmp_path = base + ['neighbor', neighbor, 'address-family', 'ipv6-unicast', 'peer-group']
+ if config.exists(tmp_path):
+ peer_group = config.return_value(tmp_path)
+ config.set(base + ['neighbor', neighbor, 'peer-group'], value=peer_group)
+ config.delete(tmp_path)
diff --git a/src/migration-scripts/bgp/2-to-3 b/src/migration-scripts/bgp/2-to-3
new file mode 100644
index 0000000..d8bc34d
--- /dev/null
+++ b/src/migration-scripts/bgp/2-to-3
@@ -0,0 +1,30 @@
+# Copyright 2022-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/>.
+
+# T4257: Discussion on changing BGP autonomous system number syntax
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ # Check if BGP is even configured. Then check if local-as exists, then add the system-as, then remove the local-as. This is for global configuration.
+ if config.exists(['protocols', 'bgp']):
+ if config.exists(['protocols', 'bgp', 'local-as']):
+ config.rename(['protocols', 'bgp', 'local-as'], 'system-as')
+
+ # Check if vrf names are configured. Then check if local-as exists inside of a name, then add the system-as, then remove the local-as. This is for vrf configuration.
+ if config.exists(['vrf', 'name']):
+ for vrf in config.list_nodes(['vrf', 'name']):
+ if config.exists(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as']):
+ config.rename(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as'], 'system-as')
diff --git a/src/migration-scripts/bgp/3-to-4 b/src/migration-scripts/bgp/3-to-4
new file mode 100644
index 0000000..842aef0
--- /dev/null
+++ b/src/migration-scripts/bgp/3-to-4
@@ -0,0 +1,43 @@
+# Copyright 2023-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/>.
+
+# T5150: Rework CLI definitions to apply route-maps between routing daemons
+# and zebra/kernel
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ bgp_base = ['protocols', 'bgp']
+ # Check if BGP is configured - if so, migrate the CLI node
+ if config.exists(bgp_base):
+ if config.exists(bgp_base + ['route-map']):
+ tmp = config.return_value(bgp_base + ['route-map'])
+
+ config.set(['system', 'ip', 'protocol', 'bgp', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ip', 'protocol'])
+ config.delete(bgp_base + ['route-map'])
+
+
+ # Check if vrf names are configured. Check if BGP is configured - if so, migrate
+ # the CLI node(s)
+ if config.exists(['vrf', 'name']):
+ for vrf in config.list_nodes(['vrf', 'name']):
+ vrf_base = ['vrf', 'name', vrf]
+ if config.exists(vrf_base + ['protocols', 'bgp', 'route-map']):
+ tmp = config.return_value(vrf_base + ['protocols', 'bgp', 'route-map'])
+
+ config.set(vrf_base + ['ip', 'protocol', 'bgp', 'route-map'], value=tmp)
+ config.set_tag(vrf_base + ['ip', 'protocol', 'bgp'])
+ config.delete(vrf_base + ['protocols', 'bgp', 'route-map'])
diff --git a/src/migration-scripts/bgp/4-to-5 b/src/migration-scripts/bgp/4-to-5
new file mode 100644
index 0000000..d779eb1
--- /dev/null
+++ b/src/migration-scripts/bgp/4-to-5
@@ -0,0 +1,46 @@
+# Copyright 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/>.
+
+# Delete 'protocols bgp address-family ipv6-unicast route-target vpn
+# import/export', if 'protocols bgp address-family ipv6-unicast
+# route-target vpn both' exists
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ bgp_base = ['protocols', 'bgp']
+ # Delete 'import/export' in default vrf if 'both' exists
+ if config.exists(bgp_base):
+ for address_family in ['ipv4-unicast', 'ipv6-unicast']:
+ rt_path = bgp_base + ['address-family', address_family, 'route-target',
+ 'vpn']
+ if config.exists(rt_path + ['both']):
+ if config.exists(rt_path + ['import']):
+ config.delete(rt_path + ['import'])
+ if config.exists(rt_path + ['export']):
+ config.delete(rt_path + ['export'])
+
+ # Delete import/export in vrfs if both exists
+ if config.exists(['vrf', 'name']):
+ for vrf in config.list_nodes(['vrf', 'name']):
+ vrf_base = ['vrf', 'name', vrf]
+ for address_family in ['ipv4-unicast', 'ipv6-unicast']:
+ rt_path = vrf_base + bgp_base + ['address-family', address_family,
+ 'route-target', 'vpn']
+ if config.exists(rt_path + ['both']):
+ if config.exists(rt_path + ['import']):
+ config.delete(rt_path + ['import'])
+ if config.exists(rt_path + ['export']):
+ config.delete(rt_path + ['export'])
diff --git a/src/migration-scripts/cluster/1-to-2 b/src/migration-scripts/cluster/1-to-2
new file mode 100644
index 0000000..5ca4531
--- /dev/null
+++ b/src/migration-scripts/cluster/1-to-2
@@ -0,0 +1,178 @@
+# Copyright 2023-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/>.
+
+import re
+import sys
+
+from vyos.configtree import ConfigTree
+from vyos.base import MigrationError
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['cluster']):
+ # Cluster is not set -- nothing to do at all
+ return
+
+ # If at least one cluster group is defined, we have real work to do.
+ # If there are no groups, we remove the top-level cluster node at the end of this script anyway.
+ if config.exists(['cluster', 'group']):
+ # First, gather timer and interface settings to duplicate them in all groups,
+ # since in the old cluster they are global, but in VRRP they are always per-group
+
+ global_interface = None
+ if config.exists(['cluster', 'interface']):
+ global_interface = config.return_value(['cluster', 'interface'])
+ else:
+ # Such configs shouldn't exist in practice because interface is a required option.
+ # But since it's possible to specify interface inside 'service' options,
+ # we may be able to convert such configs nonetheless.
+ print("Warning: incorrect cluster config: interface is not defined.", file=sys.stderr)
+
+ # There are three timers: advertise-interval, dead-interval, and monitor-dead-interval
+ # Only the first one makes sense for the VRRP, we translate it to advertise-interval
+ advertise_interval = None
+ if config.exists(['cluster', 'keepalive-interval']):
+ advertise_interval = config.return_value(['cluster', 'keepalive-interval'])
+
+ if advertise_interval is not None:
+ # Cluster had all timers in milliseconds, so we need to convert them to seconds
+ # And ensure they are not shorter than one second
+ advertise_interval = int(advertise_interval) // 1000
+ if advertise_interval < 1:
+ advertise_interval = 1
+
+ # Cluster had password as a global option, in VRRP it's per-group
+ password = None
+ if config.exists(['cluster', 'pre-shared-secret']):
+ password = config.return_value(['cluster', 'pre-shared-secret'])
+
+ # Set up the stage for converting cluster groups to VRRP groups
+ free_vrids = set(range(1,255))
+ vrrp_base_path = ['high-availability', 'vrrp', 'group']
+ if not config.exists(vrrp_base_path):
+ # If VRRP is not set up, create a node and set it to 'tag node'
+ # Setting it to 'tag' is not mandatory but it's better to be consistent
+ # with configs produced by 'save'
+ config.set(vrrp_base_path)
+ config.set_tag(vrrp_base_path)
+ else:
+ # If there are VRRP groups already, we need to find the set of unused VRID numbers to avoid conflicts
+ existing_vrids = set()
+ for vg in config.list_nodes(vrrp_base_path):
+ existing_vrids.add(int(config.return_value(vrrp_base_path + [vg, 'vrid'])))
+ free_vrids = free_vrids.difference(existing_vrids)
+
+ # Now handle cluster groups
+ groups = config.list_nodes(['cluster', 'group'])
+ for g in groups:
+ base_path = ['cluster', 'group', g]
+ service_names = config.return_values(base_path + ['service'])
+
+ # Cluster used to allow services other than IP addresses, at least nominally
+ # Whether that ever worked is a big question, but we need to consider that,
+ # since configs with custom services are definitely impossible to meaningfully migrate now
+ services = {"ip": [], "other": []}
+ for s in service_names:
+ if re.match(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2})(/[a-z]+\d+)?$', s):
+ services["ip"].append(s)
+ else:
+ services["other"].append(s)
+
+ if services["other"]:
+ err_str = "Cluster config includes non-IP address services and cannot be migrated"
+ print(err_str, file=sys.stderr)
+ raise MigrationError(err_str)
+
+ # Cluster allowed virtual IPs for different interfaces within a single group.
+ # VRRP groups are by definition bound to interfaces, so we cannot migrate such configurations.
+ # Thus we need to find out if all addresses either leave the interface unspecified
+ # (in that case the global 'cluster interface' option is used),
+ # or have the same interface, or have the same interface as the global 'cluster interface'.
+
+ # First, we collect all addresses and check if they have interface specified
+ # If not, we substitute the global interface option
+ # or throw an error if it's not in the config.
+ ips = []
+ for ip in services["ip"]:
+ ip_with_intf = re.match(r'^(?P<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/(?P<intf>[a-z]+\d+)$', ip)
+ if ip_with_intf:
+ ips.append({"ip": ip_with_intf.group("ip"), "interface": ip_with_intf.group("intf")})
+ else:
+ if global_interface is not None:
+ ips.append({"ip": ip, "interface": global_interface})
+ else:
+ err_str = "Cluster group has addresses without interfaces and 'cluster interface' is not specified."
+ print(f'Error: {err_str}', file=sys.stderr)
+ raise MigrationError(err_str)
+
+ # Then we check if all addresses are for the same interface.
+ intfs_set = set(map(lambda i: i["interface"], ips))
+ if len(intfs_set) > 1:
+ err_str = "Cluster group has addresses for different interfaces"
+ print(f'Error: {err_str}', file=sys.stderr)
+ raise MigrationError(err_str)
+
+ # If we got this far, the group is migratable.
+
+ # Extract the interface from the set -- we know there's only a single member.
+ interface = intfs_set.pop()
+
+ addresses = list(map(lambda i: i["ip"], ips))
+ vrrp_path = ['high-availability', 'vrrp', 'group', g]
+
+ # If there's already a VRRP group with exactly the same name,
+ # we probably shouldn't try to make up a unique name, just leave migration to the user...
+ if config.exists(vrrp_path):
+ err_str = "VRRP group with the same name already exists"
+ print(f'Error: {err_str}', file=sys.stderr)
+ raise MigrationError(err_str)
+
+ config.set(vrrp_path + ['interface'], value=interface)
+ for a in addresses:
+ config.set(vrrp_path + ['virtual-address'], value=a, replace=False)
+
+ # Take the next free VRID and assign it to the group
+ vrid = free_vrids.pop()
+ config.set(vrrp_path + ['vrid'], value=vrid)
+
+ # Convert the monitor option to VRRP ping health check
+ if config.exists(base_path + ['monitor']):
+ monitor_ip = config.return_value(base_path + ['monitor'])
+ config.set(vrrp_path + ['health-check', 'ping'], value=monitor_ip)
+
+ # Convert "auto-failback" to "no-preempt", if necessary
+ if config.exists(base_path + ['auto-failback']):
+ # It's a boolean node that requires "true" or "false"
+ # so if it exists we still need to check its value
+ auto_failback = config.return_value(base_path + ['auto-failback'])
+ if auto_failback == "false":
+ config.set(vrrp_path + ['no-preempt'])
+ else:
+ # It's "true" or we assume it is, which means preemption is desired,
+ # and in VRRP config it's the default
+ pass
+ else:
+ # The old default for that option is false
+ config.set(vrrp_path + ['no-preempt'])
+
+ # Inject settings from the global cluster config that have to be per-group in VRRP
+ if advertise_interval is not None:
+ config.set(vrrp_path + ['advertise-interval'], value=advertise_interval)
+
+ if password is not None:
+ config.set(vrrp_path + ['authentication', 'password'], value=password)
+ config.set(vrrp_path + ['authentication', 'type'], value='plaintext-password')
+
+ # Finally, clean up the old cluster node
+ config.delete(['cluster'])
diff --git a/src/migration-scripts/config-management/0-to-1 b/src/migration-scripts/config-management/0-to-1
new file mode 100644
index 0000000..44c6856
--- /dev/null
+++ b/src/migration-scripts/config-management/0-to-1
@@ -0,0 +1,24 @@
+# Copyright 2018-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/>.
+
+# Add commit-revisions option if it doesn't exist
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if config.exists(['system', 'config-management', 'commit-revisions']):
+ # Nothing to do
+ return
+ config.set(['system', 'config-management', 'commit-revisions'], value='200')
diff --git a/src/migration-scripts/conntrack-sync/1-to-2 b/src/migration-scripts/conntrack-sync/1-to-2
new file mode 100644
index 0000000..3e10e98
--- /dev/null
+++ b/src/migration-scripts/conntrack-sync/1-to-2
@@ -0,0 +1,46 @@
+# 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/>.
+
+# VyOS 1.2 crux allowed configuring a lower or upper case loglevel. This
+# is no longer supported as the input data is validated and will lead to
+# an error. If user specifies an upper case logleve, make it lowercase
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'conntrack-sync']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ base_accept_proto = base + ['accept-protocol']
+ if config.exists(base_accept_proto):
+ tmp = config.return_value(base_accept_proto)
+ config.delete(base_accept_proto)
+ for protocol in tmp.split(','):
+ config.set(base_accept_proto, value=protocol, replace=False)
+
+ base_ignore_addr = base + ['ignore-address', 'ipv4']
+ if config.exists(base_ignore_addr):
+ tmp = config.return_values(base_ignore_addr)
+ config.delete(base_ignore_addr)
+ for address in tmp:
+ config.set(base + ['ignore-address'], value=address, replace=False)
+
+ # we no longer support cluster mode
+ base_cluster = base + ['failover-mechanism', 'cluster']
+ if config.exists(base_cluster):
+ config.delete(base_cluster)
diff --git a/src/migration-scripts/conntrack/1-to-2 b/src/migration-scripts/conntrack/1-to-2
new file mode 100644
index 0000000..0a4fb3d
--- /dev/null
+++ b/src/migration-scripts/conntrack/1-to-2
@@ -0,0 +1,26 @@
+# 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/>.
+
+# Delete "set system conntrack modules gre" option
+
+from vyos.configtree import ConfigTree
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['system', 'conntrack', 'modules', 'gre']):
+ return
+
+ # Delete abandoned node
+ config.delete(['system', 'conntrack', 'modules', 'gre'])
diff --git a/src/migration-scripts/conntrack/2-to-3 b/src/migration-scripts/conntrack/2-to-3
new file mode 100644
index 0000000..5ad4e63
--- /dev/null
+++ b/src/migration-scripts/conntrack/2-to-3
@@ -0,0 +1,31 @@
+# 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/>.
+
+# Conntrack syntax version 3
+# Enables all conntrack modules (previous default behaviour) and omits manually disabled modules.
+
+from vyos.configtree import ConfigTree
+
+module_path = ['system', 'conntrack', 'modules']
+
+def migrate(config: ConfigTree) -> None:
+ # Go over all conntrack modules available as of v1.3.0.
+ for module in ['ftp', 'h323', 'nfs', 'pptp', 'sip', 'sqlnet', 'tftp']:
+ # 'disable' is being phased out.
+ if config.exists(module_path + [module, 'disable']):
+ config.delete(module_path + [module])
+ # If it wasn't manually 'disable'd, it was enabled by default.
+ else:
+ config.set(module_path + [module])
diff --git a/src/migration-scripts/conntrack/3-to-4 b/src/migration-scripts/conntrack/3-to-4
new file mode 100644
index 0000000..679a260
--- /dev/null
+++ b/src/migration-scripts/conntrack/3-to-4
@@ -0,0 +1,30 @@
+# Copyright 2023-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/>.
+
+# Add support for IPv6 conntrack ignore, move existing nodes to `system conntrack ignore ipv4`
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'conntrack']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['ignore', 'rule']):
+ config.set(base + ['ignore', 'ipv4'])
+ config.copy(base + ['ignore', 'rule'], base + ['ignore', 'ipv4', 'rule'])
+ config.delete(base + ['ignore', 'rule'])
diff --git a/src/migration-scripts/conntrack/4-to-5 b/src/migration-scripts/conntrack/4-to-5
new file mode 100644
index 0000000..775fe74
--- /dev/null
+++ b/src/migration-scripts/conntrack/4-to-5
@@ -0,0 +1,39 @@
+# Copyright 2023-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/>.
+
+# T5779: system conntrack timeout custom
+# Before:
+# Protocols tcp, udp and icmp allowed. When using udp it did not work
+# Only ipv4 custom timeout rules
+# Now:
+# Valid protocols are only tcp or udp.
+# Extend functionality to ipv6 and move ipv4 custom rules to new node:
+# set system conntrack timeout custom [ipv4 | ipv6] rule <rule> ...
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'conntrack']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['timeout', 'custom', 'rule']):
+ for rule in config.list_nodes(base + ['timeout', 'custom', 'rule']):
+ if config.exists(base + ['timeout', 'custom', 'rule', rule, 'protocol', 'tcp']):
+ config.set(base + ['timeout', 'custom', 'ipv4', 'rule'])
+ config.copy(base + ['timeout', 'custom', 'rule', rule], base + ['timeout', 'custom', 'ipv4', 'rule', rule])
+ config.delete(base + ['timeout', 'custom', 'rule'])
diff --git a/src/migration-scripts/container/0-to-1 b/src/migration-scripts/container/0-to-1
new file mode 100644
index 0000000..99102a5
--- /dev/null
+++ b/src/migration-scripts/container/0-to-1
@@ -0,0 +1,65 @@
+# Copyright 2022-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/>.
+
+# T4870: change underlaying container filesystem from vfs to overlay
+
+import os
+import shutil
+
+from vyos.configtree import ConfigTree
+from vyos.utils.process import call
+
+base = ['container', 'name']
+
+def migrate(config: ConfigTree) -> None:
+ # Check if containers exist and we need to perform image manipulation
+ if config.exists(base):
+ for container in config.list_nodes(base):
+ # Stop any given container first
+ call(f'sudo systemctl stop vyos-container-{container}.service')
+ # Export container image for later re-import to new filesystem. We store
+ # the backup on a real disk as a tmpfs (like /tmp) could probably lack
+ # memory if a host has too many containers stored.
+ image_name = config.return_value(base + [container, 'image'])
+ call(f'sudo podman image save --quiet --output /root/{container}.tar --format oci-archive {image_name}')
+
+ # No need to adjust the strage driver online (this is only used for testing and
+ # debugging on a live system) - it is already overlay2 when the migration script
+ # is run during system update. But the specified driver in the image is actually
+ # overwritten by the still present VFS filesystem on disk. Thus podman still
+ # thinks it uses VFS until we delete the libpod directory under:
+ # /usr/lib/live/mount/persistence/container/storage
+ #call('sed -i "s/vfs/overlay2/g" /etc/containers/storage.conf /usr/share/vyos/templates/container/storage.conf.j2')
+
+ base_path = '/usr/lib/live/mount/persistence/container/storage'
+ for dir in ['libpod', 'vfs', 'vfs-containers', 'vfs-images', 'vfs-layers']:
+ if os.path.exists(f'{base_path}/{dir}'):
+ shutil.rmtree(f'{base_path}/{dir}')
+
+ # Now all remaining information about VFS is gone and we operate in overlayfs2
+ # filesystem mode. Time to re-import the images.
+ if config.exists(base):
+ for container in config.list_nodes(base):
+ # Export container image for later re-import to new filesystem
+ image_name = config.return_value(base + [container, 'image'])
+ image_path = f'/root/{container}.tar'
+ call(f'sudo podman image load --quiet --input {image_path}')
+
+ # Start any given container first
+ call(f'sudo systemctl start vyos-container-{container}.service')
+
+ # Delete temporary container image
+ if os.path.exists(image_path):
+ os.unlink(image_path)
diff --git a/src/migration-scripts/container/1-to-2 b/src/migration-scripts/container/1-to-2
new file mode 100644
index 0000000..c12dd8e
--- /dev/null
+++ b/src/migration-scripts/container/1-to-2
@@ -0,0 +1,32 @@
+# Copyright 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/>.
+
+# T6208: container: rename "cap-add" CLI node to "capability"
+
+from vyos.configtree import ConfigTree
+
+base = ['container', 'name']
+
+def migrate(config: ConfigTree) -> None:
+
+ # Check if containers exist and we need to perform image manipulation
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for container in config.list_nodes(base):
+ cap_path = base + [container, 'cap-add']
+ if config.exists(cap_path):
+ config.rename(cap_path, 'capability')
diff --git a/src/migration-scripts/dhcp-relay/1-to-2 b/src/migration-scripts/dhcp-relay/1-to-2
new file mode 100644
index 0000000..54cd8d6
--- /dev/null
+++ b/src/migration-scripts/dhcp-relay/1-to-2
@@ -0,0 +1,29 @@
+# Copyright 2018-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/>.
+
+# Delete "set service dhcp-relay relay-options port" option
+# Delete "set service dhcpv6-relay listen-port" option
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not (config.exists(['service', 'dhcp-relay', 'relay-options', 'port']) or config.exists(['service', 'dhcpv6-relay', 'listen-port'])):
+ # Nothing to do
+ return
+
+ # Delete abandoned node
+ config.delete(['service', 'dhcp-relay', 'relay-options', 'port'])
+ # Delete abandoned node
+ config.delete(['service', 'dhcpv6-relay', 'listen-port'])
diff --git a/src/migration-scripts/dhcp-server/10-to-11 b/src/migration-scripts/dhcp-server/10-to-11
new file mode 100644
index 0000000..f54a4c7
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/10-to-11
@@ -0,0 +1,28 @@
+# Copyright 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/>.
+
+# T6171: rename "service dhcp-server failover" to "service dhcp-server high-availability"
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['failover']):
+ config.rename(base + ['failover'],'high-availability')
diff --git a/src/migration-scripts/dhcp-server/4-to-5 b/src/migration-scripts/dhcp-server/4-to-5
new file mode 100644
index 0000000..a655515
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/4-to-5
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+
+# Copyright 2018-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/>.
+
+# Removes boolean operator from:
+# - "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable (true|false)"
+# - "set service dhcp-server shared-network-name <xyz> authoritative (true|false)"
+# - "set service dhcp-server disabled (true|false)"
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['service', 'dhcp-server']):
+ # Nothing to do
+ return
+ else:
+ base = ['service', 'dhcp-server']
+ # Make node "set service dhcp-server dynamic-dns-update enable (true|false)" valueless
+ if config.exists(base + ['dynamic-dns-update']):
+ bool_val = config.return_value(base + ['dynamic-dns-update', 'enable'])
+
+ # Delete the node with the old syntax
+ config.delete(base + ['dynamic-dns-update'])
+ if str(bool_val) == 'true':
+ # Enable dynamic-dns-update with new syntax
+ config.set(base + ['dynamic-dns-update'], value=None)
+
+ # Make node "set service dhcp-server disabled (true|false)" valueless
+ if config.exists(base + ['disabled']):
+ bool_val = config.return_value(base + ['disabled'])
+
+ # Delete the node with the old syntax
+ config.delete(base + ['disabled'])
+ if str(bool_val) == 'true':
+ # Now disable DHCP server with the new syntax
+ config.set(base + ['disable'], value=None)
+
+ # Make node "set service dhcp-server hostfile-update (enable|disable) valueless
+ if config.exists(base + ['hostfile-update']):
+ bool_val = config.return_value(base + ['hostfile-update'])
+
+ # Delete the node with the old syntax incl. all subnodes
+ config.delete(base + ['hostfile-update'])
+ if str(bool_val) == 'enable':
+ # Enable hostfile update with new syntax
+ config.set(base + ['hostfile-update'], value=None)
+
+ # Run this for every instance if 'shared-network-name'
+ for network in config.list_nodes(base + ['shared-network-name']):
+ base_network = base + ['shared-network-name', network]
+ # format as tag node to avoid loading problems
+ config.set_tag(base + ['shared-network-name'])
+
+ # Run this for every specified 'subnet'
+ for subnet in config.list_nodes(base_network + ['subnet']):
+ base_subnet = base_network + ['subnet', subnet]
+ # format as tag node to avoid loading problems
+ config.set_tag(base_network + ['subnet'])
+
+ # Make node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable" valueless
+ if config.exists(base_subnet + ['ip-forwarding', 'enable']):
+ bool_val = config.return_value(base_subnet + ['ip-forwarding', 'enable'])
+ # Delete the node with the old syntax
+ config.delete(base_subnet + ['ip-forwarding'])
+ if str(bool_val) == 'true':
+ # Recreate node with new syntax
+ config.set(base_subnet + ['ip-forwarding'], value=None)
+
+ # Rename node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 start <172.16.0.4> stop <172.16.0.9>
+ if config.exists(base_subnet + ['start']):
+ # This is the new "range" id for DHCP lease ranges
+ r_id = 0
+ for range in config.list_nodes(base_subnet + ['start']):
+ range_start = range
+ range_stop = config.return_value(base_subnet + ['start', range_start, 'stop'])
+
+ # Delete the node with the old syntax
+ config.delete(base_subnet + ['start', range_start])
+
+ # Create the node for the new syntax
+ # Note: range is a tag node, counter is its child, not a value
+ config.set(base_subnet + ['range', r_id])
+ config.set(base_subnet + ['range', r_id, 'start'], value=range_start)
+ config.set(base_subnet + ['range', r_id, 'stop'], value=range_stop)
+
+ # format as tag node to avoid loading problems
+ config.set_tag(base_subnet + ['range'])
+
+ # increment range id for possible next range definition
+ r_id += 1
+
+ # Delete the node with the old syntax
+ config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'subnet', subnet, 'start'])
+
+
+ # Make node "set service dhcp-server shared-network-name <xyz> authoritative" valueless
+ if config.exists(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']):
+ authoritative = config.return_value(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
+
+ # Delete the node with the old syntax
+ config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
+
+ # Recreate node with new syntax - if required
+ if authoritative == "enable":
+ config.set(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
diff --git a/src/migration-scripts/dhcp-server/5-to-6 b/src/migration-scripts/dhcp-server/5-to-6
new file mode 100644
index 0000000..9404cd0
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/5-to-6
@@ -0,0 +1,69 @@
+# 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/>.
+
+# T1968: allow multiple static-routes to be configured
+# T3838: rename dns-server -> name-server
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base + ['shared-network-name']):
+ # Nothing to do
+ return
+
+ # Run this for every instance if 'shared-network-name'
+ for network in config.list_nodes(base + ['shared-network-name']):
+ base_network = base + ['shared-network-name', network]
+
+ if not config.exists(base_network + ['subnet']):
+ continue
+
+ # Run this for every specified 'subnet'
+ for subnet in config.list_nodes(base_network + ['subnet']):
+ base_subnet = base_network + ['subnet', subnet]
+
+ # T1968: allow multiple static-routes to be configured
+ if config.exists(base_subnet + ['static-route']):
+ prefix = config.return_value(base_subnet + ['static-route', 'destination-subnet'])
+ router = config.return_value(base_subnet + ['static-route', 'router'])
+ config.delete(base_subnet + ['static-route'])
+
+ config.set(base_subnet + ['static-route', prefix, 'next-hop'], value=router)
+ config.set_tag(base_subnet + ['static-route'])
+
+ # T3838: rename dns-server -> name-server
+ if config.exists(base_subnet + ['dns-server']):
+ config.rename(base_subnet + ['dns-server'], 'name-server')
+
+
+ # T3672: ISC DHCP server only supports one failover peer
+ if config.exists(base_subnet + ['failover']):
+ # There can only be one failover configuration, if none is present
+ # we add the first one
+ if not config.exists(base + ['failover']):
+ local = config.return_value(base_subnet + ['failover', 'local-address'])
+ remote = config.return_value(base_subnet + ['failover', 'peer-address'])
+ status = config.return_value(base_subnet + ['failover', 'status'])
+ name = config.return_value(base_subnet + ['failover', 'name'])
+
+ config.set(base + ['failover', 'remote'], value=remote)
+ config.set(base + ['failover', 'source-address'], value=local)
+ config.set(base + ['failover', 'status'], value=status)
+ config.set(base + ['failover', 'name'], value=name)
+
+ config.delete(base_subnet + ['failover'])
+ config.set(base_subnet + ['enable-failover'])
diff --git a/src/migration-scripts/dhcp-server/6-to-7 b/src/migration-scripts/dhcp-server/6-to-7
new file mode 100644
index 0000000..4e6583a
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/6-to-7
@@ -0,0 +1,58 @@
+# Copyright 2022-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/>.
+
+# T6079: Disable duplicate static mappings
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base + ['shared-network-name']):
+ # Nothing to do
+ return
+
+ # Run this for every instance if 'shared-network-name'
+ for network in config.list_nodes(base + ['shared-network-name']):
+ base_network = base + ['shared-network-name', network]
+
+ if not config.exists(base_network + ['subnet']):
+ continue
+
+ for subnet in config.list_nodes(base_network + ['subnet']):
+ base_subnet = base_network + ['subnet', subnet]
+
+ if config.exists(base_subnet + ['static-mapping']):
+ used_mac = []
+ used_ip = []
+
+ for mapping in config.list_nodes(base_subnet + ['static-mapping']):
+ base_mapping = base_subnet + ['static-mapping', mapping]
+
+ if config.exists(base_mapping + ['mac-address']):
+ mac = config.return_value(base_mapping + ['mac-address'])
+
+ if mac in used_mac:
+ config.set(base_mapping + ['disable'])
+ else:
+ used_mac.append(mac)
+
+ if config.exists(base_mapping + ['ip-address']):
+ ip = config.return_value(base_mapping + ['ip-address'])
+
+ if ip in used_ip:
+ config.set(base_subnet + ['static-mapping', mapping, 'disable'])
+ else:
+ used_ip.append(ip)
diff --git a/src/migration-scripts/dhcp-server/7-to-8 b/src/migration-scripts/dhcp-server/7-to-8
new file mode 100644
index 0000000..7fcb62e
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/7-to-8
@@ -0,0 +1,69 @@
+# Copyright 2023-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/>.
+
+# T3316: Migrate to Kea
+# - global-parameters will not function
+# - shared-network-parameters will not function
+# - subnet-parameters will not function
+# - static-mapping-parameters will not function
+# - host-decl-name is on by default, option removed
+# - ping-check no longer supported
+# - failover is default enabled on all subnets that exist on failover servers
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['host-decl-name']):
+ config.delete(base + ['host-decl-name'])
+
+ if config.exists(base + ['global-parameters']):
+ config.delete(base + ['global-parameters'])
+
+ if config.exists(base + ['shared-network-name']):
+ for network in config.list_nodes(base + ['shared-network-name']):
+ base_network = base + ['shared-network-name', network]
+
+ if config.exists(base_network + ['ping-check']):
+ config.delete(base_network + ['ping-check'])
+
+ if config.exists(base_network + ['shared-network-parameters']):
+ config.delete(base_network +['shared-network-parameters'])
+
+ if not config.exists(base_network + ['subnet']):
+ continue
+
+ # Run this for every specified 'subnet'
+ for subnet in config.list_nodes(base_network + ['subnet']):
+ base_subnet = base_network + ['subnet', subnet]
+
+ if config.exists(base_subnet + ['enable-failover']):
+ config.delete(base_subnet + ['enable-failover'])
+
+ if config.exists(base_subnet + ['ping-check']):
+ config.delete(base_subnet + ['ping-check'])
+
+ if config.exists(base_subnet + ['subnet-parameters']):
+ config.delete(base_subnet + ['subnet-parameters'])
+
+ if config.exists(base_subnet + ['static-mapping']):
+ for mapping in config.list_nodes(base_subnet + ['static-mapping']):
+ if config.exists(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']):
+ config.delete(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters'])
diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9
new file mode 100644
index 0000000..5843e9f
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/8-to-9
@@ -0,0 +1,47 @@
+# Copyright 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/>.
+
+# T3316:
+# - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore)
+# - Rename "service dhcp-server shared-network-name ... static-mapping <hostname> mac-address ..."
+# to "service dhcp-server shared-network-name ... static-mapping <hostname> mac ..."
+
+import re
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for network in config.list_nodes(base):
+ # Run this for every specified 'subnet'
+ if config.exists(base + [network, 'subnet']):
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ base_subnet = base + [network, 'subnet', subnet]
+ if config.exists(base_subnet + ['static-mapping']):
+ for hostname in config.list_nodes(base_subnet + ['static-mapping']):
+ base_mapping = base_subnet + ['static-mapping', hostname]
+
+ # Rename the 'mac-address' node to 'mac'
+ if config.exists(base_mapping + ['mac-address']):
+ config.rename(base_mapping + ['mac-address'], 'mac')
+
+ # Adjust hostname to have valid FQDN characters only
+ new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
+ if new_hostname != hostname:
+ config.rename(base_mapping, new_hostname)
diff --git a/src/migration-scripts/dhcp-server/9-to-10 b/src/migration-scripts/dhcp-server/9-to-10
new file mode 100644
index 0000000..eda9755
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/9-to-10
@@ -0,0 +1,57 @@
+# Copyright 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/>.
+
+# T3316:
+# - Migrate dhcp options under new option node
+# - Add subnet IDs to existing subnets
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcp-server', 'shared-network-name']
+
+option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal',
+ 'client-prefix-length', 'default-router', 'domain-name', 'domain-search',
+ 'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server',
+ 'pop-server', 'server-identifier', 'smtp-server', 'static-route',
+ 'tftp-server-name', 'time-offset', 'time-server', 'time-zone',
+ 'vendor-option', 'wins-server', 'wpad-url']
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ subnet_id = 1
+
+ for network in config.list_nodes(base):
+ for option in option_nodes:
+ if config.exists(base + [network, option]):
+ config.set(base + [network, 'option'])
+ config.copy(base + [network, option], base + [network, 'option', option])
+ config.delete(base + [network, option])
+
+ if config.exists(base + [network, 'subnet']):
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ base_subnet = base + [network, 'subnet', subnet]
+
+ for option in option_nodes:
+ if config.exists(base_subnet + [option]):
+ config.set(base_subnet + ['option'])
+ config.copy(base_subnet + [option], base_subnet + ['option', option])
+ config.delete(base_subnet + [option])
+
+ config.set(base_subnet + ['subnet-id'], value=subnet_id)
+ subnet_id += 1
diff --git a/src/migration-scripts/dhcpv6-server/0-to-1 b/src/migration-scripts/dhcpv6-server/0-to-1
new file mode 100644
index 0000000..fd9b2d7
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/0-to-1
@@ -0,0 +1,44 @@
+# Copyright 202-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/>.
+
+# combine both sip-server-address and sip-server-name nodes to common sip-server
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # we need to run this for every configured network
+ for network in config.list_nodes(base):
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ sip_server = []
+
+ # Do we have 'sip-server-address' configured?
+ if config.exists(base + [network, 'subnet', subnet, 'sip-server-address']):
+ sip_server += config.return_values(base + [network, 'subnet', subnet, 'sip-server-address'])
+ config.delete(base + [network, 'subnet', subnet, 'sip-server-address'])
+
+ # Do we have 'sip-server-name' configured?
+ if config.exists(base + [network, 'subnet', subnet, 'sip-server-name']):
+ sip_server += config.return_values(base + [network, 'subnet', subnet, 'sip-server-name'])
+ config.delete(base + [network, 'subnet', subnet, 'sip-server-name'])
+
+ # Write new CLI value for sip-server
+ for server in sip_server:
+ config.set(base + [network, 'subnet', subnet, 'sip-server'], value=server, replace=False)
diff --git a/src/migration-scripts/dhcpv6-server/1-to-2 b/src/migration-scripts/dhcpv6-server/1-to-2
new file mode 100644
index 0000000..ad30749
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/1-to-2
@@ -0,0 +1,68 @@
+# Copyright 2023-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/>.
+
+# T3316: Migrate to Kea
+# - Kea was meant to have support for key "prefix-highest" under PD which would allow an address range
+# However this seems to have never been implemented. A conversion to prefix length is needed (where possible).
+# Ref: https://lists.isc.org/pipermail/kea-users/2022-November/003686.html
+# - Remove prefix temporary value, convert to multi leafNode (https://kea.readthedocs.io/en/kea-2.2.0/arm/dhcp6-srv.html#dhcpv6-server-limitations)
+
+from vyos.configtree import ConfigTree
+from vyos.utils.network import ipv6_prefix_length
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for network in config.list_nodes(base):
+ if not config.exists(base + [network, 'subnet']):
+ continue
+
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ # Delete temporary value under address-range prefix, convert tagNode to leafNode multi
+ if config.exists(base + [network, 'subnet', subnet, 'address-range', 'prefix']):
+ prefix_base = base + [network, 'subnet', subnet, 'address-range', 'prefix']
+ prefixes = config.list_nodes(prefix_base)
+
+ config.delete(prefix_base)
+
+ for prefix in prefixes:
+ config.set(prefix_base, value=prefix, replace=False)
+
+ if config.exists(base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']):
+ prefix_base = base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']
+
+ config.set(prefix_base)
+ config.set_tag(prefix_base)
+
+ for start in config.list_nodes(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']):
+ path = base + [network, 'subnet', subnet, 'prefix-delegation', 'start', start]
+
+ delegated_length = config.return_value(path + ['prefix-length'])
+ stop = config.return_value(path + ['stop'])
+
+ prefix_length = ipv6_prefix_length(start, stop)
+
+ # This range could not be converted into a simple prefix length and must be skipped
+ if not prefix_length:
+ continue
+
+ config.set(prefix_base + [start, 'delegated-length'], value=delegated_length)
+ config.set(prefix_base + [start, 'prefix-length'], value=prefix_length)
+
+ config.delete(base + [network, 'subnet', subnet, 'prefix-delegation', 'start'])
diff --git a/src/migration-scripts/dhcpv6-server/2-to-3 b/src/migration-scripts/dhcpv6-server/2-to-3
new file mode 100644
index 0000000..b44798d
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/2-to-3
@@ -0,0 +1,60 @@
+# Copyright 2023-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/>.
+
+# T3316:
+# - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore)
+# - Adjust duid (old identifier) to comply with duid format
+# - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> identifier ..."
+# to "service dhcpv6-server shared-network-name ... static-mapping <hostname> duid ..."
+# - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac-address ..."
+# to "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac ..."
+
+import re
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for network in config.list_nodes(base):
+ # Run this for every specified 'subnet'
+ if config.exists(base + [network, 'subnet']):
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ base_subnet = base + [network, 'subnet', subnet]
+ if config.exists(base_subnet + ['static-mapping']):
+ for hostname in config.list_nodes(base_subnet + ['static-mapping']):
+ base_mapping = base_subnet + ['static-mapping', hostname]
+ if config.exists(base_mapping + ['identifier']):
+
+ # Adjust duid to comply with duid format (a:3:b:04:... => 0a:03:0b:04:...)
+ duid = config.return_value(base_mapping + ['identifier'])
+ new_duid = ':'.join(x.rjust(2,'0') for x in duid.split(':'))
+ if new_duid != duid:
+ config.set(base_mapping + ['identifier'], new_duid)
+
+ # Rename the 'identifier' node to 'duid'
+ config.rename(base_mapping + ['identifier'], 'duid')
+
+ # Rename the 'mac-address' node to 'mac'
+ if config.exists(base_mapping + ['mac-address']):
+ config.rename(base_mapping + ['mac-address'], 'mac')
+
+ # Adjust hostname to have valid FQDN characters only
+ new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
+ if new_hostname != hostname:
+ config.rename(base_mapping, new_hostname)
diff --git a/src/migration-scripts/dhcpv6-server/3-to-4 b/src/migration-scripts/dhcpv6-server/3-to-4
new file mode 100644
index 0000000..e38e365
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/3-to-4
@@ -0,0 +1,72 @@
+# Copyright 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/>.
+
+# T3316:
+# - Add subnet IDs to existing subnets
+# - Move options to option node
+# - Migrate address-range to range tagNode
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+option_nodes = ['captive-portal', 'domain-search', 'name-server',
+ 'nis-domain', 'nis-server', 'nisplus-domain', 'nisplus-server',
+ 'sip-server', 'sntp-server', 'vendor-option']
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ subnet_id = 1
+
+ for network in config.list_nodes(base):
+ if config.exists(base + [network, 'subnet']):
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ base_subnet = base + [network, 'subnet', subnet]
+
+ if config.exists(base_subnet + ['address-range']):
+ config.set(base_subnet + ['range'])
+ config.set_tag(base_subnet + ['range'])
+
+ range_id = 1
+
+ if config.exists(base_subnet + ['address-range', 'prefix']):
+ for prefix in config.return_values(base_subnet + ['address-range', 'prefix']):
+ config.set(base_subnet + ['range', range_id, 'prefix'], value=prefix)
+
+ range_id += 1
+
+ if config.exists(base_subnet + ['address-range', 'start']):
+ for start in config.list_nodes(base_subnet + ['address-range', 'start']):
+ stop = config.return_value(base_subnet + ['address-range', 'start', start, 'stop'])
+
+ config.set(base_subnet + ['range', range_id, 'start'], value=start)
+ config.set(base_subnet + ['range', range_id, 'stop'], value=stop)
+
+ range_id += 1
+
+ config.delete(base_subnet + ['address-range'])
+
+ for option in option_nodes:
+ if config.exists(base_subnet + [option]):
+ config.set(base_subnet + ['option'])
+ config.copy(base_subnet + [option], base_subnet + ['option', option])
+ config.delete(base_subnet + [option])
+
+ config.set(base_subnet + ['subnet-id'], value=subnet_id)
+ subnet_id += 1
diff --git a/src/migration-scripts/dhcpv6-server/4-to-5 b/src/migration-scripts/dhcpv6-server/4-to-5
new file mode 100644
index 0000000..ad18e1a
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/4-to-5
@@ -0,0 +1,73 @@
+# Copyright 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/>.
+
+# T5993: Check if subnet is locally accessible and assign interface to subnet
+
+from ipaddress import ip_network
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ def find_subnet_interface(subnet):
+ subnet_net = ip_network(subnet)
+
+ def check_addr(if_path):
+ if config.exists(if_path + ['address']):
+ for addr in config.return_values(if_path + ['address']):
+ try:
+ if ip_network(addr, strict=False) == subnet_net:
+ return True
+ except:
+ pass # interface address was probably "dhcp" or other magic string
+ return None
+
+ for iftype in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', iftype]):
+ if_base = ['interfaces', iftype, ifname]
+
+ if check_addr(if_base):
+ return ifname
+
+ if config.exists(if_base + ['vif']):
+ for vif in config.list_nodes(if_base + ['vif']):
+ if check_addr(if_base + ['vif', vif]):
+ return f'{ifname}.{vif}'
+
+ if config.exists(if_base + ['vif-s']):
+ for vifs in config.list_nodes(if_base + ['vif-s']):
+ if check_addr(if_base + ['vif-s', vifs]):
+ return f'{ifname}.{vifs}'
+
+ if config.exists(if_base + ['vif-s', vifs, 'vif-c']):
+ for vifc in config.list_nodes(if_base + ['vif-s', vifs, 'vif-c']):
+ if check_addr(if_base + ['vif-s', vifs, 'vif-c', vifc]):
+ return f'{ifname}.{vifs}.{vifc}'
+
+ return False
+
+ for network in config.list_nodes(base):
+ if not config.exists(base + [network, 'subnet']):
+ continue
+
+ for subnet in config.list_nodes(base + [network, 'subnet']):
+ subnet_interface = find_subnet_interface(subnet)
+
+ if subnet_interface:
+ config.set(base + [network, 'subnet', subnet, 'interface'], value=subnet_interface)
diff --git a/src/migration-scripts/dhcpv6-server/5-to-6 b/src/migration-scripts/dhcpv6-server/5-to-6
new file mode 100644
index 0000000..cad0a35
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/5-to-6
@@ -0,0 +1,31 @@
+# Copyright 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/>.
+
+# T6648: Rename "common-options" to "option" at shared-network level
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for network in config.list_nodes(base):
+ if not config.exists(base + [network, 'common-options']):
+ continue
+
+ config.rename(base + [network, 'common-options'], 'option')
diff --git a/src/migration-scripts/dns-dynamic/0-to-1 b/src/migration-scripts/dns-dynamic/0-to-1
new file mode 100644
index 0000000..6a91b36
--- /dev/null
+++ b/src/migration-scripts/dns-dynamic/0-to-1
@@ -0,0 +1,109 @@
+# Copyright 2023-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/>.
+
+# T5144:
+# - migrate "service dns dynamic interface ..."
+# to "service dns dynamic address ..."
+# - migrate "service dns dynamic interface <interface> use-web ..."
+# to "service dns dynamic address <address> web-options ..."
+# - migrate "service dns dynamic interface <interface> rfc2136 <config> record ..."
+# to "service dns dynamic address <address> rfc2136 <config> host-name ..."
+# - migrate "service dns dynamic interface <interface> service <config> login ..."
+# to "service dns dynamic address <address> service <config> username ..."
+# - apply global 'ipv6-enable' to per <config> 'ip-version: ipv6'
+# - apply service protocol mapping upfront, they are not 'auto-detected' anymore
+# - migrate web-options url to stricter format
+
+import re
+from vyos.configtree import ConfigTree
+
+service_protocol_mapping = {
+ 'afraid': 'freedns',
+ 'changeip': 'changeip',
+ 'cloudflare': 'cloudflare',
+ 'dnspark': 'dnspark',
+ 'dslreports': 'dslreports1',
+ 'dyndns': 'dyndns2',
+ 'easydns': 'easydns',
+ 'namecheap': 'namecheap',
+ 'noip': 'noip',
+ 'sitelutions': 'sitelutions',
+ 'zoneedit': 'zoneedit1'
+}
+
+old_base_path = ['service', 'dns', 'dynamic', 'interface']
+new_base_path = ['service', 'dns', 'dynamic', 'address']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base_path):
+ # Nothing to do
+ return
+
+ # Migrate "service dns dynamic interface"
+ # to "service dns dynamic address"
+ config.rename(old_base_path, new_base_path[-1])
+
+ for address in config.list_nodes(new_base_path):
+ # Migrate "service dns dynamic interface <interface> rfc2136 <config> record"
+ # to "service dns dynamic address <address> rfc2136 <config> host-name"
+ if config.exists(new_base_path + [address, 'rfc2136']):
+ for rfc_cfg in config.list_nodes(new_base_path + [address, 'rfc2136']):
+ if config.exists(new_base_path + [address, 'rfc2136', rfc_cfg, 'record']):
+ config.rename(new_base_path + [address, 'rfc2136', rfc_cfg, 'record'], 'host-name')
+
+ # Migrate "service dns dynamic interface <interface> service <config> login"
+ # to "service dns dynamic address <address> service <config> username"
+ if config.exists(new_base_path + [address, 'service']):
+ for svc_cfg in config.list_nodes(new_base_path + [address, 'service']):
+ if config.exists(new_base_path + [address, 'service', svc_cfg, 'login']):
+ config.rename(new_base_path + [address, 'service', svc_cfg, 'login'], 'username')
+ # Apply global 'ipv6-enable' to per <config> 'ip-version: ipv6'
+ if config.exists(new_base_path + [address, 'ipv6-enable']):
+ config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], 'ipv6')
+ config.delete(new_base_path + [address, 'ipv6-enable'])
+ # Apply service protocol mapping upfront, they are not 'auto-detected' anymore
+ if svc_cfg in service_protocol_mapping:
+ config.set(new_base_path + [address, 'service', svc_cfg, 'protocol'],
+ service_protocol_mapping.get(svc_cfg))
+
+ # If use-web is set, then:
+ # Move "service dns dynamic address <address> <service|rfc2136> <service> ..."
+ # to "service dns dynamic address web <service|rfc2136> <service>-<address> ..."
+ # Move "service dns dynamic address web use-web ..."
+ # to "service dns dynamic address web web-options ..."
+ # Note: The config is named <service>-<address> to avoid name conflict with old entries
+ if config.exists(new_base_path + [address, 'use-web']):
+ for svc_type in ['rfc2136', 'service']:
+ if config.exists(new_base_path + [address, svc_type]):
+ config.set(new_base_path + ['web', svc_type])
+ config.set_tag(new_base_path + ['web', svc_type])
+ for svc_cfg in config.list_nodes(new_base_path + [address, svc_type]):
+ config.copy(new_base_path + [address, svc_type, svc_cfg],
+ new_base_path + ['web', svc_type, f'{svc_cfg}-{address}'])
+
+ # Multiple web-options were not supported, so copy only the first one
+ # Also, migrate web-options url to stricter format and transition
+ # checkip.dyndns.org to https://domains.google.com/checkip for better
+ # TLS support (see: https://github.com/ddclient/ddclient/issues/597)
+ if not config.exists(new_base_path + ['web', 'web-options']):
+ config.copy(new_base_path + [address, 'use-web'], new_base_path + ['web', 'web-options'])
+ if config.exists(new_base_path + ['web', 'web-options', 'url']):
+ url = config.return_value(new_base_path + ['web', 'web-options', 'url'])
+ if re.search("^(https?://)?checkip\.dyndns\.org", url):
+ config.set(new_base_path + ['web', 'web-options', 'url'], 'https://domains.google.com/checkip')
+ if not url.startswith(('http://', 'https://')):
+ config.set(new_base_path + ['web', 'web-options', 'url'], f'https://{url}')
+
+ config.delete(new_base_path + [address])
diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2
new file mode 100644
index 0000000..5dca9e3
--- /dev/null
+++ b/src/migration-scripts/dns-dynamic/1-to-2
@@ -0,0 +1,51 @@
+# Copyright 2023-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/>.
+
+# T5708:
+# - migrate "service dns dynamic timeout ..."
+# to "service dns dynamic interval ..."
+# - remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+# - migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
+# to "service dns dynamic address <interface> service <service> protocol dnsexit2"
+
+from vyos.configtree import ConfigTree
+
+base_path = ['service', 'dns', 'dynamic']
+timeout_path = base_path + ['timeout']
+address_path = base_path + ['address']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ # Migrate "service dns dynamic timeout ..."
+ # to "service dns dynamic interval ..."
+ if config.exists(timeout_path):
+ config.rename(timeout_path, 'interval')
+
+ # Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+ for address in config.list_nodes(address_path):
+ if config.exists(address_path + [address, 'web-options']) and address != 'web':
+ config.delete(address_path + [address, 'web-options'])
+
+ # Migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
+ # to "service dns dynamic address <interface> service <service> protocol dnsexit2"
+ for address in config.list_nodes(address_path):
+ for svc_cfg in config.list_nodes(address_path + [address, 'service']):
+ if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']):
+ protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol'])
+ if protocol == 'dnsexit':
+ config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2')
diff --git a/src/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3
new file mode 100644
index 0000000..9aafc41
--- /dev/null
+++ b/src/migration-scripts/dns-dynamic/2-to-3
@@ -0,0 +1,99 @@
+# Copyright 2023-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/>.
+
+# T5791:
+# - migrate "service dns dynamic address web web-options ..."
+# to "service dns dynamic name <service> address web ..." (per service)
+# - migrate "service dns dynamic address <address> rfc2136 <service> ..."
+# to "service dns dynamic name <service> address <interface> protocol 'nsupdate'"
+# - migrate "service dns dynamic address <interface> service <service> ..."
+# to "service dns dynamic name <service> address <interface> ..."
+# - normalize the all service names to conform with name constraints
+
+import re
+from unicodedata import normalize
+from vyos.configtree import ConfigTree
+
+def normalize_name(name):
+ """Normalize service names to conform with name constraints.
+
+ This is necessary as part of migration because there were no constraints in
+ the old name format.
+ """
+ # Normalize unicode characters to ASCII (NFKD)
+ # Replace all separators with hypens, strip leading and trailing hyphens
+ name = normalize('NFKD', name).encode('ascii', 'ignore').decode()
+ name = re.sub(r'(\s|_|\W)+', '-', name).strip('-')
+
+ return name
+
+base_path = ['service', 'dns', 'dynamic']
+address_path = base_path + ['address']
+name_path = base_path + ['name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(address_path):
+ # Nothing to do
+ return
+
+ # config.copy does not recursively create a path, so initialize the name path as tagged node
+ if not config.exists(name_path):
+ config.set(name_path)
+ config.set_tag(name_path)
+
+ for address in config.list_nodes(address_path):
+
+ address_path_tag = address_path + [address]
+
+ # Move web-option as a configuration in each service instead of top level web-option
+ if config.exists(address_path_tag + ['web-options']) and address == 'web':
+ for svc_type in ['service', 'rfc2136']:
+ if config.exists(address_path_tag + [svc_type]):
+ for svc_cfg in config.list_nodes(address_path_tag + [svc_type]):
+ config.copy(address_path_tag + ['web-options'],
+ address_path_tag + [svc_type, svc_cfg, 'web-options'])
+ config.delete(address_path_tag + ['web-options'])
+
+ for svc_type in ['service', 'rfc2136']:
+ if config.exists(address_path_tag + [svc_type]):
+ # Set protocol to 'nsupdate' for RFC2136 configuration
+ if svc_type == 'rfc2136':
+ for rfc_cfg in config.list_nodes(address_path_tag + ['rfc2136']):
+ config.set(address_path_tag + ['rfc2136', rfc_cfg, 'protocol'], 'nsupdate')
+
+ # Add address as config value in each service before moving the service path
+ # And then copy the services from 'address <interface> service <service>'
+ # to 'name (service|rfc2136)-<service>-<address>'
+ # Note: The new service is named (service|rfc2136)-<service>-<address>
+ # to avoid name conflict with old entries
+ for svc_cfg in config.list_nodes(address_path_tag + [svc_type]):
+ config.set(address_path_tag + [svc_type, svc_cfg, 'address'], address)
+ config.copy(address_path_tag + [svc_type, svc_cfg],
+ name_path + ['-'.join([svc_type, svc_cfg, address])])
+
+ # Finally cleanup the old address path
+ config.delete(address_path)
+
+ # Normalize the all service names to conform with name constraints
+ index = 1
+ for name in config.list_nodes(name_path):
+ new_name = normalize_name(name)
+ if new_name != name:
+ # Append index if there is still a name conflicts after normalization
+ # For example, "foo-?(" and "foo-!)" both normalize to "foo-"
+ if config.exists(name_path + [new_name]):
+ new_name = f'{new_name}-{index}'
+ index += 1
+ config.rename(name_path + [name], new_name)
diff --git a/src/migration-scripts/dns-dynamic/3-to-4 b/src/migration-scripts/dns-dynamic/3-to-4
new file mode 100644
index 0000000..c8e1ffe
--- /dev/null
+++ b/src/migration-scripts/dns-dynamic/3-to-4
@@ -0,0 +1,57 @@
+# Copyright 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/>.
+
+# T5966:
+# - migrate "service dns dynamic name <service> address <interface>"
+# to "service dns dynamic name <service> address interface <interface>"
+# when <interface> != 'web'
+# - migrate "service dns dynamic name <service> web-options ..."
+# to "service dns dynamic name <service> address web ..."
+# when <interface> == 'web'
+
+from vyos.configtree import ConfigTree
+
+base_path = ['service', 'dns', 'dynamic', 'name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ for service in config.list_nodes(base_path):
+
+ service_path = base_path + [service]
+
+ if config.exists(service_path + ['address']):
+ address = config.return_value(service_path + ['address'])
+ # 'address' is not a leaf node anymore, delete it first
+ config.delete(service_path + ['address'])
+
+ # When address is an interface (not 'web'), move it to 'address interface'
+ if address != 'web':
+ config.set(service_path + ['address', 'interface'], address)
+
+ else: # address == 'web'
+ # Relocate optional 'web-options' directly under 'address web'
+ if config.exists(service_path + ['web-options']):
+ # config.copy does not recursively create a path, so initialize it
+ config.set(service_path + ['address'])
+ config.copy(service_path + ['web-options'],
+ service_path + ['address', 'web'])
+ config.delete(service_path + ['web-options'])
+
+ # ensure that valueless 'address web' still exists even if there are no 'web-options'
+ if not config.exists(service_path + ['address', 'web']):
+ config.set(service_path + ['address', 'web'])
diff --git a/src/migration-scripts/dns-forwarding/0-to-1 b/src/migration-scripts/dns-forwarding/0-to-1
new file mode 100644
index 0000000..264ffb4
--- /dev/null
+++ b/src/migration-scripts/dns-forwarding/0-to-1
@@ -0,0 +1,31 @@
+# Copyright 2019-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/>.
+
+# This migration script will check if there is a allow-from directive configured
+# for the dns forwarding service - if not, the node will be created with the old
+# default values of 0.0.0.0/0 and ::/0
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dns', 'forwarding']
+
+def migrate(config: ConfigTree)-> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if not config.exists(base + ['allow-from']):
+ config.set(base + ['allow-from'], value='0.0.0.0/0', replace=False)
+ config.set(base + ['allow-from'], value='::/0', replace=False)
diff --git a/src/migration-scripts/dns-forwarding/1-to-2 b/src/migration-scripts/dns-forwarding/1-to-2
new file mode 100644
index 0000000..15ed1e1
--- /dev/null
+++ b/src/migration-scripts/dns-forwarding/1-to-2
@@ -0,0 +1,67 @@
+# Copyright 2019-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/>.
+
+# This migration script will remove the deprecated 'listen-on' statement
+# from the dns forwarding service and will add the corresponding
+# listen-address nodes instead. This is required as PowerDNS can only listen
+# on interface addresses and not on interface names.
+
+from ipaddress import ip_interface
+from vyos.ifconfig import Interface
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dns', 'forwarding']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base + ['listen-on']):
+ # Nothing to do
+ return
+
+ listen_intf = config.return_values(base + ['listen-on'])
+ # Delete node with abandoned command
+ config.delete(base + ['listen-on'])
+
+ # retrieve interface addresses for every configured listen-on interface
+ listen_addr = []
+ for intf in listen_intf:
+ # we need to evaluate the interface section before manipulating the 'intf' variable
+ section = Interface.section(intf)
+ if not section:
+ raise ValueError(f'Invalid interface name {intf}')
+
+ # we need to treat vif and vif-s interfaces differently,
+ # both "real interfaces" use dots for vlan identifiers - those
+ # need to be exchanged with vif and vif-s identifiers
+ if intf.count('.') == 1:
+ # this is a regular VLAN interface
+ intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1]
+ elif intf.count('.') == 2:
+ # this is a QinQ VLAN interface
+ intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' + intf.split('.')[2]
+
+ # retrieve corresponding interface addresses in CIDR format
+ # those need to be converted in pure IP addresses without network information
+ path = ['interfaces', section, intf, 'address']
+ try:
+ for addr in config.return_values(path):
+ listen_addr.append( ip_interface(addr).ip )
+ except:
+ # Some interface types do not use "address" option (e.g. OpenVPN)
+ # and may not even have a fixed address
+ print("Could not retrieve the address of the interface {} from the config".format(intf))
+ print("You will need to update your DNS forwarding configuration manually")
+
+ for addr in listen_addr:
+ config.set(base + ['listen-address'], value=addr, replace=False)
diff --git a/src/migration-scripts/dns-forwarding/2-to-3 b/src/migration-scripts/dns-forwarding/2-to-3
new file mode 100644
index 0000000..729c1f0
--- /dev/null
+++ b/src/migration-scripts/dns-forwarding/2-to-3
@@ -0,0 +1,32 @@
+# Copyright 2020-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/>.
+
+# Sets the new options "addnta" and "recursion-desired" for all
+# 'dns forwarding domain' as this is usually desired
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dns', 'forwarding']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['domain']):
+ for domain in config.list_nodes(base + ['domain']):
+ domain_base = base + ['domain', domain]
+ config.set(domain_base + ['addnta'])
+ config.set(domain_base + ['recursion-desired'])
diff --git a/src/migration-scripts/dns-forwarding/3-to-4 b/src/migration-scripts/dns-forwarding/3-to-4
new file mode 100644
index 0000000..b02c0b7
--- /dev/null
+++ b/src/migration-scripts/dns-forwarding/3-to-4
@@ -0,0 +1,31 @@
+# Copyright 2023-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/>.
+
+# T5115: migrate "service dns forwarding domain example.com server" to
+# "service dns forwarding domain example.com name-server"
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'dns', 'forwarding', 'domain']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for domain in config.list_nodes(base):
+ if config.exists(base + [domain, 'server']):
+ config.copy(base + [domain, 'server'], base + [domain, 'name-server'])
+ config.delete(base + [domain, 'server'])
diff --git a/src/migration-scripts/firewall/10-to-11 b/src/migration-scripts/firewall/10-to-11
new file mode 100644
index 0000000..70a1709
--- /dev/null
+++ b/src/migration-scripts/firewall/10-to-11
@@ -0,0 +1,187 @@
+# Copyright 2023-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/>.
+
+# T5160: Firewall re-writing
+
+# cli changes from:
+# set firewall name <name> ...
+# set firewall ipv6-name <name> ...
+# To
+# set firewall ipv4 name <name>
+# set firewall ipv6 name <name>
+
+## Also from 'firewall interface' removed.
+## in and out:
+ # set firewall interface <iface> [in|out] [name | ipv6-name] <name>
+ # To
+ # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> [inbound-interface | outboubd-interface] interface-name <iface>
+ # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> action jump
+ # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> jump-target <name>
+## local:
+ # set firewall interface <iface> local [name | ipv6-name] <name>
+ # To
+ # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> inbound-interface interface-name <iface>
+ # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> action jump
+ # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> jump-target <name>
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ ### Migration of state policies
+ if config.exists(base + ['state-policy']):
+ for state in config.list_nodes(base + ['state-policy']):
+ action = config.return_value(base + ['state-policy', state, 'action'])
+ config.set(base + ['global-options', 'state-policy', state, 'action'], value=action)
+ if config.exists(base + ['state-policy', state, 'log']):
+ config.set(base + ['global-options', 'state-policy', state, 'log'], value='enable')
+ config.delete(base + ['state-policy'])
+
+ ## migration of global options:
+ for option in ['all-ping', 'broadcast-ping', 'config-trap', 'ip-src-route', 'ipv6-receive-redirects', 'ipv6-src-route', 'log-martians',
+ 'receive-redirects', 'resolver-cache', 'resolver-internal', 'send-redirects', 'source-validation', 'syn-cookies', 'twa-hazards-protection']:
+ if config.exists(base + [option]):
+ if option != 'config-trap':
+ val = config.return_value(base + [option])
+ config.set(base + ['global-options', option], value=val)
+ config.delete(base + [option])
+
+ ### Migration of firewall name and ipv6-name
+ ### Also migrate legacy 'accept' behaviour
+ if config.exists(base + ['name']):
+ config.set(['firewall', 'ipv4', 'name'])
+ config.set_tag(['firewall', 'ipv4', 'name'])
+
+ for ipv4name in config.list_nodes(base + ['name']):
+ config.copy(base + ['name', ipv4name], base + ['ipv4', 'name', ipv4name])
+
+ if config.exists(base + ['ipv4', 'name', ipv4name, 'default-action']):
+ action = config.return_value(base + ['ipv4', 'name', ipv4name, 'default-action'])
+
+ if action == 'accept':
+ config.set(base + ['ipv4', 'name', ipv4name, 'default-action'], value='return')
+
+ if config.exists(base + ['ipv4', 'name', ipv4name, 'rule']):
+ for rule_id in config.list_nodes(base + ['ipv4', 'name', ipv4name, 'rule']):
+ action = config.return_value(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'])
+
+ if action == 'accept':
+ config.set(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'], value='return')
+
+ config.delete(base + ['name'])
+
+ if config.exists(base + ['ipv6-name']):
+ config.set(['firewall', 'ipv6', 'name'])
+ config.set_tag(['firewall', 'ipv6', 'name'])
+
+ for ipv6name in config.list_nodes(base + ['ipv6-name']):
+ config.copy(base + ['ipv6-name', ipv6name], base + ['ipv6', 'name', ipv6name])
+
+ if config.exists(base + ['ipv6', 'name', ipv6name, 'default-action']):
+ action = config.return_value(base + ['ipv6', 'name', ipv6name, 'default-action'])
+
+ if action == 'accept':
+ config.set(base + ['ipv6', 'name', ipv6name, 'default-action'], value='return')
+
+ if config.exists(base + ['ipv6', 'name', ipv6name, 'rule']):
+ for rule_id in config.list_nodes(base + ['ipv6', 'name', ipv6name, 'rule']):
+ action = config.return_value(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'])
+
+ if action == 'accept':
+ config.set(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'], value='return')
+
+ config.delete(base + ['ipv6-name'])
+
+ ### Migration of firewall interface
+ if config.exists(base + ['interface']):
+ fwd_ipv4_rule = 5
+ inp_ipv4_rule = 5
+ fwd_ipv6_rule = 5
+ inp_ipv6_rule = 5
+ for direction in ['in', 'out', 'local']:
+ for iface in config.list_nodes(base + ['interface']):
+ if config.exists(base + ['interface', iface, direction]):
+ if config.exists(base + ['interface', iface, direction, 'name']):
+ target = config.return_value(base + ['interface', iface, direction, 'name'])
+ if direction == 'in':
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
+ new_base = base + ['ipv4', 'forward', 'filter', 'rule']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [fwd_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
+ config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
+ fwd_ipv4_rule = fwd_ipv4_rule + 5
+ elif direction == 'out':
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
+ new_base = base + ['ipv4', 'forward', 'filter', 'rule']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [fwd_ipv4_rule, 'outbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
+ config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
+ fwd_ipv4_rule = fwd_ipv4_rule + 5
+ else:
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv4', 'input', 'filter', 'default-action'], value='accept')
+ new_base = base + ['ipv4', 'input', 'filter', 'rule']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [inp_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [inp_ipv4_rule, 'action'], value='jump')
+ config.set(new_base + [inp_ipv4_rule, 'jump-target'], value=target)
+ inp_ipv4_rule = inp_ipv4_rule + 5
+
+ if config.exists(base + ['interface', iface, direction, 'ipv6-name']):
+ target = config.return_value(base + ['interface', iface, direction, 'ipv6-name'])
+ if direction == 'in':
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
+ new_base = base + ['ipv6', 'forward', 'filter', 'rule']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [fwd_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
+ config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
+ fwd_ipv6_rule = fwd_ipv6_rule + 5
+ elif direction == 'out':
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
+ new_base = base + ['ipv6', 'forward', 'filter', 'rule']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [fwd_ipv6_rule, 'outbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
+ config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
+ fwd_ipv6_rule = fwd_ipv6_rule + 5
+ else:
+ new_base = base + ['ipv6', 'input', 'filter', 'rule']
+ # Add default-action== accept for compatibility reasons:
+ config.set(base + ['ipv6', 'input', 'filter', 'default-action'], value='accept')
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.set(new_base + [inp_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
+ config.set(new_base + [inp_ipv6_rule, 'action'], value='jump')
+ config.set(new_base + [inp_ipv6_rule, 'jump-target'], value=target)
+ inp_ipv6_rule = inp_ipv6_rule + 5
+
+ config.delete(base + ['interface'])
diff --git a/src/migration-scripts/firewall/11-to-12 b/src/migration-scripts/firewall/11-to-12
new file mode 100644
index 0000000..80a74cc
--- /dev/null
+++ b/src/migration-scripts/firewall/11-to-12
@@ -0,0 +1,51 @@
+# Copyright 2023-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/>.
+
+# T5681: Firewall re-writing. Simplify cli when mathcing interface
+# From
+ # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-name <iface>
+ # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-group <iface_group>
+# To
+ # set firewall ... rule <rule> [inbound-interface | outboubd-interface] name <iface>
+ # set firewall ... rule <rule> [inbound-interface | outboubd-interface] group <iface_group>
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ ## Migration from base chains
+ #if config.exists(base + ['interface', iface, direction]):
+ for family in ['ipv4', 'ipv6']:
+ if config.exists(base + [family]):
+ for hook in ['forward', 'input', 'output', 'name']:
+ if config.exists(base + [family, hook]):
+ for priority in config.list_nodes(base + [family, hook]):
+ if config.exists(base + [family, hook, priority, 'rule']):
+ for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
+ for direction in ['inbound-interface', 'outbound-interface']:
+ if config.exists(base + [family, hook, priority, 'rule', rule, direction]):
+ if config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']):
+ iface = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
+ config.set(base + [family, hook, priority, 'rule', rule, direction, 'name'], value=iface)
+ config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
+ elif config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']):
+ group = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
+ config.set(base + [family, hook, priority, 'rule', rule, direction, 'group'], value=group)
+ config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
diff --git a/src/migration-scripts/firewall/12-to-13 b/src/migration-scripts/firewall/12-to-13
new file mode 100644
index 0000000..d7b801c
--- /dev/null
+++ b/src/migration-scripts/firewall/12-to-13
@@ -0,0 +1,69 @@
+# Copyright 2023-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/>.
+
+# T5729: Switch to valueless whenever is possible.
+# From
+ # set firewall ... rule <rule> log enable
+ # set firewall ... rule <rule> state <state> enable
+ # set firewall ... rule <rule> log disable
+ # set firewall ... rule <rule> state <state> disable
+# To
+ # set firewall ... rule <rule> log
+ # set firewall ... rule <rule> state <state>
+ # Remove command if log=disable or <state>=disable
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # State Policy logs:
+ if config.exists(base + ['global-options', 'state-policy']):
+ for state in config.list_nodes(base + ['global-options', 'state-policy']):
+ if config.exists(base + ['global-options', 'state-policy', state, 'log']):
+ log_value = config.return_value(base + ['global-options', 'state-policy', state, 'log'])
+ config.delete(base + ['global-options', 'state-policy', state, 'log'])
+ if log_value == 'enable':
+ config.set(base + ['global-options', 'state-policy', state, 'log'])
+
+ for family in ['ipv4', 'ipv6', 'bridge']:
+ if config.exists(base + [family]):
+ for hook in ['forward', 'input', 'output', 'name']:
+ if config.exists(base + [family, hook]):
+ for priority in config.list_nodes(base + [family, hook]):
+ if config.exists(base + [family, hook, priority, 'rule']):
+ for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
+ # Log
+ if config.exists(base + [family, hook, priority, 'rule', rule, 'log']):
+ log_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'log'])
+ config.delete(base + [family, hook, priority, 'rule', rule, 'log'])
+ if log_value == 'enable':
+ config.set(base + [family, hook, priority, 'rule', rule, 'log'])
+ # State
+ if config.exists(base + [family, hook, priority, 'rule', rule, 'state']):
+ flag_enable = 'False'
+ for state in ['established', 'invalid', 'new', 'related']:
+ if config.exists(base + [family, hook, priority, 'rule', rule, 'state', state]):
+ state_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'state', state])
+ config.delete(base + [family, hook, priority, 'rule', rule, 'state', state])
+ if state_value == 'enable':
+ config.set(base + [family, hook, priority, 'rule', rule, 'state'], value=state, replace=False)
+ flag_enable = 'True'
+ if flag_enable == 'False':
+ config.delete(base + [family, hook, priority, 'rule', rule, 'state'])
diff --git a/src/migration-scripts/firewall/13-to-14 b/src/migration-scripts/firewall/13-to-14
new file mode 100644
index 0000000..723b0ae
--- /dev/null
+++ b/src/migration-scripts/firewall/13-to-14
@@ -0,0 +1,39 @@
+# Copyright 2023-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/>.
+
+# T5834: Rename 'enable-default-log' to 'default-log'
+# From
+ # set firewall ... filter enable-default-log
+ # set firewall ... name <name> enable-default-log
+# To
+ # set firewall ... filter default-log
+ # set firewall ... name <name> default-log
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for family in ['ipv4', 'ipv6', 'bridge']:
+ if config.exists(base + [family]):
+ for hook in ['forward', 'input', 'output', 'name']:
+ if config.exists(base + [family, hook]):
+ for priority in config.list_nodes(base + [family, hook]):
+ if config.exists(base + [family, hook, priority, 'enable-default-log']):
+ config.rename(base + [family, hook, priority, 'enable-default-log'], 'default-log')
diff --git a/src/migration-scripts/firewall/14-to-15 b/src/migration-scripts/firewall/14-to-15
new file mode 100644
index 0000000..e4a2aae
--- /dev/null
+++ b/src/migration-scripts/firewall/14-to-15
@@ -0,0 +1,25 @@
+# Copyright 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/>.
+
+# T5535: Migrate <set system ip disable-directed-broadcast> to <set firewall global-options directed-broadcas [enable|disable]
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if config.exists(['system', 'ip', 'disable-directed-broadcast']):
+ config.set(['firewall', 'global-options', 'directed-broadcast'], value='disable')
+ config.delete(['system', 'ip', 'disable-directed-broadcast'])
diff --git a/src/migration-scripts/firewall/15-to-16 b/src/migration-scripts/firewall/15-to-16
new file mode 100644
index 0000000..8e28bba
--- /dev/null
+++ b/src/migration-scripts/firewall/15-to-16
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+
+# T6394: Migrate conntrack timeout options to firewall global-options
+ # from: set system conntrack timeout ..
+ # to: set firewall global-options timeout ...
+
+from vyos.configtree import ConfigTree
+
+firewall_base = ['firewall', 'global-options']
+conntrack_base = ['system', 'conntrack', 'timeout']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(conntrack_base):
+ # Nothing to do
+ return
+
+ for protocol in ['icmp', 'tcp', 'udp', 'other']:
+ if config.exists(conntrack_base + [protocol]):
+ if not config.exists(firewall_base + ['timeout']):
+ config.set(firewall_base + ['timeout'])
+
+ config.copy(conntrack_base + [protocol], firewall_base + ['timeout', protocol])
+ config.delete(conntrack_base + [protocol])
diff --git a/src/migration-scripts/firewall/16-to-17 b/src/migration-scripts/firewall/16-to-17
new file mode 100644
index 0000000..ad0706f
--- /dev/null
+++ b/src/migration-scripts/firewall/16-to-17
@@ -0,0 +1,60 @@
+# 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/>.
+
+#
+# T4694: Adding rt ipsec exists/missing match to firewall configs.
+# This involves a syntax change for IPsec matches, reflecting that different
+# nftables expressions are required depending on whether we're matching a
+# decrypted packet or a packet that will be encrypted - it's directional.
+# The old rules only matched decrypted packets, those matches are now *-in:
+ # from: set firewall <family> <chainspec> rule <rule#> ipsec match-ipsec|match-none
+ # to: set firewall <family> <chainspec> rule <rule#> ipsec match-ipsec-in|match-none-in
+#
+# The <chainspec> positions this match allowed were:
+# name (any custom chains), forward filter, input filter, prerouting raw.
+# There are positions where it was possible to set, but it would never commit
+# (nftables rejects 'meta ipsec' in output hooks), they are not considered here.
+#
+
+from vyos.configtree import ConfigTree
+
+firewall_base = ['firewall']
+
+def migrate_chain(config: ConfigTree, path: list[str]) -> None:
+ if not config.exists(path + ['rule']):
+ return
+
+ for rule_num in config.list_nodes(path + ['rule']):
+ tmp_path = path + ['rule', rule_num, 'ipsec']
+ if config.exists(tmp_path + ['match-ipsec']):
+ config.delete(tmp_path + ['match-ipsec'])
+ config.set(tmp_path + ['match-ipsec-in'])
+ elif config.exists(tmp_path + ['match-none']):
+ config.delete(tmp_path + ['match-none'])
+ config.set(tmp_path + ['match-none-in'])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(firewall_base):
+ # Nothing to do
+ return
+
+ for family in ['ipv4', 'ipv6']:
+ tmp_path = firewall_base + [family, 'name']
+ if config.exists(tmp_path):
+ for custom_fwname in config.list_nodes(tmp_path):
+ migrate_chain(config, tmp_path + [custom_fwname])
+
+ for base_hook in [['forward', 'filter'], ['input', 'filter'], ['prerouting', 'raw']]:
+ tmp_path = firewall_base + [family] + base_hook
+ migrate_chain(config, tmp_path)
diff --git a/src/migration-scripts/firewall/5-to-6 b/src/migration-scripts/firewall/5-to-6
new file mode 100644
index 0000000..d016847
--- /dev/null
+++ b/src/migration-scripts/firewall/5-to-6
@@ -0,0 +1,85 @@
+# 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/>.
+
+# T3090: migrate "firewall options interface <name> adjust-mss" to the
+# individual interface.
+
+from vyos.configtree import ConfigTree
+from vyos.ifconfig import Section
+
+base = ['firewall', 'options', 'interface']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base):
+ if config.exists(base + [interface, 'disable']):
+ continue
+
+ if config.exists(base + [interface, 'adjust-mss']):
+ section = Section.section(interface)
+ tmp = config.return_value(base + [interface, 'adjust-mss'])
+
+ vlan = interface.split('.')
+ base_interface_path = ['interfaces', section, vlan[0]]
+
+ if len(vlan) == 1:
+ # Normal interface, no VLAN
+ config.set(base_interface_path + ['ip', 'adjust-mss'], value=tmp)
+ elif len(vlan) == 2:
+ # Regular VIF or VIF-S interface - we need to check the config
+ vif = vlan[1]
+ if config.exists(base_interface_path + ['vif', vif]):
+ config.set(base_interface_path + ['vif', vif, 'ip', 'adjust-mss'], value=tmp)
+ elif config.exists(base_interface_path + ['vif-s', vif]):
+ config.set(base_interface_path + ['vif-s', vif, 'ip', 'adjust-mss'], value=tmp)
+ elif len(vlan) == 3:
+ # VIF-S interface with VIF-C subinterface
+ vif_s = vlan[1]
+ vif_c = vlan[2]
+ config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ip', 'adjust-mss'], value=tmp)
+ config.set_tag(base_interface_path + ['vif-s'])
+ config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
+
+ if config.exists(base + [interface, 'adjust-mss6']):
+ section = Section.section(interface)
+ tmp = config.return_value(base + [interface, 'adjust-mss6'])
+
+ vlan = interface.split('.')
+ base_interface_path = ['interfaces', section, vlan[0]]
+
+ if len(vlan) == 1:
+ # Normal interface, no VLAN
+ config.set(['interfaces', section, interface, 'ipv6', 'adjust-mss'], value=tmp)
+ elif len(vlan) == 2:
+ # Regular VIF or VIF-S interface - we need to check the config
+ vif = vlan[1]
+ if config.exists(base_interface_path + ['vif', vif]):
+ config.set(base_interface_path + ['vif', vif, 'ipv6', 'adjust-mss'], value=tmp)
+ config.set_tag(base_interface_path + ['vif'])
+ elif config.exists(base_interface_path + ['vif-s', vif]):
+ config.set(base_interface_path + ['vif-s', vif, 'ipv6', 'adjust-mss'], value=tmp)
+ config.set_tag(base_interface_path + ['vif-s'])
+ elif len(vlan) == 3:
+ # VIF-S interface with VIF-C subinterface
+ vif_s = vlan[1]
+ vif_c = vlan[2]
+ config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ipv6', 'adjust-mss'], value=tmp)
+ config.set_tag(base_interface_path + ['vif-s'])
+ config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
+
+ config.delete(['firewall', 'options'])
diff --git a/src/migration-scripts/firewall/6-to-7 b/src/migration-scripts/firewall/6-to-7
new file mode 100644
index 0000000..1afbc78
--- /dev/null
+++ b/src/migration-scripts/firewall/6-to-7
@@ -0,0 +1,304 @@
+# 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/>.
+
+# T2199: Remove unavailable nodes due to XML/Python implementation using nftables
+# monthdays: nftables does not have a monthdays equivalent
+# utc: nftables userspace uses localtime and calculates the UTC offset automatically
+# icmp/v6: migrate previously available `type-name` to valid type/code
+# T4178: Update tcp flags to use multi value node
+# T6071: CLI description limit of 256 characters
+
+import re
+
+from vyos.configtree import ConfigTree
+
+max_len_description = 255
+
+base = ['firewall']
+
+icmp_remove = ['any']
+icmp_translations = {
+ 'ping': 'echo-request',
+ 'pong': 'echo-reply',
+ 'ttl-exceeded': 'time-exceeded',
+ # Network Unreachable
+ 'network-unreachable': [3, 0],
+ 'host-unreachable': [3, 1],
+ 'protocol-unreachable': [3, 2],
+ 'port-unreachable': [3, 3],
+ 'fragmentation-needed': [3, 4],
+ 'source-route-failed': [3, 5],
+ 'network-unknown': [3, 6],
+ 'host-unknown': [3, 7],
+ 'network-prohibited': [3, 9],
+ 'host-prohibited': [3, 10],
+ 'TOS-network-unreachable': [3, 11],
+ 'TOS-host-unreachable': [3, 12],
+ 'communication-prohibited': [3, 13],
+ 'host-precedence-violation': [3, 14],
+ 'precedence-cutoff': [3, 15],
+ # Redirect
+ 'network-redirect': [5, 0],
+ 'host-redirect': [5, 1],
+ 'TOS-network-redirect': [5, 2],
+ 'TOS host-redirect': [5, 3],
+ # Time Exceeded
+ 'ttl-zero-during-transit': [11, 0],
+ 'ttl-zero-during-reassembly': [11, 1],
+ 'ttl-exceeded': 'time-exceeded',
+ # Parameter Problem
+ 'ip-header-bad': [12, 0],
+ 'required-option-missing': [12, 1]
+}
+
+icmpv6_remove = []
+icmpv6_translations = {
+ 'ping': 'echo-request',
+ 'pong': 'echo-reply',
+ # Destination Unreachable
+ 'no-route': [1, 0],
+ 'communication-prohibited': [1, 1],
+ 'address-unreachble': [1, 3],
+ 'port-unreachable': [1, 4],
+ # nd
+ 'redirect': 'nd-redirect',
+ 'router-solicitation': 'nd-router-solicit',
+ 'router-advertisement': 'nd-router-advert',
+ 'neighbour-solicitation': 'nd-neighbor-solicit',
+ 'neighbor-solicitation': 'nd-neighbor-solicit',
+ 'neighbour-advertisement': 'nd-neighbor-advert',
+ 'neighbor-advertisement': 'nd-neighbor-advert',
+ # Time Exceeded
+ 'ttl-zero-during-transit': [3, 0],
+ 'ttl-zero-during-reassembly': [3, 1],
+ # Parameter Problem
+ 'bad-header': [4, 0],
+ 'unknown-header-type': [4, 1],
+ 'unknown-option': [4, 2]
+}
+
+v4_groups = ["address-group", "network-group", "port-group"]
+v6_groups = ["ipv6-address-group", "ipv6-network-group", "port-group"]
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ v4_found = False
+ v6_found = False
+ translated_dict = {}
+
+ if config.exists(base + ['group']):
+ for group_type in config.list_nodes(base + ['group']):
+ for group_name in config.list_nodes(base + ['group', group_type]):
+ name_description = base + ['group', group_type, group_name, 'description']
+ if config.exists(name_description):
+ tmp = config.return_value(name_description)
+ config.set(name_description, value=tmp[:max_len_description])
+ if '+' in group_name:
+ replacement_string = "_"
+ if group_type in v4_groups and not v4_found:
+ v4_found = True
+ if group_type in v6_groups and not v6_found:
+ v6_found = True
+ new_group_name = group_name.replace('+', replacement_string)
+ while config.exists(base + ['group', group_type, new_group_name]):
+ replacement_string = replacement_string + "_"
+ new_group_name = group_name.replace('+', replacement_string)
+ translated_dict[group_name] = new_group_name
+ config.copy(base + ['group', group_type, group_name], base + ['group', group_type, new_group_name])
+ config.delete(base + ['group', group_type, group_name])
+
+ if config.exists(base + ['name']):
+ for name in config.list_nodes(base + ['name']):
+ name_description = base + ['name', name, 'description']
+ if config.exists(name_description):
+ tmp = config.return_value(name_description)
+ config.set(name_description, value=tmp[:max_len_description])
+
+ if not config.exists(base + ['name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['name', name, 'rule']):
+ rule_description = base + ['name', name, 'rule', rule, 'description']
+ if config.exists(rule_description):
+ tmp = config.return_value(rule_description)
+ config.set(rule_description, value=tmp[:max_len_description])
+
+ rule_recent = base + ['name', name, 'rule', rule, 'recent']
+ rule_time = base + ['name', name, 'rule', rule, 'time']
+ rule_tcp_flags = base + ['name', name, 'rule', rule, 'tcp', 'flags']
+ rule_icmp = base + ['name', name, 'rule', rule, 'icmp']
+
+ if config.exists(rule_time + ['monthdays']):
+ config.delete(rule_time + ['monthdays'])
+
+ if config.exists(rule_time + ['utc']):
+ config.delete(rule_time + ['utc'])
+
+ if config.exists(rule_recent + ['time']):
+ tmp = int(config.return_value(rule_recent + ['time']))
+ unit = 'minute'
+ if tmp > 600:
+ unit = 'hour'
+ elif tmp < 10:
+ unit = 'second'
+ config.set(rule_recent + ['time'], value=unit)
+
+ if config.exists(rule_tcp_flags):
+ tmp = config.return_value(rule_tcp_flags)
+ config.delete(rule_tcp_flags)
+ for flag in tmp.split(","):
+ if flag[0] == '!':
+ config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+ else:
+ config.set(rule_tcp_flags + [flag.lower()])
+
+ if config.exists(rule_icmp + ['type-name']):
+ tmp = config.return_value(rule_icmp + ['type-name'])
+ if tmp in icmp_remove:
+ config.delete(rule_icmp + ['type-name'])
+ elif tmp in icmp_translations:
+ translate = icmp_translations[tmp]
+ if isinstance(translate, str):
+ config.set(rule_icmp + ['type-name'], value=translate)
+ elif isinstance(translate, list):
+ config.delete(rule_icmp + ['type-name'])
+ config.set(rule_icmp + ['type'], value=translate[0])
+ config.set(rule_icmp + ['code'], value=translate[1])
+
+ for direction in ['destination', 'source']:
+ if config.exists(base + ['name', name, 'rule', rule, direction]):
+ if config.exists(base + ['name', name, 'rule', rule, direction, 'group']) and v4_found:
+ for group_type in config.list_nodes(base + ['name', name, 'rule', rule, direction, 'group']):
+ group_name = config.return_value(base + ['name', name, 'rule', rule, direction, 'group', group_type])
+ if '+' in group_name:
+ if group_name[0] == "!":
+ new_group_name = "!" + translated_dict[group_name[1:]]
+ else:
+ new_group_name = translated_dict[group_name]
+ config.set(base + ['name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
+
+ pg_base = base + ['name', name, 'rule', rule, direction, 'group', 'port-group']
+ proto_base = base + ['name', name, 'rule', rule, 'protocol']
+ if config.exists(pg_base) and not config.exists(proto_base):
+ config.set(proto_base, value='tcp_udp')
+
+ if '+' in name:
+ replacement_string = "_"
+ new_name = name.replace('+', replacement_string)
+ while config.exists(base + ['name', new_name]):
+ replacement_string = replacement_string + "_"
+ new_name = name.replace('+', replacement_string)
+ config.copy(base + ['name', name], base + ['name', new_name])
+ config.delete(base + ['name', name])
+
+ if config.exists(base + ['ipv6-name']):
+ for name in config.list_nodes(base + ['ipv6-name']):
+ name_description = base + ['ipv6-name', name, 'description']
+ if config.exists(name_description):
+ tmp = config.return_value(name_description)
+ config.set(name_description, value=tmp[:max_len_description])
+
+ if not config.exists(base + ['ipv6-name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+ rule_description = base + ['ipv6-name', name, 'rule', rule, 'description']
+ if config.exists(rule_description):
+ tmp = config.return_value(rule_description)
+ config.set(rule_description, value=tmp[:max_len_description])
+
+ rule_recent = base + ['ipv6-name', name, 'rule', rule, 'recent']
+ rule_time = base + ['ipv6-name', name, 'rule', rule, 'time']
+ rule_tcp_flags = base + ['ipv6-name', name, 'rule', rule, 'tcp', 'flags']
+ rule_icmp = base + ['ipv6-name', name, 'rule', rule, 'icmpv6']
+
+ if config.exists(rule_time + ['monthdays']):
+ config.delete(rule_time + ['monthdays'])
+
+ if config.exists(rule_time + ['utc']):
+ config.delete(rule_time + ['utc'])
+
+ if config.exists(rule_recent + ['time']):
+ tmp = int(config.return_value(rule_recent + ['time']))
+ unit = 'minute'
+ if tmp > 600:
+ unit = 'hour'
+ elif tmp < 10:
+ unit = 'second'
+ config.set(rule_recent + ['time'], value=unit)
+
+ if config.exists(rule_tcp_flags):
+ tmp = config.return_value(rule_tcp_flags)
+ config.delete(rule_tcp_flags)
+ for flag in tmp.split(","):
+ if flag[0] == '!':
+ config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+ else:
+ config.set(rule_tcp_flags + [flag.lower()])
+
+ if config.exists(base + ['ipv6-name', name, 'rule', rule, 'protocol']):
+ tmp = config.return_value(base + ['ipv6-name', name, 'rule', rule, 'protocol'])
+ if tmp == 'icmpv6':
+ config.set(base + ['ipv6-name', name, 'rule', rule, 'protocol'], value='ipv6-icmp')
+
+ if config.exists(rule_icmp + ['type']):
+ tmp = config.return_value(rule_icmp + ['type'])
+ type_code_match = re.match(r'^(\d+)(?:/(\d+))?$', tmp)
+
+ if type_code_match:
+ config.set(rule_icmp + ['type'], value=type_code_match[1])
+ if type_code_match[2]:
+ config.set(rule_icmp + ['code'], value=type_code_match[2])
+ elif tmp in icmpv6_remove:
+ config.delete(rule_icmp + ['type'])
+ elif tmp in icmpv6_translations:
+ translate = icmpv6_translations[tmp]
+ if isinstance(translate, str):
+ config.delete(rule_icmp + ['type'])
+ config.set(rule_icmp + ['type-name'], value=translate)
+ elif isinstance(translate, list):
+ config.set(rule_icmp + ['type'], value=translate[0])
+ config.set(rule_icmp + ['code'], value=translate[1])
+ else:
+ config.rename(rule_icmp + ['type'], 'type-name')
+
+ for direction in ['destination', 'source']:
+ if config.exists(base + ['ipv6-name', name, 'rule', rule, direction]):
+ if config.exists(base + ['ipv6-name', name, 'rule', rule, direction, 'group']) and v6_found:
+ for group_type in config.list_nodes(base + ['ipv6-name', name, 'rule', rule, direction, 'group']):
+ group_name = config.return_value(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type])
+ if '+' in group_name:
+ if group_name[0] == "!":
+ new_group_name = "!" + translated_dict[group_name[1:]]
+ else:
+ new_group_name = translated_dict[group_name]
+ config.set(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
+
+ pg_base = base + ['ipv6-name', name, 'rule', rule, direction, 'group', 'port-group']
+ proto_base = base + ['ipv6-name', name, 'rule', rule, 'protocol']
+ if config.exists(pg_base) and not config.exists(proto_base):
+ config.set(proto_base, value='tcp_udp')
+
+ if '+' in name:
+ replacement_string = "_"
+ new_name = name.replace('+', replacement_string)
+ while config.exists(base + ['ipv6-name', new_name]):
+ replacement_string = replacement_string + "_"
+ new_name = name.replace('+', replacement_string)
+ config.copy(base + ['ipv6-name', name], base + ['ipv6-name', new_name])
+ config.delete(base + ['ipv6-name', name])
diff --git a/src/migration-scripts/firewall/7-to-8 b/src/migration-scripts/firewall/7-to-8
new file mode 100644
index 0000000..b8bcc52
--- /dev/null
+++ b/src/migration-scripts/firewall/7-to-8
@@ -0,0 +1,81 @@
+# Copyright 2022-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/>.
+
+# T2199: Migrate interface firewall nodes to firewall interfaces <ifname> <direction> name/ipv6-name <name>
+# T2199: Migrate zone-policy to firewall node
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+zone_base = ['zone-policy']
+
+def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None):
+ if_path = ['interfaces', iftype, ifname]
+ ifname_full = ifname
+
+ if vif:
+ if_path += ['vif', vif]
+ ifname_full = f'{ifname}.{vif}'
+ elif vifs:
+ if_path += ['vif-s', vifs]
+ ifname_full = f'{ifname}.{vifs}'
+ if vifc:
+ if_path += ['vif-c', vifc]
+ ifname_full = f'{ifname}.{vifs}.{vifc}'
+
+ if not config.exists(if_path + ['firewall']):
+ return
+
+ if not config.exists(['firewall', 'interface']):
+ config.set(['firewall', 'interface'])
+ config.set_tag(['firewall', 'interface'])
+
+ config.copy(if_path + ['firewall'], ['firewall', 'interface', ifname_full])
+ config.delete(if_path + ['firewall'])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base) and not config.exists(zone_base):
+ # Nothing to do
+ return
+
+ for iftype in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', iftype]):
+ migrate_interface(config, iftype, ifname)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif']):
+ for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+ migrate_interface(config, iftype, ifname, vif=vif)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+ for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+ migrate_interface(config, iftype, ifname, vifs=vifs)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
+
+ if config.exists(zone_base + ['zone']):
+ config.set(['firewall', 'zone'])
+ config.set_tag(['firewall', 'zone'])
+
+ for zone in config.list_nodes(zone_base + ['zone']):
+ if 'interface' in config.list_nodes(zone_base + ['zone', zone]):
+ for iface in config.return_values(zone_base + ['zone', zone, 'interface']):
+ if '+' in iface:
+ config.delete_value(zone_base + ['zone', zone, 'interface'], value=iface)
+ iface = iface.replace('+', '*')
+ config.set(zone_base + ['zone', zone, 'interface'], value=iface, replace=False)
+ config.copy(zone_base + ['zone', zone], ['firewall', 'zone', zone])
+ config.delete(zone_base) \ No newline at end of file
diff --git a/src/migration-scripts/firewall/8-to-9 b/src/migration-scripts/firewall/8-to-9
new file mode 100644
index 0000000..3c9e846
--- /dev/null
+++ b/src/migration-scripts/firewall/8-to-9
@@ -0,0 +1,68 @@
+# Copyright 2022-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/>.
+
+# T4780: Add firewall interface group
+# cli changes from:
+# set firewall [name | ipv6-name] <name> rule <number> [inbound-interface | outbound-interface] <interface_name>
+# To
+# set firewall [name | ipv6-name] <name> rule <number> [inbound-interface | outbound-interface] [interface-name | interface-group] <interface_name | interface_group>
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['name']):
+ for name in config.list_nodes(base + ['name']):
+ if not config.exists(base + ['name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['name', name, 'rule']):
+ rule_iiface = base + ['name', name, 'rule', rule, 'inbound-interface']
+ rule_oiface = base + ['name', name, 'rule', rule, 'outbound-interface']
+
+ if config.exists(rule_iiface):
+ tmp = config.return_value(rule_iiface)
+ config.delete(rule_iiface)
+ config.set(rule_iiface + ['interface-name'], value=tmp)
+
+ if config.exists(rule_oiface):
+ tmp = config.return_value(rule_oiface)
+ config.delete(rule_oiface)
+ config.set(rule_oiface + ['interface-name'], value=tmp)
+
+
+ if config.exists(base + ['ipv6-name']):
+ for name in config.list_nodes(base + ['ipv6-name']):
+ if not config.exists(base + ['ipv6-name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+ rule_iiface = base + ['ipv6-name', name, 'rule', rule, 'inbound-interface']
+ rule_oiface = base + ['ipv6-name', name, 'rule', rule, 'outbound-interface']
+
+ if config.exists(rule_iiface):
+ tmp = config.return_value(rule_iiface)
+ config.delete(rule_iiface)
+ config.set(rule_iiface + ['interface-name'], value=tmp)
+
+ if config.exists(rule_oiface):
+ tmp = config.return_value(rule_oiface)
+ config.delete(rule_oiface)
+ config.set(rule_oiface + ['interface-name'], value=tmp)
diff --git a/src/migration-scripts/firewall/9-to-10 b/src/migration-scripts/firewall/9-to-10
new file mode 100644
index 0000000..306a53a
--- /dev/null
+++ b/src/migration-scripts/firewall/9-to-10
@@ -0,0 +1,57 @@
+# Copyright 2023-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/>.
+
+# T5050: Log options
+# cli changes from:
+# set firewall [name | ipv6-name] <name> rule <number> log-level <log_level>
+# To
+# set firewall [name | ipv6-name] <name> rule <number> log-options level <log_level>
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['name']):
+ for name in config.list_nodes(base + ['name']):
+ if not config.exists(base + ['name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['name', name, 'rule']):
+ log_options_base = base + ['name', name, 'rule', rule, 'log-options']
+ rule_log_level = base + ['name', name, 'rule', rule, 'log-level']
+
+ if config.exists(rule_log_level):
+ tmp = config.return_value(rule_log_level)
+ config.delete(rule_log_level)
+ config.set(log_options_base + ['level'], value=tmp)
+
+ if config.exists(base + ['ipv6-name']):
+ for name in config.list_nodes(base + ['ipv6-name']):
+ if not config.exists(base + ['ipv6-name', name, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+ log_options_base = base + ['ipv6-name', name, 'rule', rule, 'log-options']
+ rule_log_level = base + ['ipv6-name', name, 'rule', rule, 'log-level']
+
+ if config.exists(rule_log_level):
+ tmp = config.return_value(rule_log_level)
+ config.delete(rule_log_level)
+ config.set(log_options_base + ['level'], value=tmp)
diff --git a/src/migration-scripts/flow-accounting/0-to-1 b/src/migration-scripts/flow-accounting/0-to-1
new file mode 100644
index 0000000..77670e3
--- /dev/null
+++ b/src/migration-scripts/flow-accounting/0-to-1
@@ -0,0 +1,51 @@
+# 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/>.
+
+# T4099: flow-accounting: sync "source-ip" and "source-address" between netflow
+# and sflow ion CLI
+# T4105: flow-accounting: drop "sflow agent-address auto"
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'flow-accounting']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # T4099
+ tmp = base + ['netflow', 'source-ip']
+ if config.exists(tmp):
+ config.rename(tmp, 'source-address')
+
+ # T4105
+ tmp = base + ['sflow', 'agent-address']
+ if config.exists(tmp):
+ value = config.return_value(tmp)
+ if value == 'auto':
+ # delete the "auto"
+ config.delete(tmp)
+
+ # 1) check if BGP router-id is set
+ # 2) check if OSPF router-id is set
+ # 3) check if OSPFv3 router-id is set
+ router_id = None
+ for protocol in ['bgp', 'ospf', 'ospfv3']:
+ if config.exists(['protocols', protocol, 'parameters', 'router-id']):
+ router_id = config.return_value(['protocols', protocol, 'parameters', 'router-id'])
+ break
+ if router_id:
+ config.set(tmp, value=router_id)
diff --git a/src/migration-scripts/https/0-to-1 b/src/migration-scripts/https/0-to-1
new file mode 100644
index 0000000..52fe3f2
--- /dev/null
+++ b/src/migration-scripts/https/0-to-1
@@ -0,0 +1,50 @@
+# Copyright 202-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/>.
+
+# * Move server block directives under 'virtual-host' tag node, instead of
+# relying on 'listen-address' tag node
+
+from vyos.configtree import ConfigTree
+
+old_base = ['service', 'https', 'listen-address']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base):
+ # Nothing to do
+ return
+
+ new_base = ['service', 'https', 'virtual-host']
+ config.set(new_base)
+ config.set_tag(new_base)
+
+ index = 0
+ for addr in config.list_nodes(old_base):
+ tag_name = f'vhost{index}'
+ config.set(new_base + [tag_name])
+ config.set(new_base + [tag_name, 'listen-address'], value=addr)
+
+ if config.exists(old_base + [addr, 'listen-port']):
+ port = config.return_value(old_base + [addr, 'listen-port'])
+ config.set(new_base + [tag_name, 'listen-port'], value=port)
+
+ if config.exists(old_base + [addr, 'server-name']):
+ names = config.return_values(old_base + [addr, 'server-name'])
+ for name in names:
+ config.set(new_base + [tag_name, 'server-name'], value=name,
+ replace=False)
+
+ index += 1
+
+ config.delete(old_base)
diff --git a/src/migration-scripts/https/1-to-2 b/src/migration-scripts/https/1-to-2
new file mode 100644
index 0000000..dad7ac1
--- /dev/null
+++ b/src/migration-scripts/https/1-to-2
@@ -0,0 +1,35 @@
+# Copyright 2020-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/>.
+
+# * Move 'api virtual-host' list to 'api-restrict virtual-host' so it
+# is owned by service_https.py
+
+from vyos.configtree import ConfigTree
+
+old_base = ['service', 'https', 'api', 'virtual-host']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base):
+ # Nothing to do
+ return
+
+ new_base = ['service', 'https', 'api-restrict', 'virtual-host']
+ config.set(new_base)
+
+ names = config.return_values(old_base)
+ for name in names:
+ config.set(new_base, value=name, replace=False)
+
+ config.delete(old_base)
diff --git a/src/migration-scripts/https/2-to-3 b/src/migration-scripts/https/2-to-3
new file mode 100644
index 0000000..1125cae
--- /dev/null
+++ b/src/migration-scripts/https/2-to-3
@@ -0,0 +1,66 @@
+# 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 signed certificate to use PKI
+
+from vyos.configtree import ConfigTree
+from vyos.pki import create_certificate
+from vyos.pki import create_certificate_request
+from vyos.pki import create_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+
+base = ['service', 'https', 'certificates']
+pki_base = ['pki']
+
+def wrapped_pem_to_config_value(pem):
+ out = []
+ for line in pem.strip().split("\n"):
+ if not line or line.startswith("-----") or line[0] == '#':
+ continue
+ out.append(line)
+ return "".join(out)
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base + ['system-generated-certificate']):
+ return
+
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ valid_days = 365
+ if config.exists(base + ['system-generated-certificate', 'lifetime']):
+ valid_days = int(config.return_value(base + ['system-generated-certificate', 'lifetime']))
+
+ key = create_private_key('rsa', 2048)
+ subject = {'country': 'GB', 'state': 'N/A', 'locality': 'N/A', 'organization': 'VyOS', 'common_name': 'vyos'}
+ cert_req = create_certificate_request(subject, key, ['vyos'])
+ cert = create_certificate(cert_req, cert_req, key, valid_days)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', 'generated_https', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+
+ if key:
+ key_pem = encode_private_key(key)
+ config.set(pki_base + ['certificate', 'generated_https', 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+
+ if cert and key:
+ config.set(base + ['certificate'], value='generated_https')
+ else:
+ print('Failed to migrate system-generated-certificate from https service')
+
+ config.delete(base + ['system-generated-certificate'])
diff --git a/src/migration-scripts/https/3-to-4 b/src/migration-scripts/https/3-to-4
new file mode 100644
index 0000000..c01236c
--- /dev/null
+++ b/src/migration-scripts/https/3-to-4
@@ -0,0 +1,34 @@
+# Copyright 2022-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/>.
+
+# T4768 rename node 'gql' to 'graphql'.
+
+from vyos.configtree import ConfigTree
+
+old_base = ['service', 'https', 'api', 'gql']
+new_base = ['service', 'https', 'api', 'graphql']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base):
+ # Nothing to do
+ return
+
+ config.set(new_base)
+
+ nodes = config.list_nodes(old_base)
+ for node in nodes:
+ config.copy(old_base + [node], new_base + [node])
+
+ config.delete(old_base)
diff --git a/src/migration-scripts/https/4-to-5 b/src/migration-scripts/https/4-to-5
new file mode 100644
index 0000000..0f1c790
--- /dev/null
+++ b/src/migration-scripts/https/4-to-5
@@ -0,0 +1,43 @@
+# Copyright 2023-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/>.
+
+# T5762: http: api: smoketests fail as they can not establish IPv6 connection
+# to uvicorn backend server, always make the UNIX domain socket the
+# default way of communication
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'https']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Delete "socket" CLI option - we always use UNIX domain sockets for
+ # NGINX <-> API server communication
+ if config.exists(base + ['api', 'socket']):
+ config.delete(base + ['api', 'socket'])
+
+ # There is no need for an API service port, as UNIX domain sockets
+ # are used
+ if config.exists(base + ['api', 'port']):
+ config.delete(base + ['api', 'port'])
+
+ # rename listen-port -> port ver virtual-host
+ if config.exists(base + ['virtual-host']):
+ for vhost in config.list_nodes(base + ['virtual-host']):
+ if config.exists(base + ['virtual-host', vhost, 'listen-port']):
+ config.rename(base + ['virtual-host', vhost, 'listen-port'], 'port')
diff --git a/src/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6
new file mode 100644
index 0000000..6ef6976
--- /dev/null
+++ b/src/migration-scripts/https/5-to-6
@@ -0,0 +1,89 @@
+# Copyright 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/>.
+
+# T5886: Add support for ACME protocol (LetsEncrypt), migrate https certbot
+# to new "pki certificate" CLI tree
+# T5902: Remove virtual-host
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.defaults import directories
+from vyos.utils.process import cmd
+
+vyos_certbot_dir = directories['certbot']
+
+base = ['service', 'https']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['certificates', 'certbot']):
+ # both domain-name and email must be set on CLI - ensured by previous verify()
+ domain_names = config.return_values(base + ['certificates', 'certbot', 'domain-name'])
+ email = config.return_value(base + ['certificates', 'certbot', 'email'])
+ config.delete(base + ['certificates', 'certbot'])
+
+ # Set default certname based on domain-name
+ cert_name = 'https-' + domain_names[0].split('.')[0]
+ # Overwrite certname from previous certbot calls if available
+ # We can not use python code like os.scandir due to filesystem permissions.
+ # This must be run as root
+ certbot_live = f'{vyos_certbot_dir}/live/' # we need the trailing /
+ if os.path.exists(certbot_live):
+ tmp = cmd(f'sudo find {certbot_live} -maxdepth 1 -type d')
+ tmp = tmp.split() # tmp = ['/config/auth/letsencrypt/live', '/config/auth/letsencrypt/live/router.vyos.net']
+ tmp.remove(certbot_live)
+ cert_name = tmp[0].replace(certbot_live, '')
+
+ config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email)
+ config.set_tag(['pki', 'certificate'])
+ for domain in domain_names:
+ config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False)
+
+ # Update Webserver certificate
+ config.set(base + ['certificates', 'certificate'], value=cert_name)
+
+ if config.exists(base + ['virtual-host']):
+ allow_client = []
+ listen_port = []
+ listen_address = []
+ for virtual_host in config.list_nodes(base + ['virtual-host']):
+ allow_path = base + ['virtual-host', virtual_host, 'allow-client', 'address']
+ if config.exists(allow_path):
+ tmp = config.return_values(allow_path)
+ allow_client.extend(tmp)
+
+ port_path = base + ['virtual-host', virtual_host, 'port']
+ if config.exists(port_path):
+ tmp = config.return_value(port_path)
+ listen_port.append(tmp)
+
+ listen_address_path = base + ['virtual-host', virtual_host, 'listen-address']
+ if config.exists(listen_address_path):
+ tmp = config.return_value(listen_address_path)
+ listen_address.append(tmp)
+
+ config.delete(base + ['virtual-host'])
+ for client in allow_client:
+ config.set(base + ['allow-client', 'address'], value=client, replace=False)
+
+ # clear listen-address if "all" were specified
+ if '*' in listen_address:
+ listen_address = []
+ for address in listen_address:
+ config.set(base + ['listen-address'], value=address, replace=False)
diff --git a/src/migration-scripts/ids/0-to-1 b/src/migration-scripts/ids/0-to-1
new file mode 100644
index 0000000..1b963e8
--- /dev/null
+++ b/src/migration-scripts/ids/0-to-1
@@ -0,0 +1,38 @@
+# Copyright 2022-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/>.
+
+# T4557: Migrate threshold and add new threshold types
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'ids', 'ddos-protection']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base + ['threshold']):
+ # Nothing to do
+ return
+ else:
+ if config.exists(base + ['threshold', 'fps']):
+ tmp = config.return_value(base + ['threshold', 'fps'])
+ config.delete(base + ['threshold', 'fps'])
+ config.set(base + ['threshold', 'general', 'fps'], value=tmp)
+ if config.exists(base + ['threshold', 'mbps']):
+ tmp = config.return_value(base + ['threshold', 'mbps'])
+ config.delete(base + ['threshold', 'mbps'])
+ config.set(base + ['threshold', 'general', 'mbps'], value=tmp)
+ if config.exists(base + ['threshold', 'pps']):
+ tmp = config.return_value(base + ['threshold', 'pps'])
+ config.delete(base + ['threshold', 'pps'])
+ config.set(base + ['threshold', 'general', 'pps'], value=tmp)
diff --git a/src/migration-scripts/interfaces/0-to-1 b/src/migration-scripts/interfaces/0-to-1
new file mode 100644
index 0000000..7c135e7
--- /dev/null
+++ b/src/migration-scripts/interfaces/0-to-1
@@ -0,0 +1,113 @@
+# Copyright 2019-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/>.
+
+# Change syntax of bridge interface
+# - move interface based bridge-group to actual bridge (de-nest)
+# - make stp and igmp-snooping nodes valueless
+# https://vyos.dev/T1556
+
+from vyos.configtree import ConfigTree
+
+def migrate_bridge(config, tree, intf):
+ # check if bridge-group exists
+ tree_bridge = tree + ['bridge-group']
+ if config.exists(tree_bridge):
+ bridge = config.return_value(tree_bridge + ['bridge'])
+ # create new bridge member interface
+ config.set(base + [bridge, 'member', 'interface', intf])
+ # format as tag node to avoid loading problems
+ config.set_tag(base + [bridge, 'member', 'interface'])
+
+ # cost: migrate if configured
+ tree_cost = tree + ['bridge-group', 'cost']
+ if config.exists(tree_cost):
+ cost = config.return_value(tree_cost)
+ # set new node
+ config.set(base + [bridge, 'member', 'interface', intf, 'cost'], value=cost)
+
+ # priority: migrate if configured
+ tree_priority = tree + ['bridge-group', 'priority']
+ if config.exists(tree_priority):
+ priority = config.return_value(tree_priority)
+ # set new node
+ config.set(base + [bridge, 'member', 'interface', intf, 'priority'], value=priority)
+
+ # Delete the old bridge-group assigned to an interface
+ config.delete(tree_bridge)
+
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'bridge']
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ #
+ # make stp and igmp-snooping nodes valueless
+ #
+ for br in config.list_nodes(base):
+ # STP: check if enabled
+ if config.exists(base + [br, 'stp']):
+ stp_val = config.return_value(base + [br, 'stp'])
+ # STP: delete node with old syntax
+ config.delete(base + [br, 'stp'])
+ # STP: set new node - if enabled
+ if stp_val == "true":
+ config.set(base + [br, 'stp'], value=None)
+
+ # igmp-snooping: check if enabled
+ if config.exists(base + [br, 'igmp-snooping', 'querier']):
+ igmp_val = config.return_value(base + [br, 'igmp-snooping', 'querier'])
+ # igmp-snooping: delete node with old syntax
+ config.delete(base + [br, 'igmp-snooping', 'querier'])
+ # igmp-snooping: set new node - if enabled
+ if igmp_val == "enable":
+ config.set(base + [br, 'igmp', 'querier'], value=None)
+
+ #
+ # move interface based bridge-group to actual bridge (de-nest)
+ #
+ bridge_types = ['bonding', 'ethernet', 'l2tpv3', 'openvpn', 'vxlan', 'wireless']
+ for type in bridge_types:
+ if not config.exists(['interfaces', type]):
+ continue
+
+ for interface in config.list_nodes(['interfaces', type]):
+ # check if bridge-group exists
+ bridge_group = ['interfaces', type, interface]
+ if config.exists(bridge_group + ['bridge-group']):
+ migrate_bridge(config, bridge_group, interface)
+
+ # We also need to migrate VLAN interfaces
+ vlan_base = ['interfaces', type, interface, 'vif']
+ if config.exists(vlan_base):
+ for vlan in config.list_nodes(vlan_base):
+ intf = "{}.{}".format(interface, vlan)
+ migrate_bridge(config, vlan_base + [vlan], intf)
+
+ # And then we have service VLANs (vif-s) interfaces
+ vlan_base = ['interfaces', type, interface, 'vif-s']
+ if config.exists(vlan_base):
+ for vif_s in config.list_nodes(vlan_base):
+ intf = "{}.{}".format(interface, vif_s)
+ migrate_bridge(config, vlan_base + [vif_s], intf)
+
+ # Every service VLAN can have multiple customer VLANs (vif-c)
+ vlan_c = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
+ if config.exists(vlan_c):
+ for vif_c in config.list_nodes(vlan_c):
+ intf = "{}.{}.{}".format(interface, vif_s, vif_c)
+ migrate_bridge(config, vlan_c + [vif_c], intf)
diff --git a/src/migration-scripts/interfaces/1-to-2 b/src/migration-scripts/interfaces/1-to-2
new file mode 100644
index 0000000..ebf02b0
--- /dev/null
+++ b/src/migration-scripts/interfaces/1-to-2
@@ -0,0 +1,59 @@
+# Copyright 2019-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/>.
+
+# Change syntax of bond interface
+# - move interface based bond-group to actual bond (de-nest)
+# https://vyos.dev/T1614
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'bonding']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ #
+ # move interface based bond-group to actual bond (de-nest)
+ #
+ for intf in config.list_nodes(['interfaces', 'ethernet']):
+ # check if bond-group exists
+ if config.exists(['interfaces', 'ethernet', intf, 'bond-group']):
+ # get configured bond interface
+ bond = config.return_value(['interfaces', 'ethernet', intf, 'bond-group'])
+ # delete old interface asigned (nested) bond group
+ config.delete(['interfaces', 'ethernet', intf, 'bond-group'])
+ # create new bond member interface
+ config.set(base + [bond, 'member', 'interface'], value=intf, replace=False)
+
+ #
+ # some combinations were allowed in the past from a CLI perspective
+ # but the kernel overwrote them - remove from CLI to not confuse the users.
+ # In addition new consitency checks are in place so users can't repeat the
+ # mistake. One of those nice issues is https://vyos.dev/T532
+ for bond in config.list_nodes(base):
+ if config.exists(base + [bond, 'arp-monitor', 'interval']) and config.exists(base + [bond, 'mode']):
+ mode = config.return_value(base + [bond, 'mode'])
+ if mode in ['802.3ad', 'transmit-load-balance', 'adaptive-load-balance']:
+ intvl = int(config.return_value(base + [bond, 'arp-monitor', 'interval']))
+ if intvl > 0:
+ # this is not allowed and the linux kernel replies with:
+ # option arp_interval: mode dependency failed, not supported in mode 802.3ad(4)
+ # option arp_interval: mode dependency failed, not supported in mode balance-alb(6)
+ # option arp_interval: mode dependency failed, not supported in mode balance-tlb(5)
+ #
+ # so we simply disable arp_interval by setting it to 0 and miimon will take care about the link
+ config.set(base + [bond, 'arp-monitor', 'interval'], value='0')
diff --git a/src/migration-scripts/interfaces/10-to-11 b/src/migration-scripts/interfaces/10-to-11
new file mode 100644
index 0000000..8a562f2
--- /dev/null
+++ b/src/migration-scripts/interfaces/10-to-11
@@ -0,0 +1,38 @@
+# Copyright 2020-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/>.
+
+# rename WWAN (wirelessmodem) serial interface from non persistent ttyUSB2 to
+# a bus like name, e.g. "usb0b1.3p1.3"
+
+import os
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'wirelessmodem']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for wwan in config.list_nodes(base):
+ if config.exists(base + [wwan, 'device']):
+ device = config.return_value(base + [wwan, 'device'])
+
+ for root, dirs, files in os.walk('/dev/serial/by-bus'):
+ for file in files:
+ device_file = os.path.realpath(os.path.join(root, file))
+ if os.path.basename(device_file) == device:
+ config.set(base + [wwan, 'device'], value=file, replace=True)
diff --git a/src/migration-scripts/interfaces/11-to-12 b/src/migration-scripts/interfaces/11-to-12
new file mode 100644
index 0000000..132cecb
--- /dev/null
+++ b/src/migration-scripts/interfaces/11-to-12
@@ -0,0 +1,39 @@
+# Copyright 2020-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/>.
+
+# - rename 'dhcpv6-options prefix-delegation' from single node to a new tag node
+# 'dhcpv6-options pd 0'
+# - delete 'sla-len' from CLI - value is calculated on demand
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ for type in config.list_nodes(['interfaces']):
+ for interface in config.list_nodes(['interfaces', type]):
+ # cache current config tree
+ base_path = ['interfaces', type, interface, 'dhcpv6-options']
+ old_base = base_path + ['prefix-delegation']
+ new_base = base_path + ['pd']
+ if config.exists(old_base):
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(old_base, new_base + ['0'])
+ config.delete(old_base)
+
+ for pd in config.list_nodes(new_base):
+ for tmp in config.list_nodes(new_base + [pd, 'interface']):
+ sla_config = new_base + [pd, 'interface', tmp, 'sla-len']
+ if config.exists(sla_config):
+ config.delete(sla_config)
diff --git a/src/migration-scripts/interfaces/12-to-13 b/src/migration-scripts/interfaces/12-to-13
new file mode 100644
index 0000000..585deb8
--- /dev/null
+++ b/src/migration-scripts/interfaces/12-to-13
@@ -0,0 +1,51 @@
+# Copyright 2020-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/>.
+
+# - T2903: Change vif-s ethertype from numeric number to literal
+# - 0x88a8 -> 802.1ad
+# - 0x8100 -> 802.1q
+# - T2905: Change WWAN "ondemand" node to "connect-on-demand" to have identical
+# CLI nodes for both types of dialer interfaces
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ #
+ # T2903
+ #
+ for type in config.list_nodes(['interfaces']):
+ for interface in config.list_nodes(['interfaces', type]):
+ if not config.exists(['interfaces', type, interface, 'vif-s']):
+ continue
+
+ for vif_s in config.list_nodes(['interfaces', type, interface, 'vif-s']):
+ base_path = ['interfaces', type, interface, 'vif-s', vif_s]
+ if config.exists(base_path + ['ethertype']):
+ protocol = '802.1ad'
+ tmp = config.return_value(base_path + ['ethertype'])
+ if tmp == '0x8100':
+ protocol = '802.1q'
+
+ config.set(base_path + ['protocol'], value=protocol)
+ config.delete(base_path + ['ethertype'])
+
+ #
+ # T2905
+ #
+ wwan_base = ['interfaces', 'wirelessmodem']
+ if config.exists(wwan_base):
+ for interface in config.list_nodes(wwan_base):
+ if config.exists(wwan_base + [interface, 'ondemand']):
+ config.rename(wwan_base + [interface, 'ondemand'], 'connect-on-demand')
diff --git a/src/migration-scripts/interfaces/13-to-14 b/src/migration-scripts/interfaces/13-to-14
new file mode 100644
index 0000000..45d8e3b
--- /dev/null
+++ b/src/migration-scripts/interfaces/13-to-14
@@ -0,0 +1,42 @@
+# Copyright 2020-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/>.
+
+# T3043: rename Wireless interface security mode 'both' to 'wpa+wpa2'
+# T3043: move "system wifi-regulatory-domain" to indicidual wireless interface
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'wireless']
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ country_code = ''
+ cc_cli = ['system', 'wifi-regulatory-domain']
+ if config.exists(cc_cli):
+ country_code = config.return_value(cc_cli)
+ config.delete(cc_cli)
+
+ for wifi in config.list_nodes(base):
+ sec_mode = base + [wifi, 'security', 'wpa', 'mode']
+ if config.exists(sec_mode):
+ mode = config.return_value(sec_mode)
+ if mode == 'both':
+ config.set(sec_mode, value='wpa+wpa2', replace=True)
+
+ if country_code:
+ config.set(base + [wifi, 'country-code'], value=country_code)
diff --git a/src/migration-scripts/interfaces/14-to-15 b/src/migration-scripts/interfaces/14-to-15
new file mode 100644
index 0000000..d45d59b
--- /dev/null
+++ b/src/migration-scripts/interfaces/14-to-15
@@ -0,0 +1,38 @@
+# Copyright 2020-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/>.
+
+# T3048: remove smp-affinity node from ethernet and use tuned instead
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'ethernet']
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ migrate = False
+ for interface in config.list_nodes(base):
+ smp_base = base + [interface, 'smp-affinity']
+ # if any one interface had smp-affinity configured manually, we will
+ # configure "system option performance"
+ if config.exists(smp_base):
+ if config.return_value(smp_base) != 'auto':
+ migrate = True
+ config.delete(smp_base)
+
+ if migrate:
+ config.set(['system', 'options', 'performance'], value='throughput')
diff --git a/src/migration-scripts/interfaces/15-to-16 b/src/migration-scripts/interfaces/15-to-16
new file mode 100644
index 0000000..c9abdb5
--- /dev/null
+++ b/src/migration-scripts/interfaces/15-to-16
@@ -0,0 +1,30 @@
+# Copyright 2020-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/>.
+
+# remove pppoe "ipv6 enable" option
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'pppoe']
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base):
+ ipv6_enable = base + [interface, 'ipv6', 'enable']
+ if config.exists(ipv6_enable):
+ config.delete(ipv6_enable)
diff --git a/src/migration-scripts/interfaces/16-to-17 b/src/migration-scripts/interfaces/16-to-17
new file mode 100644
index 0000000..7d241ac
--- /dev/null
+++ b/src/migration-scripts/interfaces/16-to-17
@@ -0,0 +1,33 @@
+# Copyright 2020-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/>.
+
+# Command line migration of port mirroring
+# https://vyos.dev/T3089
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'ethernet']
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base):
+ mirror_old_base = base + [interface, 'mirror']
+ if config.exists(mirror_old_base):
+ intf = config.return_values(mirror_old_base)
+ if config.exists(mirror_old_base):
+ config.delete(mirror_old_base)
+ config.set(mirror_old_base + ['ingress'],intf[0])
diff --git a/src/migration-scripts/interfaces/17-to-18 b/src/migration-scripts/interfaces/17-to-18
new file mode 100644
index 0000000..f45695a
--- /dev/null
+++ b/src/migration-scripts/interfaces/17-to-18
@@ -0,0 +1,52 @@
+# Copyright 2020-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/>.
+
+# T3043: Move "system wifi-regulatory-domain" to indicidual wireless interface.
+# Country Code will be migratred from upper to lower case.
+# T3140: Relax ethernet interface offload-options
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ # T3140: Cleanup ethernet offload-options, remove on/off value and use
+ # valueless nodes instead.
+ eth_base = ['interfaces', 'ethernet']
+ if config.exists(eth_base):
+ for eth in config.list_nodes(eth_base):
+ offload = eth_base + [eth, 'offload-options']
+ if config.exists(offload):
+ mapping = {
+ 'generic-receive' : 'gro',
+ 'generic-segmentation' : 'gso',
+ 'scatter-gather' : 'sg',
+ 'tcp-segmentation' : 'tso',
+ 'udp-fragmentation' : 'ufo',
+ }
+ for k, v in mapping.items():
+ if config.exists(offload + [k]):
+ tmp = config.return_value(offload + [k])
+ if tmp == 'on':
+ config.set(eth_base + [eth, 'offload', v])
+
+ config.delete(offload)
+
+ # T3043: WIFI country-code should be lower-case
+ wifi_base = ['interfaces', 'wireless']
+ if config.exists(wifi_base):
+ for wifi in config.list_nodes(wifi_base):
+ ccode = wifi_base + [wifi, 'country-code']
+ if config.exists(ccode):
+ tmp = config.return_value(ccode)
+ config.set(ccode, value=tmp.lower(), replace=True)
diff --git a/src/migration-scripts/interfaces/18-to-19 b/src/migration-scripts/interfaces/18-to-19
new file mode 100644
index 0000000..ae1a07a
--- /dev/null
+++ b/src/migration-scripts/interfaces/18-to-19
@@ -0,0 +1,86 @@
+# 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/>.
+
+import os
+
+from vyos.configtree import ConfigTree
+
+def replace_nat_interfaces(config, old, new):
+ if not config.exists(['nat']):
+ return
+ for direction in ['destination', 'source']:
+ conf_direction = ['nat', direction, 'rule']
+ if not config.exists(conf_direction):
+ return
+ for rule in config.list_nodes(conf_direction):
+ conf_rule = conf_direction + [rule]
+ if config.exists(conf_rule + ['inbound-interface']):
+ tmp = config.return_value(conf_rule + ['inbound-interface'])
+ if tmp == old:
+ config.set(conf_rule + ['inbound-interface'], value=new)
+ if config.exists(conf_rule + ['outbound-interface']):
+ tmp = config.return_value(conf_rule + ['outbound-interface'])
+ if tmp == old:
+ config.set(conf_rule + ['outbound-interface'], value=new)
+
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'wirelessmodem']
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ new_base = ['interfaces', 'wwan']
+ config.set(new_base)
+ config.set_tag(new_base)
+ for old_interface in config.list_nodes(base):
+ # convert usb0b1.3p1.2 device identifier and extract 1.3 usb bus id
+ usb = config.return_value(base + [old_interface, 'device'])
+ device = usb.split('b')[-1]
+ busid = device.split('p')[0]
+ for new_interface in os.listdir('/sys/class/net'):
+ # we are only interested in interfaces starting with wwan
+ if not new_interface.startswith('wwan'):
+ continue
+ device = os.readlink(f'/sys/class/net/{new_interface}/device')
+ device = device.split(':')[0]
+ if busid in device:
+ config.copy(base + [old_interface], new_base + [new_interface])
+ replace_nat_interfaces(config, old_interface, new_interface)
+
+ config.delete(base)
+
+ # Now that we have copied the old wirelessmodem interfaces to wwan
+ # we can start to migrate also individual config items.
+ for interface in config.list_nodes(new_base):
+ # we do no longer need the USB device name
+ config.delete(new_base + [interface, 'device'])
+ # set/unset DNS configuration
+ dns = new_base + [interface, 'no-peer-dns']
+ if config.exists(dns):
+ config.delete(dns)
+ else:
+ config.set(['system', 'name-servers-dhcp'], value=interface, replace=False)
+
+ # Backup distance is now handled by DHCP option "default-route-distance"
+ distance = dns = new_base + [interface, 'backup', 'distance']
+ old_default_distance = '10'
+ if config.exists(distance):
+ old_default_distance = config.return_value(distance)
+ config.delete(distance)
+ config.set(new_base + [interface, 'dhcp-options', 'default-route-distance'], value=old_default_distance)
+
+ # the new wwan interface use regular IP addressing
+ config.set(new_base + [interface, 'address'], value='dhcp')
diff --git a/src/migration-scripts/interfaces/19-to-20 b/src/migration-scripts/interfaces/19-to-20
new file mode 100644
index 0000000..7ee6302
--- /dev/null
+++ b/src/migration-scripts/interfaces/19-to-20
@@ -0,0 +1,41 @@
+# 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/>.
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ for type in ['tunnel', 'l2tpv3']:
+ base = ['interfaces', type]
+ if not config.exists(base):
+ # Nothing to do
+ continue
+
+ for interface in config.list_nodes(base):
+ # Migrate "interface tunnel <tunX> encapsulation gre-bridge" to gretap
+ encap_path = base + [interface, 'encapsulation']
+ if type == 'tunnel' and config.exists(encap_path):
+ tmp = config.return_value(encap_path)
+ if tmp == 'gre-bridge':
+ config.set(encap_path, value='gretap')
+
+ # Migrate "interface tunnel|l2tpv3 <interface> local-ip" to source-address
+ # Migrate "interface tunnel|l2tpv3 <interface> remote-ip" to remote
+ local_ip_path = base + [interface, 'local-ip']
+ if config.exists(local_ip_path):
+ config.rename(local_ip_path, 'source-address')
+
+ remote_ip_path = base + [interface, 'remote-ip']
+ if config.exists(remote_ip_path):
+ config.rename(remote_ip_path, 'remote')
diff --git a/src/migration-scripts/interfaces/2-to-3 b/src/migration-scripts/interfaces/2-to-3
new file mode 100644
index 0000000..695dcbf
--- /dev/null
+++ b/src/migration-scripts/interfaces/2-to-3
@@ -0,0 +1,40 @@
+# Copyright 2019-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/>.
+
+# Change syntax of openvpn encryption settings
+# - move cipher from encryption to encryption cipher
+# https://vyos.dev/T1704
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'openvpn']
+
+def migrate(config: ConfigTree) -> None:
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+ #
+ # move cipher from "encryption" to "encryption cipher"
+ #
+ for intf in config.list_nodes(['interfaces', 'openvpn']):
+ # Check if encryption is set
+ if config.exists(['interfaces', 'openvpn', intf, 'encryption']):
+ # Get cipher used
+ cipher = config.return_value(['interfaces', 'openvpn', intf, 'encryption'])
+ # Delete old syntax
+ config.delete(['interfaces', 'openvpn', intf, 'encryption'])
+ # Add new syntax to config
+ config.set(['interfaces', 'openvpn', intf, 'encryption', 'cipher'], value=cipher)
diff --git a/src/migration-scripts/interfaces/20-to-21 b/src/migration-scripts/interfaces/20-to-21
new file mode 100644
index 0000000..0b68951
--- /dev/null
+++ b/src/migration-scripts/interfaces/20-to-21
@@ -0,0 +1,107 @@
+# 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/>.
+
+# T3619: mirror Linux Kernel defaults for ethernet offloading options into VyOS
+# CLI. See https://vyos.dev/T3619#102254 for all the details.
+# T3787: Remove deprecated UDP fragmentation offloading option
+
+from vyos.ethtool import Ethtool
+from vyos.configtree import ConfigTree
+from vyos.utils.network import interface_exists
+
+base = ['interfaces', 'ethernet']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ for ifname in config.list_nodes(base):
+ # Bail out early if interface vanished from system
+ if not interface_exists(ifname):
+ continue
+
+ eth = Ethtool(ifname)
+
+ # If GRO is enabled by the Kernel - we reflect this on the CLI. If GRO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'gro'])
+ enabled, fixed = eth.get_generic_receive_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'gro'])
+ elif enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'gro'])
+
+ # If GSO is enabled by the Kernel - we reflect this on the CLI. If GSO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'gso'])
+ enabled, fixed = eth.get_generic_segmentation_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'gso'])
+ elif enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'gso'])
+
+ # If LRO is enabled by the Kernel - we reflect this on the CLI. If LRO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'lro'])
+ enabled, fixed = eth.get_large_receive_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'lro'])
+ elif enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'lro'])
+
+ # If SG is enabled by the Kernel - we reflect this on the CLI. If SG is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'sg'])
+ enabled, fixed = eth.get_scatter_gather()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'sg'])
+ elif enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'sg'])
+
+ # If TSO is enabled by the Kernel - we reflect this on the CLI. If TSO is
+ # enabled via CLI but not supported by the NIC - we remove it from the CLI
+ configured = config.exists(base + [ifname, 'offload', 'tso'])
+ enabled, fixed = eth.get_tcp_segmentation_offload()
+ if configured and fixed:
+ config.delete(base + [ifname, 'offload', 'tso'])
+ elif enabled and not fixed:
+ config.set(base + [ifname, 'offload', 'tso'])
+
+ # Remove deprecated UDP fragmentation offloading option
+ if config.exists(base + [ifname, 'offload', 'ufo']):
+ config.delete(base + [ifname, 'offload', 'ufo'])
+
+ # Also while processing the interface configuration, not all adapters support
+ # changing the speed and duplex settings. If the desired speed and duplex
+ # values do not work for the NIC driver, we change them back to the default
+ # value of "auto" - which will be applied if the CLI node is deleted.
+ speed_path = base + [ifname, 'speed']
+ duplex_path = base + [ifname, 'duplex']
+ # speed and duplex must always be set at the same time if not set to "auto"
+ if config.exists(speed_path) and config.exists(duplex_path):
+ speed = config.return_value(speed_path)
+ duplex = config.return_value(duplex_path)
+ if speed != 'auto' and duplex != 'auto':
+ if not eth.check_speed_duplex(speed, duplex):
+ config.delete(speed_path)
+ config.delete(duplex_path)
+
+ # Also while processing the interface configuration, not all adapters support
+ # changing disabling flow-control - or change this setting. If disabling
+ # flow-control is not supported by the NIC, we remove the setting from CLI
+ flow_control_path = base + [ifname, 'disable-flow-control']
+ if config.exists(flow_control_path):
+ if not eth.check_flow_control():
+ config.delete(flow_control_path)
diff --git a/src/migration-scripts/interfaces/21-to-22 b/src/migration-scripts/interfaces/21-to-22
new file mode 100644
index 0000000..046eb10
--- /dev/null
+++ b/src/migration-scripts/interfaces/21-to-22
@@ -0,0 +1,29 @@
+# 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/>.
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'tunnel']
+
+ if not config.exists(base):
+ return
+
+ for interface in config.list_nodes(base):
+ path = base + [interface, 'dhcp-interface']
+ if config.exists(path):
+ tmp = config.return_value(path)
+ config.delete(path)
+ config.set(base + [interface, 'source-interface'], value=tmp)
diff --git a/src/migration-scripts/interfaces/22-to-23 b/src/migration-scripts/interfaces/22-to-23
new file mode 100644
index 0000000..31f7fa2
--- /dev/null
+++ b/src/migration-scripts/interfaces/22-to-23
@@ -0,0 +1,40 @@
+# 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/>.
+
+# Deletes Wireguard peers if they have the same public key as the router has.
+
+from vyos.configtree import ConfigTree
+from vyos.utils.network import is_wireguard_key_pair
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'wireguard']
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base):
+ if not config.exists(base + [interface, 'private-key']):
+ continue
+ private_key = config.return_value(base + [interface, 'private-key'])
+ interface_base = base + [interface]
+ if config.exists(interface_base + ['peer']):
+ for peer in config.list_nodes(interface_base + ['peer']):
+ peer_base = interface_base + ['peer', peer]
+ if not config.exists(peer_base + ['public-key']):
+ continue
+ peer_public_key = config.return_value(peer_base + ['public-key'])
+ if not config.exists(peer_base + ['disable']) \
+ and is_wireguard_key_pair(private_key, peer_public_key):
+ config.set(peer_base + ['disable'])
diff --git a/src/migration-scripts/interfaces/23-to-24 b/src/migration-scripts/interfaces/23-to-24
new file mode 100644
index 0000000..b72ceee
--- /dev/null
+++ b/src/migration-scripts/interfaces/23-to-24
@@ -0,0 +1,125 @@
+# 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/>.
+
+from vyos.configtree import ConfigTree
+
+def migrate_ospf(config, path, interface):
+ path = path + ['ospf']
+ if config.exists(path):
+ new_base = ['protocols', 'ospf', 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(path, new_base + [interface])
+ config.delete(path)
+
+ # if "ip ospf" was the only setting, we can clean out the empty
+ # ip node afterwards
+ if len(config.list_nodes(path[:-1])) == 0:
+ config.delete(path[:-1])
+
+def migrate_ospfv3(config, path, interface):
+ path = path + ['ospfv3']
+ if config.exists(path):
+ new_base = ['protocols', 'ospfv3', 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(path, new_base + [interface])
+ config.delete(path)
+
+ # if "ipv6 ospfv3" was the only setting, we can clean out the empty
+ # ip node afterwards
+ if len(config.list_nodes(path[:-1])) == 0:
+ config.delete(path[:-1])
+
+def migrate_rip(config, path, interface):
+ path = path + ['rip']
+ if config.exists(path):
+ new_base = ['protocols', 'rip', 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(path, new_base + [interface])
+ config.delete(path)
+
+ # if "ip rip" was the only setting, we can clean out the empty
+ # ip node afterwards
+ if len(config.list_nodes(path[:-1])) == 0:
+ config.delete(path[:-1])
+
+def migrate_ripng(config, path, interface):
+ path = path + ['ripng']
+ if config.exists(path):
+ new_base = ['protocols', 'ripng', 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(path, new_base + [interface])
+ config.delete(path)
+
+ # if "ipv6 ripng" was the only setting, we can clean out the empty
+ # ip node afterwards
+ if len(config.list_nodes(path[:-1])) == 0:
+ config.delete(path[:-1])
+
+def migrate(config: ConfigTree) -> None:
+ #
+ # Migrate "interface ethernet eth0 ip ospf" to "protocols ospf interface eth0"
+ #
+ for type in config.list_nodes(['interfaces']):
+ for interface in config.list_nodes(['interfaces', type]):
+ ip_base = ['interfaces', type, interface, 'ip']
+ ipv6_base = ['interfaces', type, interface, 'ipv6']
+ migrate_rip(config, ip_base, interface)
+ migrate_ripng(config, ipv6_base, interface)
+ migrate_ospf(config, ip_base, interface)
+ migrate_ospfv3(config, ipv6_base, interface)
+
+ vif_path = ['interfaces', type, interface, 'vif']
+ if config.exists(vif_path):
+ for vif in config.list_nodes(vif_path):
+ vif_ip_base = vif_path + [vif, 'ip']
+ vif_ipv6_base = vif_path + [vif, 'ipv6']
+ ifname = f'{interface}.{vif}'
+
+ migrate_rip(config, vif_ip_base, ifname)
+ migrate_ripng(config, vif_ipv6_base, ifname)
+ migrate_ospf(config, vif_ip_base, ifname)
+ migrate_ospfv3(config, vif_ipv6_base, ifname)
+
+
+ vif_s_path = ['interfaces', type, interface, 'vif-s']
+ if config.exists(vif_s_path):
+ for vif_s in config.list_nodes(vif_s_path):
+ vif_s_ip_base = vif_s_path + [vif_s, 'ip']
+ vif_s_ipv6_base = vif_s_path + [vif_s, 'ipv6']
+
+ # vif-c interfaces MUST be migrated before their parent vif-s
+ # interface as the migrate_*() functions delete the path!
+ vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
+ if config.exists(vif_c_path):
+ for vif_c in config.list_nodes(vif_c_path):
+ vif_c_ip_base = vif_c_path + [vif_c, 'ip']
+ vif_c_ipv6_base = vif_c_path + [vif_c, 'ipv6']
+ ifname = f'{interface}.{vif_s}.{vif_c}'
+
+ migrate_rip(config, vif_c_ip_base, ifname)
+ migrate_ripng(config, vif_c_ipv6_base, ifname)
+ migrate_ospf(config, vif_c_ip_base, ifname)
+ migrate_ospfv3(config, vif_c_ipv6_base, ifname)
+
+
+ ifname = f'{interface}.{vif_s}'
+ migrate_rip(config, vif_s_ip_base, ifname)
+ migrate_ripng(config, vif_s_ipv6_base, ifname)
+ migrate_ospf(config, vif_s_ip_base, ifname)
+ migrate_ospfv3(config, vif_s_ipv6_base, ifname)
diff --git a/src/migration-scripts/interfaces/24-to-25 b/src/migration-scripts/interfaces/24-to-25
new file mode 100644
index 0000000..9f8cc80
--- /dev/null
+++ b/src/migration-scripts/interfaces/24-to-25
@@ -0,0 +1,41 @@
+# 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/>.
+
+# A VTI interface also requires an IPSec configuration - VyOS 1.2 supported
+# having a VTI interface in the CLI but no IPSec configuration - drop VTI
+# configuration if this is the case for VyOS 1.4
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'vti']
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ ipsec_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
+ for interface in config.list_nodes(base):
+ found = False
+ if config.exists(ipsec_base):
+ for peer in config.list_nodes(ipsec_base):
+ if config.exists(ipsec_base + [peer, 'vti', 'bind']):
+ tmp = config.return_value(ipsec_base + [peer, 'vti', 'bind'])
+ if tmp == interface:
+ # Interface was found and we no longer need to search
+ # for it in our IPSec peers
+ found = True
+ break
+ if not found:
+ config.delete(base + [interface])
diff --git a/src/migration-scripts/interfaces/25-to-26 b/src/migration-scripts/interfaces/25-to-26
new file mode 100644
index 0000000..7a4032d
--- /dev/null
+++ b/src/migration-scripts/interfaces/25-to-26
@@ -0,0 +1,368 @@
+# 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 Wireguard to store keys in CLI
+# Migrate EAPoL to PKI configuration
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.pki import CERT_BEGIN
+from vyos.pki import load_certificate
+from vyos.pki import load_crl
+from vyos.pki import load_dh_parameters
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_dh_parameters
+from vyos.pki import encode_private_key
+from vyos.pki import verify_crl
+from vyos.utils.process import run
+
+def wrapped_pem_to_config_value(pem):
+ out = []
+ for line in pem.strip().split("\n"):
+ if not line or line.startswith("-----") or line[0] == '#':
+ continue
+ out.append(line)
+ return "".join(out)
+
+def read_file_for_pki(config_auth_path):
+ full_path = os.path.join(AUTH_DIR, config_auth_path)
+ output = None
+
+ if os.path.isfile(full_path):
+ if not os.access(full_path, os.R_OK):
+ run(f'sudo chmod 644 {full_path}')
+
+ with open(full_path, 'r') as f:
+ output = f.read()
+
+ return output
+
+AUTH_DIR = '/config/auth'
+pki_base = ['pki']
+
+def migrate(config: ConfigTree) -> None:
+ # OpenVPN
+ base = ['interfaces', 'openvpn']
+
+ if config.exists(base):
+ for interface in config.list_nodes(base):
+ x509_base = base + [interface, 'tls']
+ pki_name = f'openvpn_{interface}'
+
+ if config.exists(base + [interface, 'shared-secret-key-file']):
+ if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+ config.set(pki_base + ['openvpn', 'shared-secret'])
+ config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+ key_file = config.return_value(base + [interface, 'shared-secret-key-file'])
+ key = read_file_for_pki(key_file)
+ key_pki_name = f'{pki_name}_shared'
+
+ if key:
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+ config.set(base + [interface, 'shared-secret-key'], value=key_pki_name)
+ else:
+ print(f'Failed to migrate shared-secret-key on openvpn interface {interface}')
+
+ config.delete(base + [interface, 'shared-secret-key-file'])
+
+ if not config.exists(base + [interface, 'tls']):
+ continue
+
+ if config.exists(base + [interface, 'tls', 'auth-file']):
+ if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+ config.set(pki_base + ['openvpn', 'shared-secret'])
+ config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+ key_file = config.return_value(base + [interface, 'tls', 'auth-file'])
+ key = read_file_for_pki(key_file)
+ key_pki_name = f'{pki_name}_auth'
+
+ if key:
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+ config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name)
+ else:
+ print(f'Failed to migrate auth-key on openvpn interface {interface}')
+
+ config.delete(base + [interface, 'tls', 'auth-file'])
+
+ if config.exists(base + [interface, 'tls', 'crypt-file']):
+ if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+ config.set(pki_base + ['openvpn', 'shared-secret'])
+ config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+ key_file = config.return_value(base + [interface, 'tls', 'crypt-file'])
+ key = read_file_for_pki(key_file)
+ key_pki_name = f'{pki_name}_crypt'
+
+ if key:
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+ config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+ config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name)
+ else:
+ print(f'Failed to migrate crypt-key on openvpn interface {interface}')
+
+ config.delete(base + [interface, 'tls', 'crypt-file'])
+
+ ca_certs = {}
+
+ if config.exists(x509_base + ['ca-cert-file']):
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ cert_file = config.return_value(x509_base + ['ca-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ certs_str = f.read()
+ certs_data = certs_str.split(CERT_BEGIN)
+ index = 1
+ for cert_data in certs_data[1:]:
+ cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False)
+
+ if cert:
+ ca_certs[f'{pki_name}_{index}'] = cert
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False)
+ else:
+ print(f'Failed to migrate CA certificate on openvpn interface {interface}')
+
+ index += 1
+ else:
+ print(f'Failed to migrate CA certificate on openvpn interface {interface}')
+
+ config.delete(x509_base + ['ca-cert-file'])
+
+ if config.exists(x509_base + ['crl-file']):
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ crl_file = config.return_value(x509_base + ['crl-file'])
+ crl_path = os.path.join(AUTH_DIR, crl_file)
+ crl = None
+ crl_ca_name = None
+
+ if os.path.isfile(crl_path):
+ if not os.access(crl_path, os.R_OK):
+ run(f'sudo chmod 644 {crl_path}')
+
+ with open(crl_path, 'r') as f:
+ crl_data = f.read()
+ crl = load_crl(crl_data, wrap_tags=False)
+
+ for ca_name, ca_cert in ca_certs.items():
+ if verify_crl(crl, ca_cert):
+ crl_ca_name = ca_name
+ break
+
+ if crl and crl_ca_name:
+ crl_pem = encode_certificate(crl)
+ config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
+ else:
+ print(f'Failed to migrate CRL on openvpn interface {interface}')
+
+ config.delete(x509_base + ['crl-file'])
+
+ if config.exists(x509_base + ['cert-file']):
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ cert_file = config.return_value(x509_base + ['cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on openvpn interface {interface}')
+
+ config.delete(x509_base + ['cert-file'])
+
+ if config.exists(x509_base + ['key-file']):
+ key_file = config.return_value(x509_base + ['key-file'])
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=None)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+ else:
+ print(f'Failed to migrate private key on openvpn interface {interface}')
+
+ config.delete(x509_base + ['key-file'])
+
+ if config.exists(x509_base + ['dh-file']):
+ if not config.exists(pki_base + ['dh']):
+ config.set(pki_base + ['dh'])
+ config.set_tag(pki_base + ['dh'])
+
+ dh_file = config.return_value(x509_base + ['dh-file'])
+ dh_path = os.path.join(AUTH_DIR, dh_file)
+ dh = None
+
+ if os.path.isfile(dh_path):
+ if not os.access(dh_path, os.R_OK):
+ run(f'sudo chmod 644 {dh_path}')
+
+ with open(dh_path, 'r') as f:
+ dh_data = f.read()
+ dh = load_dh_parameters(dh_data, wrap_tags=False)
+
+ if dh:
+ dh_pem = encode_dh_parameters(dh)
+ config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem))
+ config.set(x509_base + ['dh-params'], value=pki_name)
+ else:
+ print(f'Failed to migrate DH parameters on openvpn interface {interface}')
+
+ config.delete(x509_base + ['dh-file'])
+
+ # Wireguard
+ base = ['interfaces', 'wireguard']
+
+ if config.exists(base):
+ for interface in config.list_nodes(base):
+ private_key_path = base + [interface, 'private-key']
+
+ key_file = 'default'
+ if config.exists(private_key_path):
+ key_file = config.return_value(private_key_path)
+
+ full_key_path = f'/config/auth/wireguard/{key_file}/private.key'
+
+ if not os.path.exists(full_key_path):
+ print(f'Could not find wireguard private key for migration on interface "{interface}"')
+ continue
+
+ with open(full_key_path, 'r') as f:
+ key_data = f.read().strip()
+ config.set(private_key_path, value=key_data)
+
+ for peer in config.list_nodes(base + [interface, 'peer']):
+ config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key')
+
+ # Ethernet EAPoL
+ base = ['interfaces', 'ethernet']
+
+ if config.exists(base):
+ for interface in config.list_nodes(base):
+ if not config.exists(base + [interface, 'eapol']):
+ continue
+
+ x509_base = base + [interface, 'eapol']
+ pki_name = f'eapol_{interface}'
+
+ if config.exists(x509_base + ['ca-cert-file']):
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ cert_file = config.return_value(x509_base + ['ca-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['ca-certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate CA certificate on eapol config for interface {interface}')
+
+ config.delete(x509_base + ['ca-cert-file'])
+
+ if config.exists(x509_base + ['cert-file']):
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ cert_file = config.return_value(x509_base + ['cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on eapol config for interface {interface}')
+
+ config.delete(x509_base + ['cert-file'])
+
+ if config.exists(x509_base + ['key-file']):
+ key_file = config.return_value(x509_base + ['key-file'])
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=None)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+ else:
+ print(f'Failed to migrate private key on eapol config for interface {interface}')
+
+ config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/interfaces/26-to-27 b/src/migration-scripts/interfaces/26-to-27
new file mode 100644
index 0000000..3f58de0
--- /dev/null
+++ b/src/migration-scripts/interfaces/26-to-27
@@ -0,0 +1,35 @@
+# Copyright 2022-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/>.
+
+# T4384: pppoe: replace default-route CLI option with common CLI nodes already
+# present for DHCP
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'pppoe']
+
+ if not config.exists(base):
+ return
+
+ for ifname in config.list_nodes(base):
+ tmp_config = base + [ifname, 'default-route']
+ if config.exists(tmp_config):
+ # Retrieve current config value
+ value = config.return_value(tmp_config)
+ # Delete old Config node
+ config.delete(tmp_config)
+ if value == 'none':
+ config.set(base + [ifname, 'no-default-route'])
diff --git a/src/migration-scripts/interfaces/27-to-28 b/src/migration-scripts/interfaces/27-to-28
new file mode 100644
index 0000000..eb9363e
--- /dev/null
+++ b/src/migration-scripts/interfaces/27-to-28
@@ -0,0 +1,29 @@
+# Copyright 2023-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/>.
+
+# T4995: pppoe, wwan, sstpc-client rename "authentication user" CLI node
+# to "authentication username"
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ for type in ['pppoe', 'sstpc-client', 'wwam']:
+ base = ['interfaces', type]
+ if not config.exists(base):
+ continue
+ for interface in config.list_nodes(base):
+ auth_base = base + [interface, 'authentication', 'user']
+ if config.exists(auth_base):
+ config.rename(auth_base, 'username')
diff --git a/src/migration-scripts/interfaces/28-to-29 b/src/migration-scripts/interfaces/28-to-29
new file mode 100644
index 0000000..886d49e
--- /dev/null
+++ b/src/migration-scripts/interfaces/28-to-29
@@ -0,0 +1,35 @@
+# Copyright 2023-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/>.
+
+# T5034: tunnel: rename "multicast enable" CLI node to "enable-multicast"
+# valueless node.
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'tunnel']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ for ifname in config.list_nodes(base):
+ multicast_base = base + [ifname, 'multicast']
+ if config.exists(multicast_base):
+ tmp = config.return_value(multicast_base)
+ print(tmp)
+ # Delete old Config node
+ config.delete(multicast_base)
+ if tmp == 'enable':
+ config.set(base + [ifname, 'enable-multicast'])
diff --git a/src/migration-scripts/interfaces/29-to-30 b/src/migration-scripts/interfaces/29-to-30
new file mode 100644
index 0000000..7b32d87
--- /dev/null
+++ b/src/migration-scripts/interfaces/29-to-30
@@ -0,0 +1,30 @@
+# Copyright 2023-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/>.
+
+# T5286: remove XDP support in favour of VPP
+
+from vyos.configtree import ConfigTree
+
+supports_xdp = ['bonding', 'ethernet']
+
+def migrate(config: ConfigTree) -> None:
+ for if_type in supports_xdp:
+ base = ['interfaces', if_type]
+ if not config.exists(base):
+ continue
+ for interface in config.list_nodes(base):
+ if_base = base + [interface]
+ if config.exists(if_base + ['xdp']):
+ config.delete(if_base + ['xdp'])
diff --git a/src/migration-scripts/interfaces/3-to-4 b/src/migration-scripts/interfaces/3-to-4
new file mode 100644
index 0000000..4e56200
--- /dev/null
+++ b/src/migration-scripts/interfaces/3-to-4
@@ -0,0 +1,93 @@
+# Copyright 2019-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/>.
+
+# Change syntax of wireless interfaces
+# Migrate boolean nodes to valueless
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'wireless']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for wifi in config.list_nodes(base):
+ # as converting a node to bool is always the same, we can script it
+ to_bool_nodes = ['capabilities ht 40MHz-incapable',
+ 'capabilities ht auto-powersave',
+ 'capabilities ht delayed-block-ack',
+ 'capabilities ht dsss-cck-40',
+ 'capabilities ht greenfield',
+ 'capabilities ht ldpc',
+ 'capabilities ht lsig-protection',
+ 'capabilities ht stbc tx',
+ 'capabilities require-ht',
+ 'capabilities require-vht',
+ 'capabilities vht antenna-pattern-fixed',
+ 'capabilities vht ldpc',
+ 'capabilities vht stbc tx',
+ 'capabilities vht tx-powersave',
+ 'capabilities vht vht-cf',
+ 'expunge-failing-stations',
+ 'isolate-stations']
+
+ for node in to_bool_nodes:
+ if config.exists(base + [wifi, node]):
+ tmp = config.return_value(base + [wifi, node])
+ # delete old node
+ config.delete(base + [wifi, node])
+ # set new node if it was enabled
+ if tmp == 'true':
+ # OLD CLI used camel casing in 40MHz-incapable which is
+ # not supported in the new backend. Convert all to lower-case
+ config.set(base + [wifi, node.lower()])
+
+ # Remove debug node
+ if config.exists(base + [wifi, 'debug']):
+ config.delete(base + [wifi, 'debug'])
+
+ # RADIUS servers
+ if config.exists(base + [wifi, 'security', 'wpa', 'radius-server']):
+ for server in config.list_nodes(base + [wifi, 'security', 'wpa', 'radius-server']):
+ base_server = base + [wifi, 'security', 'wpa', 'radius-server', server]
+
+ # Migrate RADIUS shared secret
+ if config.exists(base_server + ['secret']):
+ key = config.return_value(base_server + ['secret'])
+ # write new configuration node
+ config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'key'], value=key)
+ # format as tag node
+ config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
+
+ # Migrate RADIUS port
+ if config.exists(base_server + ['port']):
+ port = config.return_value(base_server + ['port'])
+ # write new configuration node
+ config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'port'], value=port)
+ # format as tag node
+ config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
+
+ # Migrate RADIUS accounting
+ if config.exists(base_server + ['accounting']):
+ port = config.return_value(base_server + ['accounting'])
+ # write new configuration node
+ config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'accounting'])
+ # format as tag node
+ config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
+
+ # delete old radius-server nodes
+ config.delete(base + [wifi, 'security', 'wpa', 'radius-server'])
diff --git a/src/migration-scripts/interfaces/30-to-31 b/src/migration-scripts/interfaces/30-to-31
new file mode 100644
index 0000000..7e509dd
--- /dev/null
+++ b/src/migration-scripts/interfaces/30-to-31
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# Copyright 2023-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/>.
+
+# T5254: Fixed changing ethernet when it is a bond member
+
+import json
+from vyos.configtree import ConfigTree
+from vyos.ifconfig import EthernetIf
+from vyos.ifconfig import BondIf
+from vyos.utils.dict import dict_to_paths_values
+
+base = ['interfaces', 'bonding']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for bond in config.list_nodes(base):
+ member_base = base + [bond, 'member', 'interface']
+ if config.exists(member_base):
+ for interface in config.return_values(member_base):
+ if_base = ['interfaces', 'ethernet', interface]
+ if config.exists(if_base):
+ config_ethernet = json.loads(config.get_subtree(if_base).to_json())
+ eth_dict_paths = dict_to_paths_values(config_ethernet)
+ for option_path, option_value in eth_dict_paths.items():
+ # If option is allowed for changing then continue
+ converted_path = option_path.replace('-','_')
+ if converted_path in EthernetIf.get_bond_member_allowed_options():
+ continue
+ # if option is inherited from bond then continue
+ if converted_path in BondIf.get_inherit_bond_options():
+ continue
+ option_path_list = option_path.split('.')
+ config.delete(if_base + option_path_list)
+ del option_path_list[-1]
+ # delete empty node from config
+ while len(option_path_list) > 0:
+ if config.list_nodes(if_base + option_path_list):
+ break
+ config.delete(if_base + option_path_list)
+ del option_path_list[-1]
diff --git a/src/migration-scripts/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32
new file mode 100644
index 0000000..24077ed
--- /dev/null
+++ b/src/migration-scripts/interfaces/31-to-32
@@ -0,0 +1,37 @@
+# Copyright 2023-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/>.
+
+# T5671: change port to IANA assigned default port
+# T5759: change default MTU 1450 -> 1500
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'vxlan']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for vxlan in config.list_nodes(base):
+ if config.exists(base + [vxlan, 'external']):
+ config.delete(base + [vxlan, 'external'])
+ config.set(base + [vxlan, 'parameters', 'external'])
+
+ if not config.exists(base + [vxlan, 'port']):
+ config.set(base + [vxlan, 'port'], value='8472')
+
+ if not config.exists(base + [vxlan, 'mtu']):
+ config.set(base + [vxlan, 'mtu'], value='1450')
diff --git a/src/migration-scripts/interfaces/32-to-33 b/src/migration-scripts/interfaces/32-to-33
new file mode 100644
index 0000000..c7b1c5b
--- /dev/null
+++ b/src/migration-scripts/interfaces/32-to-33
@@ -0,0 +1,40 @@
+#!/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/>.
+#
+# T6318: WiFi country-code should be set system-wide instead of per-device
+
+from vyos.configtree import ConfigTree
+
+base = ['interfaces', 'wireless']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ installed = False
+ for interface in config.list_nodes(base):
+ cc_path = base + [interface, 'country-code']
+ if config.exists(cc_path):
+ tmp = config.return_value(cc_path)
+ config.delete(cc_path)
+
+ # There can be only ONE wireless country-code per device, everything
+ # else makes no sense as a WIFI router can not operate in two
+ # different countries
+ if not installed:
+ config.set(['system', 'wireless', 'country-code'], value=tmp)
+ installed = True
diff --git a/src/migration-scripts/interfaces/4-to-5 b/src/migration-scripts/interfaces/4-to-5
new file mode 100644
index 0000000..93fa7c3
--- /dev/null
+++ b/src/migration-scripts/interfaces/4-to-5
@@ -0,0 +1,106 @@
+# Copyright 2019-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/>.
+
+# De-nest PPPoE interfaces
+# Migrate boolean nodes to valueless
+
+from vyos.configtree import ConfigTree
+
+def migrate_dialer(config, tree, intf):
+ for pppoe in config.list_nodes(tree):
+ # assemble string, 0 -> pppoe0
+ new_base = ['interfaces', 'pppoe']
+ pppoe_base = new_base + ['pppoe' + pppoe]
+ config.set(new_base)
+ # format as tag node to avoid loading problems
+ config.set_tag(new_base)
+
+ # Copy the entire old node to the new one before migrating individual
+ # parts
+ config.copy(tree + [pppoe], pppoe_base)
+
+ # Instead of letting the user choose between auto and none
+ # where auto is default, it makes more sesne to just offer
+ # an option to disable the default behavior (declutter CLI)
+ if config.exists(pppoe_base + ['name-server']):
+ tmp = config.return_value(pppoe_base + ['name-server'])
+ if tmp == "none":
+ config.set(pppoe_base + ['no-peer-dns'])
+ config.delete(pppoe_base + ['name-server'])
+
+ # Migrate user-id and password nodes under an 'authentication'
+ # node
+ if config.exists(pppoe_base + ['user-id']):
+ user = config.return_value(pppoe_base + ['user-id'])
+ config.set(pppoe_base + ['authentication', 'user'], value=user)
+ config.delete(pppoe_base + ['user-id'])
+
+ if config.exists(pppoe_base + ['password']):
+ pwd = config.return_value(pppoe_base + ['password'])
+ config.set(pppoe_base + ['authentication', 'password'], value=pwd)
+ config.delete(pppoe_base + ['password'])
+
+ # remove enable-ipv6 node and rather place it under ipv6 node
+ if config.exists(pppoe_base + ['enable-ipv6']):
+ config.set(pppoe_base + ['ipv6', 'enable'])
+ config.delete(pppoe_base + ['enable-ipv6'])
+
+ # Source interface migration
+ config.set(pppoe_base + ['source-interface'], value=intf)
+
+ # Remove IPv6 router-advert nodes as this makes no sense on a
+ # client diale rinterface to send RAs back into the network
+ # https://vyos.dev/T2055
+ ipv6_ra = pppoe_base + ['ipv6', 'router-advert']
+ if config.exists(ipv6_ra):
+ config.delete(ipv6_ra)
+
+def migrate(config: ConfigTree) -> None:
+ pppoe_links = ['bonding', 'ethernet']
+
+ for link_type in pppoe_links:
+ if not config.exists(['interfaces', link_type]):
+ continue
+
+ for interface in config.list_nodes(['interfaces', link_type]):
+ # check if PPPoE exists
+ base_if = ['interfaces', link_type, interface]
+ pppoe_if = base_if + ['pppoe']
+ if config.exists(pppoe_if):
+ for dialer in config.list_nodes(pppoe_if):
+ migrate_dialer(config, pppoe_if, interface)
+
+ # Delete old PPPoE interface
+ config.delete(pppoe_if)
+
+ # bail out early if there are no VLAN interfaces to migrate
+ if not config.exists(base_if + ['vif']):
+ continue
+
+ # Migrate PPPoE interfaces attached to a VLAN
+ for vlan in config.list_nodes(base_if + ['vif']):
+ vlan_if = base_if + ['vif', vlan]
+ pppoe_if = vlan_if + ['pppoe']
+ if config.exists(pppoe_if):
+ for dialer in config.list_nodes(pppoe_if):
+ intf = "{}.{}".format(interface, vlan)
+ migrate_dialer(config, pppoe_if, intf)
+
+ # Delete old PPPoE interface
+ config.delete(pppoe_if)
+
+ # Add interface description that this is required for PPPoE
+ if not config.exists(vlan_if + ['description']):
+ config.set(vlan_if + ['description'], value='PPPoE link interface')
diff --git a/src/migration-scripts/interfaces/5-to-6 b/src/migration-scripts/interfaces/5-to-6
new file mode 100644
index 0000000..44c32ba
--- /dev/null
+++ b/src/migration-scripts/interfaces/5-to-6
@@ -0,0 +1,114 @@
+# Copyright 202-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 IPv6 router advertisments from a nested interface configuration to
+# a denested "service router-advert"
+
+from vyos.configtree import ConfigTree
+
+def copy_rtradv(c, old_base, interface):
+ base = ['service', 'router-advert', 'interface']
+
+ if c.exists(old_base):
+ if not c.exists(base):
+ c.set(base)
+ c.set_tag(base)
+
+ # take the old node as a whole and copy it to new new path,
+ # additional migrations will be done afterwards
+ new_base = base + [interface]
+ c.copy(old_base, new_base)
+ c.delete(old_base)
+
+ # cur-hop-limit has been renamed to hop-limit
+ if c.exists(new_base + ['cur-hop-limit']):
+ c.rename(new_base + ['cur-hop-limit'], 'hop-limit')
+
+ bool_cleanup = ['managed-flag', 'other-config-flag']
+ for bool in bool_cleanup:
+ if c.exists(new_base + [bool]):
+ tmp = c.return_value(new_base + [bool])
+ c.delete(new_base + [bool])
+ if tmp == 'true':
+ c.set(new_base + [bool])
+
+ # max/min interval moved to subnode
+ intervals = ['max-interval', 'min-interval']
+ for interval in intervals:
+ if c.exists(new_base + [interval]):
+ tmp = c.return_value(new_base + [interval])
+ c.delete(new_base + [interval])
+ min_max = interval.split('-')[0]
+ c.set(new_base + ['interval', min_max], value=tmp)
+
+ # cleanup boolean nodes in individual route
+ route_base = new_base + ['route']
+ if c.exists(route_base):
+ for route in config.list_nodes(route_base):
+ if c.exists(route_base + [route, 'remove-route']):
+ tmp = c.return_value(route_base + [route, 'remove-route'])
+ c.delete(route_base + [route, 'remove-route'])
+ if tmp == 'false':
+ c.set(route_base + [route, 'no-remove-route'])
+
+ # cleanup boolean nodes in individual prefix
+ prefix_base = new_base + ['prefix']
+ if c.exists(prefix_base):
+ for prefix in config.list_nodes(prefix_base):
+ if c.exists(prefix_base + [prefix, 'autonomous-flag']):
+ tmp = c.return_value(prefix_base + [prefix, 'autonomous-flag'])
+ c.delete(prefix_base + [prefix, 'autonomous-flag'])
+ if tmp == 'false':
+ c.set(prefix_base + [prefix, 'no-autonomous-flag'])
+
+ if c.exists(prefix_base + [prefix, 'on-link-flag']):
+ tmp = c.return_value(prefix_base + [prefix, 'on-link-flag'])
+ c.delete(prefix_base + [prefix, 'on-link-flag'])
+ if tmp == 'true':
+ c.set(prefix_base + [prefix, 'on-link-flag'])
+
+ # router advertisement can be individually disabled per interface
+ # the node has been renamed from send-advert {true | false} to no-send-advert
+ if c.exists(new_base + ['send-advert']):
+ tmp = c.return_value(new_base + ['send-advert'])
+ c.delete(new_base + ['send-advert'])
+ if tmp == 'false':
+ c.set(new_base + ['no-send-advert'])
+
+ # link-mtu advertisement was formerly disabled by setting its value to 0
+ # ... this makes less sense - if it should not be send, just do not
+ # configure it
+ if c.exists(new_base + ['link-mtu']):
+ tmp = c.return_value(new_base + ['link-mtu'])
+ if tmp == '0':
+ c.delete(new_base + ['link-mtu'])
+
+def migrate(config: ConfigTree) -> None:
+ # list all individual interface types like dummy, ethernet and so on
+ for if_type in config.list_nodes(['interfaces']):
+ base_if_type = ['interfaces', if_type]
+
+ # for every individual interface we need to check if there is an
+ # ipv6 ra configured ... and also for every VIF (VLAN) interface
+ for intf in config.list_nodes(base_if_type):
+ old_base = base_if_type + [intf, 'ipv6', 'router-advert']
+ copy_rtradv(config, old_base, intf)
+
+ vif_base = base_if_type + [intf, 'vif']
+ if config.exists(vif_base):
+ for vif in config.list_nodes(vif_base):
+ old_base = vif_base + [vif, 'ipv6', 'router-advert']
+ vlan_name = f'{intf}.{vif}'
+ copy_rtradv(config, old_base, vlan_name)
diff --git a/src/migration-scripts/interfaces/6-to-7 b/src/migration-scripts/interfaces/6-to-7
new file mode 100644
index 0000000..e60121e
--- /dev/null
+++ b/src/migration-scripts/interfaces/6-to-7
@@ -0,0 +1,45 @@
+# Copyright 2020-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/>.
+
+# Remove network provider name from CLI and rather use provider APN from CLI
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'wirelessmodem']
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # list all individual wwan/wireless modem interfaces
+ for i in config.list_nodes(base):
+ iface = base + [i]
+
+ # only three carries have been supported in the past, thus
+ # this will be fairly simple \o/ - and only one (AT&T) did
+ # configure an APN
+ if config.exists(iface + ['network']):
+ network = config.return_value(iface + ['network'])
+ if network == "att":
+ apn = 'isp.cingular'
+ config.set(iface + ['apn'], value=apn)
+
+ config.delete(iface + ['network'])
+
+ # synchronize DNS configuration with PPPoE interfaces to have a
+ # uniform CLI experience
+ if config.exists(iface + ['no-dns']):
+ config.rename(iface + ['no-dns'], 'no-peer-dns')
diff --git a/src/migration-scripts/interfaces/7-to-8 b/src/migration-scripts/interfaces/7-to-8
new file mode 100644
index 0000000..43ae320
--- /dev/null
+++ b/src/migration-scripts/interfaces/7-to-8
@@ -0,0 +1,59 @@
+# Copyright 2020-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/>.
+
+# Split WireGuard endpoint into address / port nodes to make use of common
+# validators
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.utils.permission import chown
+from vyos.utils.permission import chmod_750
+
+def migrate_default_keys():
+ kdir = r'/config/auth/wireguard'
+ if os.path.exists(f'{kdir}/private.key') and not os.path.exists(f'{kdir}/default/private.key'):
+ location = f'{kdir}/default'
+ if not os.path.exists(location):
+ os.makedirs(location)
+
+ chown(location, 'root', 'vyattacfg')
+ chmod_750(location)
+ os.rename(f'{kdir}/private.key', f'{location}/private.key')
+ os.rename(f'{kdir}/public.key', f'{location}/public.key')
+
+def migrate(config: ConfigTree) -> None:
+ base = ['interfaces', 'wireguard']
+
+ migrate_default_keys()
+
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # list all individual wireguard interface isntance
+ for i in config.list_nodes(base):
+ iface = base + [i]
+ for peer in config.list_nodes(iface + ['peer']):
+ base_peer = iface + ['peer', peer]
+ if config.exists(base_peer + ['endpoint']):
+ endpoint = config.return_value(base_peer + ['endpoint'])
+ address = endpoint.split(':')[0]
+ port = endpoint.split(':')[1]
+ # delete old node
+ config.delete(base_peer + ['endpoint'])
+ # setup new nodes
+ config.set(base_peer + ['address'], value=address)
+ config.set(base_peer + ['port'], value=port)
diff --git a/src/migration-scripts/interfaces/8-to-9 b/src/migration-scripts/interfaces/8-to-9
new file mode 100644
index 0000000..bae1b34
--- /dev/null
+++ b/src/migration-scripts/interfaces/8-to-9
@@ -0,0 +1,33 @@
+# Copyright 2020-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/>.
+
+# Rename link nodes to source-interface for the following interface types:
+# - vxlan
+# - pseudo-ethernet
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ for if_type in ['vxlan', 'pseudo-ethernet']:
+ base = ['interfaces', if_type]
+ if not config.exists(base):
+ # Nothing to do
+ continue
+
+ # list all individual interface isntance
+ for i in config.list_nodes(base):
+ iface = base + [i]
+ if config.exists(iface + ['link']):
+ config.rename(iface + ['link'], 'source-interface')
diff --git a/src/migration-scripts/interfaces/9-to-10 b/src/migration-scripts/interfaces/9-to-10
new file mode 100644
index 0000000..cdfd7d4
--- /dev/null
+++ b/src/migration-scripts/interfaces/9-to-10
@@ -0,0 +1,45 @@
+# Copyright 2020-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/>.
+
+# - rename CLI node 'dhcpv6-options delgate' to 'dhcpv6-options prefix-delegation
+# interface'
+# - rename CLI node 'interface-id' for prefix-delegation to 'address' as it
+# represents the local interface IPv6 address assigned by DHCPv6-PD
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ for intf_type in config.list_nodes(['interfaces']):
+ for intf in config.list_nodes(['interfaces', intf_type]):
+ # cache current config tree
+ base_path = ['interfaces', intf_type, intf, 'dhcpv6-options',
+ 'delegate']
+
+ if config.exists(base_path):
+ # cache new config tree
+ new_path = ['interfaces', intf_type, intf, 'dhcpv6-options',
+ 'prefix-delegation']
+ if not config.exists(new_path):
+ config.set(new_path)
+
+ # copy to new node
+ config.copy(base_path, new_path + ['interface'])
+
+ # rename interface-id to address
+ for interface in config.list_nodes(new_path + ['interface']):
+ config.rename(new_path + ['interface', interface, 'interface-id'], 'address')
+
+ # delete old noe
+ config.delete(base_path)
diff --git a/src/migration-scripts/ipoe-server/1-to-2 b/src/migration-scripts/ipoe-server/1-to-2
new file mode 100644
index 0000000..034eacb
--- /dev/null
+++ b/src/migration-scripts/ipoe-server/1-to-2
@@ -0,0 +1,94 @@
+# Copyright 2023-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/>.
+
+# - T4703: merge vlan-id and vlan-range to vlan CLI node
+# L2|L3 -> l2|l3
+# mac-address -> mac
+# network-mode -> mode
+
+# - changed cli of all named pools
+# - moved gateway-address from pool to global configuration with / netmask
+# gateway can exist without pool if radius is used
+# and Framed-ip-address is transmited
+# - There are several gateway-addresses in ipoe
+# - default-pool by migration.
+# 1. The first pool that contains next-poll.
+# 2. Else, the first pool in the list
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'ipoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['authentication', 'interface']):
+ for interface in config.list_nodes(base + ['authentication', 'interface']):
+ config.rename(base + ['authentication', 'interface', interface, 'mac-address'], 'mac')
+
+ mac_base = base + ['authentication', 'interface', interface, 'mac']
+ for mac in config.list_nodes(mac_base):
+ vlan_config = mac_base + [mac, 'vlan-id']
+ if config.exists(vlan_config):
+ config.rename(vlan_config, 'vlan')
+
+ for interface in config.list_nodes(base + ['interface']):
+ base_path = base + ['interface', interface]
+ for vlan in ['vlan-id', 'vlan-range']:
+ if config.exists(base_path + [vlan]):
+ for tmp in config.return_values(base_path + [vlan]):
+ config.set(base_path + ['vlan'], value=tmp, replace=False)
+ config.delete(base_path + [vlan])
+
+ if config.exists(base_path + ['network-mode']):
+ tmp = config.return_value(base_path + ['network-mode'])
+ config.delete(base_path + ['network-mode'])
+ # Change L2|L3 to lower case l2|l3
+ config.set(base_path + ['mode'], value=tmp.lower())
+
+ pool_base = base + ['client-ip-pool']
+ if config.exists(pool_base):
+ default_pool = ''
+ gateway = ''
+
+ #named pool migration
+ namedpools_base = pool_base + ['name']
+
+ for pool_name in config.list_nodes(namedpools_base):
+ pool_path = namedpools_base + [pool_name]
+ if config.exists(pool_path + ['subnet']):
+ subnet = config.return_value(pool_path + ['subnet'])
+ config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
+ # Get netmask from subnet
+ mask = subnet.split("/")[1]
+ if config.exists(pool_path + ['next-pool']):
+ next_pool = config.return_value(pool_path + ['next-pool'])
+ config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
+ if not default_pool:
+ default_pool = pool_name
+ if config.exists(pool_path + ['gateway-address']) and mask:
+ gateway = f'{config.return_value(pool_path + ["gateway-address"])}/{mask}'
+ config.set(base + ['gateway-address'], value=gateway, replace=False)
+
+ if not default_pool and config.list_nodes(namedpools_base):
+ default_pool = config.list_nodes(namedpools_base)[0]
+
+ config.delete(namedpools_base)
+
+ if default_pool:
+ config.set(base + ['default-pool'], value=default_pool)
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/ipoe-server/2-to-3 b/src/migration-scripts/ipoe-server/2-to-3
new file mode 100644
index 0000000..dcd15e5
--- /dev/null
+++ b/src/migration-scripts/ipoe-server/2-to-3
@@ -0,0 +1,40 @@
+# Copyright 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/>.
+
+# Migrating to named ipv6 pools
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'ipoe-server']
+pool_base = base + ['client-ipv6-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ ipv6_pool_name = 'ipv6-pool'
+ config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+ if config.exists(pool_base + ['prefix']):
+ config.delete(pool_base + ['prefix'])
+ config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+ if config.exists(pool_base + ['delegate']):
+ config.delete(pool_base + ['delegate'])
+
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/ipoe-server/3-to-4 b/src/migration-scripts/ipoe-server/3-to-4
new file mode 100644
index 0000000..3bad975
--- /dev/null
+++ b/src/migration-scripts/ipoe-server/3-to-4
@@ -0,0 +1,30 @@
+# Copyright 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/>.
+
+# Add the "vlan-mon" option to the configuration to prevent it
+# from disappearing from the configuration file
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'ipoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ for interface in config.list_nodes(base + ['interface']):
+ base_path = base + ['interface', interface]
+ if config.exists(base_path + ['vlan']):
+ config.set(base_path + ['vlan-mon'])
diff --git a/src/migration-scripts/ipsec/10-to-11 b/src/migration-scripts/ipsec/10-to-11
new file mode 100644
index 0000000..6c4ccb5
--- /dev/null
+++ b/src/migration-scripts/ipsec/10-to-11
@@ -0,0 +1,63 @@
+# Copyright 2023-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/>.
+
+# T4916: Rewrite IPsec peer authentication and psk migration
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # PEER changes
+ if config.exists(base + ['site-to-site', 'peer']):
+ for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+ peer_base = base + ['site-to-site', 'peer', peer]
+
+ # replace: 'ipsec site-to-site peer <tag> authentication pre-shared-secret xxx'
+ # => 'ipsec authentication psk <tag> secret xxx'
+ if config.exists(peer_base + ['authentication', 'pre-shared-secret']):
+ tmp = config.return_value(peer_base + ['authentication', 'pre-shared-secret'])
+ config.delete(peer_base + ['authentication', 'pre-shared-secret'])
+ config.set(base + ['authentication', 'psk', peer, 'secret'], value=tmp)
+ # format as tag node to avoid loading problems
+ config.set_tag(base + ['authentication', 'psk'])
+
+ # Get id's from peers for "ipsec auth psk <tag> id xxx"
+ if config.exists(peer_base + ['authentication', 'local-id']):
+ local_id = config.return_value(peer_base + ['authentication', 'local-id'])
+ config.set(base + ['authentication', 'psk', peer, 'id'], value=local_id, replace=False)
+ if config.exists(peer_base + ['authentication', 'remote-id']):
+ remote_id = config.return_value(peer_base + ['authentication', 'remote-id'])
+ config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_id, replace=False)
+
+ if config.exists(peer_base + ['local-address']):
+ tmp = config.return_value(peer_base + ['local-address'])
+ config.set(base + ['authentication', 'psk', peer, 'id'], value=tmp, replace=False)
+ if config.exists(peer_base + ['remote-address']):
+ tmp = config.return_values(peer_base + ['remote-address'])
+ if tmp:
+ for remote_addr in tmp:
+ if remote_addr == 'any':
+ remote_addr = '%any'
+ config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_addr, replace=False)
+
+ # get DHCP peer interface as psk dhcp-interface
+ if config.exists(peer_base + ['dhcp-interface']):
+ tmp = config.return_value(peer_base + ['dhcp-interface'])
+ config.set(base + ['authentication', 'psk', peer, 'dhcp-interface'], value=tmp)
diff --git a/src/migration-scripts/ipsec/11-to-12 b/src/migration-scripts/ipsec/11-to-12
new file mode 100644
index 0000000..fc65f18
--- /dev/null
+++ b/src/migration-scripts/ipsec/11-to-12
@@ -0,0 +1,31 @@
+# Copyright 2023-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/>.
+
+# Remove legacy ipsec.conf and ipsec.secrets - Not supported with swanctl
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['include-ipsec-conf']):
+ config.delete(base + ['include-ipsec-conf'])
+
+ if config.exists(base + ['include-ipsec-secrets']):
+ config.delete(base + ['include-ipsec-secrets'])
diff --git a/src/migration-scripts/ipsec/12-to-13 b/src/migration-scripts/ipsec/12-to-13
new file mode 100644
index 0000000..ffe766e
--- /dev/null
+++ b/src/migration-scripts/ipsec/12-to-13
@@ -0,0 +1,37 @@
+# Copyright 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/>.
+
+# Changed value of dead-peer-detection.action from hold to trap
+# Changed value of close-action from hold to trap and from restart to start
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec', 'ike-group']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for ike_group in config.list_nodes(base):
+ base_dpd_action = base + [ike_group, 'dead-peer-detection', 'action']
+ base_close_action = base + [ike_group, 'close-action']
+ if config.exists(base_dpd_action) and config.return_value(base_dpd_action) == 'hold':
+ config.set(base_dpd_action, 'trap', replace=True)
+ if config.exists(base_close_action):
+ if config.return_value(base_close_action) == 'hold':
+ config.set(base_close_action, 'trap', replace=True)
+ if config.return_value(base_close_action) == 'restart':
+ config.set(base_close_action, 'start', replace=True)
diff --git a/src/migration-scripts/ipsec/4-to-5 b/src/migration-scripts/ipsec/4-to-5
new file mode 100644
index 0000000..a88a543
--- /dev/null
+++ b/src/migration-scripts/ipsec/4-to-5
@@ -0,0 +1,28 @@
+# Copyright 2019-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/>.
+
+# log-modes have changed, keyword all to any
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['vpn', 'ipsec', 'logging','log-modes']):
+ # Nothing to do
+ return
+
+ lmodes = config.return_values(['vpn', 'ipsec', 'logging','log-modes'])
+ for mode in lmodes:
+ if mode == 'all':
+ config.set(['vpn', 'ipsec', 'logging','log-modes'], value='any', replace=True)
diff --git a/src/migration-scripts/ipsec/5-to-6 b/src/migration-scripts/ipsec/5-to-6
new file mode 100644
index 0000000..373428d
--- /dev/null
+++ b/src/migration-scripts/ipsec/5-to-6
@@ -0,0 +1,73 @@
+# 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/>.
+
+# Remove deprecated strongSwan options from VyOS CLI
+# - vpn ipsec nat-traversal enable
+# - vpn ipsec nat-networks allowed-network
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Delete CLI nodes whose config options got removed by strongSwan
+ for cli_node in ['nat-traversal', 'nat-networks']:
+ if config.exists(base + [cli_node]):
+ config.delete(base + [cli_node])
+
+ # Remove options only valid in Openswan
+ if config.exists(base + ['site-to-site', 'peer']):
+ for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+ if not config.exists(base + ['site-to-site', 'peer', peer, 'tunnel']):
+ continue
+ for tunnel in config.list_nodes(base + ['site-to-site', 'peer', peer, 'tunnel']):
+ # allow-public-networks - Sets a value in ipsec.conf that was only ever valid in Openswan on kernel 2.6
+ nat_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-nat-networks']
+ if config.exists(nat_networks):
+ config.delete(nat_networks)
+
+ # allow-nat-networks - Also sets a value only valid in Openswan
+ public_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-public-networks']
+ if config.exists(public_networks):
+ config.delete(public_networks)
+
+ # Rename "logging log-level" and "logging log-modes" to something more human friendly
+ log = base + ['logging']
+ if config.exists(log):
+ config.rename(log, 'log')
+ log = base + ['log']
+
+ log_level = log + ['log-level']
+ if config.exists(log_level):
+ config.rename(log_level, 'level')
+
+ log_mode = log + ['log-modes']
+ if config.exists(log_mode):
+ config.rename(log_mode, 'subsystem')
+
+ # Rename "ipsec-interfaces interface" to "interface"
+ base_interfaces = base + ['ipsec-interfaces', 'interface']
+ if config.exists(base_interfaces):
+ config.copy(base_interfaces, base + ['interface'])
+ config.delete(base + ['ipsec-interfaces'])
+
+ # Remove deprecated "auto-update" option
+ tmp = base + ['auto-update']
+ if config.exists(tmp):
+ config.delete(tmp)
diff --git a/src/migration-scripts/ipsec/6-to-7 b/src/migration-scripts/ipsec/6-to-7
new file mode 100644
index 0000000..5679477
--- /dev/null
+++ b/src/migration-scripts/ipsec/6-to-7
@@ -0,0 +1,155 @@
+# 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 /config/auth certificates and keys into PKI configuration
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.pki import load_certificate
+from vyos.pki import load_crl
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.utils.process import run
+
+pki_base = ['pki']
+ipsec_site_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
+
+AUTH_DIR = '/config/auth'
+
+def wrapped_pem_to_config_value(pem):
+ return "".join(pem.strip().split("\n")[1:-1])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(ipsec_site_base):
+ return
+
+ migration_needed = False
+ for peer in config.list_nodes(ipsec_site_base):
+ if config.exists(ipsec_site_base + [peer, 'authentication', 'x509']):
+ migration_needed = True
+ break
+
+ if not migration_needed:
+ return
+
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ for peer in config.list_nodes(ipsec_site_base):
+ if not config.exists(ipsec_site_base + [peer, 'authentication', 'x509']):
+ continue
+
+ peer_x509_base = ipsec_site_base + [peer, 'authentication', 'x509']
+ pki_name = 'peer_' + peer.replace(".", "-").replace("@", "")
+
+ if config.exists(peer_x509_base + ['cert-file']):
+ cert_file = config.return_value(peer_x509_base + ['cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(peer_x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on peer "{peer}"')
+
+ config.delete(peer_x509_base + ['cert-file'])
+
+ if config.exists(peer_x509_base + ['ca-cert-file']):
+ ca_cert_file = config.return_value(peer_x509_base + ['ca-cert-file'])
+ ca_cert_path = os.path.join(AUTH_DIR, ca_cert_file)
+ ca_cert = None
+
+ if os.path.isfile(ca_cert_path):
+ if not os.access(ca_cert_path, os.R_OK):
+ run(f'sudo chmod 644 {ca_cert_path}')
+
+ with open(ca_cert_path, 'r') as f:
+ ca_cert_data = f.read()
+ ca_cert = load_certificate(ca_cert_data, wrap_tags=False)
+
+ if ca_cert:
+ ca_cert_pem = encode_certificate(ca_cert)
+ config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(ca_cert_pem))
+ config.set(peer_x509_base + ['ca-certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate CA certificate on peer "{peer}"')
+
+ config.delete(peer_x509_base + ['ca-cert-file'])
+
+ if config.exists(peer_x509_base + ['crl-file']):
+ crl_file = config.return_value(peer_x509_base + ['crl-file'])
+ crl_path = os.path.join(AUTH_DIR, crl_file)
+ crl = None
+
+ if os.path.isfile(crl_path):
+ if not os.access(crl_path, os.R_OK):
+ run(f'sudo chmod 644 {crl_path}')
+
+ with open(crl_path, 'r') as f:
+ crl_data = f.read()
+ crl = load_crl(crl_data, wrap_tags=False)
+
+ if crl:
+ crl_pem = encode_certificate(crl)
+ config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
+ else:
+ print(f'Failed to migrate CRL on peer "{peer}"')
+
+ config.delete(peer_x509_base + ['crl-file'])
+
+ if config.exists(peer_x509_base + ['key', 'file']):
+ key_file = config.return_value(peer_x509_base + ['key', 'file'])
+ key_passphrase = None
+
+ if config.exists(peer_x509_base + ['key', 'password']):
+ key_passphrase = config.return_value(peer_x509_base + ['key', 'password'])
+
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=key_passphrase)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+
+ if key_passphrase:
+ config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected'])
+ config.set(peer_x509_base + ['private-key-passphrase'], value=key_passphrase)
+ else:
+ print(f'Failed to migrate private key on peer "{peer}"')
+
+ config.delete(peer_x509_base + ['key'])
diff --git a/src/migration-scripts/ipsec/7-to-8 b/src/migration-scripts/ipsec/7-to-8
new file mode 100644
index 0000000..481f00d
--- /dev/null
+++ b/src/migration-scripts/ipsec/7-to-8
@@ -0,0 +1,103 @@
+# 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 rsa keys into PKI configuration
+
+import base64
+import os
+import struct
+
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from vyos.configtree import ConfigTree
+from vyos.pki import load_private_key
+from vyos.pki import encode_public_key
+from vyos.pki import encode_private_key
+
+pki_base = ['pki']
+ipsec_site_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
+rsa_keys_base = ['vpn', 'rsa-keys']
+
+LOCAL_KEY_PATHS = ['/config/auth/', '/config/ipsec.d/rsa-keys/']
+
+def migrate_from_vyatta_key(data):
+ data = base64.b64decode(data[2:])
+ length = struct.unpack('B', data[:1])[0]
+ e = int.from_bytes(data[1:1+length], 'big')
+ n = int.from_bytes(data[1+length:], 'big')
+ public_numbers = rsa.RSAPublicNumbers(e, n)
+ return public_numbers.public_key()
+
+def wrapped_pem_to_config_value(pem):
+ return "".join(pem.strip().split("\n")[1:-1])
+
+local_key_name = 'localhost'
+
+def migrate(config: ConfigTree) -> None:
+ if config.exists(rsa_keys_base):
+ if not config.exists(pki_base + ['key-pair']):
+ config.set(pki_base + ['key-pair'])
+ config.set_tag(pki_base + ['key-pair'])
+
+ if config.exists(rsa_keys_base + ['local-key', 'file']):
+ local_file = config.return_value(rsa_keys_base + ['local-key', 'file'])
+ local_path = None
+ local_key = None
+
+ for path in LOCAL_KEY_PATHS:
+ full_path = os.path.join(path, local_file)
+ if os.path.exists(full_path):
+ local_path = full_path
+ break
+
+ if local_path:
+ with open(local_path, 'r') as f:
+ local_key_data = f.read()
+ local_key = load_private_key(local_key_data, wrap_tags=False)
+
+ if local_key:
+ local_key_pem = encode_private_key(local_key)
+ config.set(pki_base + ['key-pair', local_key_name, 'private', 'key'], value=wrapped_pem_to_config_value(local_key_pem))
+ else:
+ print('Failed to migrate local RSA key')
+
+ if config.exists(rsa_keys_base + ['rsa-key-name']):
+ for rsa_name in config.list_nodes(rsa_keys_base + ['rsa-key-name']):
+ if not config.exists(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key']):
+ continue
+
+ vyatta_key = config.return_value(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key'])
+ public_key = migrate_from_vyatta_key(vyatta_key)
+
+ if public_key:
+ public_key_pem = encode_public_key(public_key)
+ config.set(pki_base + ['key-pair', rsa_name, 'public', 'key'], value=wrapped_pem_to_config_value(public_key_pem))
+ else:
+ print(f'Failed to migrate rsa-key "{rsa_name}"')
+
+ config.delete(rsa_keys_base)
+
+ if config.exists(ipsec_site_base):
+ for peer in config.list_nodes(ipsec_site_base):
+ mode = config.return_value(ipsec_site_base + [peer, 'authentication', 'mode'])
+
+ if mode != 'rsa':
+ continue
+
+ config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'local-key'], value=local_key_name)
+
+ remote_key_name = config.return_value(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
+ config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'remote-key'], value=remote_key_name)
+ config.delete(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
diff --git a/src/migration-scripts/ipsec/8-to-9 b/src/migration-scripts/ipsec/8-to-9
new file mode 100644
index 0000000..7f32513
--- /dev/null
+++ b/src/migration-scripts/ipsec/8-to-9
@@ -0,0 +1,30 @@
+# Copyright 2022-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/>.
+
+# T4288 : close-action is missing in swanctl.conf
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec', 'ike-group']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for ike_group in config.list_nodes(base):
+ base_closeaction = base + [ike_group, 'close-action']
+ if config.exists(base_closeaction) and config.return_value(base_closeaction) == 'clear':
+ config.set(base_closeaction, 'none', replace=True)
diff --git a/src/migration-scripts/ipsec/9-to-10 b/src/migration-scripts/ipsec/9-to-10
new file mode 100644
index 0000000..321a759
--- /dev/null
+++ b/src/migration-scripts/ipsec/9-to-10
@@ -0,0 +1,114 @@
+# Copyright 2022-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/>.
+
+# T4118: Change vpn ipsec syntax for IKE ESP and peer
+# T4879: IPsec migration script remote-id for peer name eq address
+
+import re
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'ipsec']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # IKE changes, T4118:
+ if config.exists(base + ['ike-group']):
+ for ike_group in config.list_nodes(base + ['ike-group']):
+ # replace 'ipsec ike-group <tag> mobike disable'
+ # => 'ipsec ike-group <tag> disable-mobike'
+ mobike = base + ['ike-group', ike_group, 'mobike']
+ if config.exists(mobike):
+ if config.return_value(mobike) == 'disable':
+ config.set(base + ['ike-group', ike_group, 'disable-mobike'])
+ config.delete(mobike)
+
+ # replace 'ipsec ike-group <tag> ikev2-reauth yes'
+ # => 'ipsec ike-group <tag> ikev2-reauth'
+ reauth = base + ['ike-group', ike_group, 'ikev2-reauth']
+ if config.exists(reauth):
+ if config.return_value(reauth) == 'yes':
+ config.delete(reauth)
+ config.set(reauth)
+ else:
+ config.delete(reauth)
+
+ # ESP changes
+ # replace 'ipsec esp-group <tag> compression enable'
+ # => 'ipsec esp-group <tag> compression'
+ if config.exists(base + ['esp-group']):
+ for esp_group in config.list_nodes(base + ['esp-group']):
+ compression = base + ['esp-group', esp_group, 'compression']
+ if config.exists(compression):
+ if config.return_value(compression) == 'enable':
+ config.delete(compression)
+ config.set(compression)
+ else:
+ config.delete(compression)
+
+ # PEER changes
+ if config.exists(base + ['site-to-site', 'peer']):
+ for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+ peer_base = base + ['site-to-site', 'peer', peer]
+
+ # replace: 'peer <tag> id x'
+ # => 'peer <tag> local-id x'
+ if config.exists(peer_base + ['authentication', 'id']):
+ config.rename(peer_base + ['authentication', 'id'], 'local-id')
+
+ # For the peer '@foo' set remote-id 'foo' if remote-id is not defined
+ # For the peer '192.0.2.1' set remote-id '192.0.2.1' if remote-id is not defined
+ if not config.exists(peer_base + ['authentication', 'remote-id']):
+ tmp = peer.replace('@', '') if peer.startswith('@') else peer
+ config.set(peer_base + ['authentication', 'remote-id'], value=tmp)
+
+ # replace: 'peer <tag> force-encapsulation enable'
+ # => 'peer <tag> force-udp-encapsulation'
+ force_enc = peer_base + ['force-encapsulation']
+ if config.exists(force_enc):
+ if config.return_value(force_enc) == 'enable':
+ config.delete(force_enc)
+ config.set(peer_base + ['force-udp-encapsulation'])
+ else:
+ config.delete(force_enc)
+
+ # add option: 'peer <tag> remote-address x.x.x.x'
+ remote_address = peer
+ if peer.startswith('@'):
+ remote_address = 'any'
+ config.set(peer_base + ['remote-address'], value=remote_address)
+ # Peer name it is swanctl connection name and shouldn't contain dots or colons
+ # rename peer:
+ # peer 192.0.2.1 => peer peer_192-0-2-1
+ # peer 2001:db8::2 => peer peer_2001-db8--2
+ # peer @foo => peer peer_foo
+ re_peer_name = re.sub(':|\.', '-', peer)
+ if re_peer_name.startswith('@'):
+ re_peer_name = re.sub('@', '', re_peer_name)
+ new_peer_name = f'peer_{re_peer_name}'
+
+ config.rename(peer_base, new_peer_name)
+
+ # remote-access/road-warrior changes
+ if config.exists(base + ['remote-access', 'connection']):
+ for connection in config.list_nodes(base + ['remote-access', 'connection']):
+ ra_base = base + ['remote-access', 'connection', connection]
+ # replace: 'remote-access connection <tag> authentication id x'
+ # => 'remote-access connection <tag> authentication local-id x'
+ if config.exists(ra_base + ['authentication', 'id']):
+ config.rename(ra_base + ['authentication', 'id'], 'local-id')
diff --git a/src/migration-scripts/isis/0-to-1 b/src/migration-scripts/isis/0-to-1
new file mode 100644
index 0000000..e242885
--- /dev/null
+++ b/src/migration-scripts/isis/0-to-1
@@ -0,0 +1,36 @@
+# 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/>.
+
+# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'isis']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # We need a temporary copy of the config
+ tmp_base = ['protocols', 'isis2']
+ config.copy(base, tmp_base)
+
+ # Now it's save to delete the old configuration
+ config.delete(base)
+
+ # Rename temporary copy to new final config (IS-IS domain key is static and no
+ # longer required to be set via CLI)
+ config.rename(tmp_base, 'isis')
diff --git a/src/migration-scripts/isis/1-to-2 b/src/migration-scripts/isis/1-to-2
new file mode 100644
index 0000000..0fc92a6
--- /dev/null
+++ b/src/migration-scripts/isis/1-to-2
@@ -0,0 +1,27 @@
+# Copyright 2022-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/>.
+
+# T4739 refactor, and remove "on" from segment routing from the configuration
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ # Check if ISIS segment routing is configured. Then check if segment
+ # routing "on" exists, then delete the "on" as it is no longer needed.
+ # This is for global configuration.
+ if config.exists(['protocols', 'isis']):
+ if config.exists(['protocols', 'isis', 'segment-routing']):
+ if config.exists(['protocols', 'isis', 'segment-routing', 'enable']):
+ config.delete(['protocols', 'isis', 'segment-routing', 'enable'])
diff --git a/src/migration-scripts/isis/2-to-3 b/src/migration-scripts/isis/2-to-3
new file mode 100644
index 0000000..afb9f23
--- /dev/null
+++ b/src/migration-scripts/isis/2-to-3
@@ -0,0 +1,43 @@
+# Copyright 2023-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/>.
+
+# T5150: Rework CLI definitions to apply route-maps between routing daemons
+# and zebra/kernel
+
+from vyos.configtree import ConfigTree
+
+isis_base = ['protocols', 'isis']
+
+def migrate(config: ConfigTree) -> None:
+ # Check if IS-IS is configured - if so, migrate the CLI node
+ if config.exists(isis_base):
+ if config.exists(isis_base + ['route-map']):
+ tmp = config.return_value(isis_base + ['route-map'])
+
+ config.set(['system', 'ip', 'protocol', 'isis', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ip', 'protocol'])
+ config.delete(isis_base + ['route-map'])
+
+ # Check if vrf names are configured. Check if IS-IS is configured - if so,
+ # migrate the CLI node(s)
+ if config.exists(['vrf', 'name']):
+ for vrf in config.list_nodes(['vrf', 'name']):
+ vrf_base = ['vrf', 'name', vrf]
+ if config.exists(vrf_base + ['protocols', 'isis', 'route-map']):
+ tmp = config.return_value(vrf_base + ['protocols', 'isis', 'route-map'])
+
+ config.set(vrf_base + ['ip', 'protocol', 'isis', 'route-map'], value=tmp)
+ config.set_tag(vrf_base + ['ip', 'protocol', 'isis'])
+ config.delete(vrf_base + ['protocols', 'isis', 'route-map'])
diff --git a/src/migration-scripts/l2tp/0-to-1 b/src/migration-scripts/l2tp/0-to-1
new file mode 100644
index 0000000..f0cb6af
--- /dev/null
+++ b/src/migration-scripts/l2tp/0-to-1
@@ -0,0 +1,56 @@
+# Copyright 2018-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/>.
+
+# T987: Unclutter L2TP/IPSec RADIUS configuration nodes
+# Unclutter L2TP VPN configuiration - move radius-server top level tag
+# nodes to a regular node which now also configures the radius source address
+# used when querying a radius server
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['vpn', 'l2tp', 'remote-access', 'authentication']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cfg_base):
+ # Nothing to do
+ return
+
+ # Migrate "vpn l2tp authentication radius-source-address" to new
+ # "vpn l2tp authentication radius source-address"
+ if config.exists(cfg_base + ['radius-source-address']):
+ address = config.return_value(cfg_base + ['radius-source-address'])
+ # delete old configuration node
+ config.delete(cfg_base + ['radius-source-address'])
+ # write new configuration node
+ config.set(cfg_base + ['radius', 'source-address'], value=address)
+
+ # Migrate "vpn l2tp authentication radius-server" tag node to new
+ # "vpn l2tp authentication radius server" tag node
+ if config.exists(cfg_base + ['radius-server']):
+ for server in config.list_nodes(cfg_base + ['radius-server']):
+ base_server = cfg_base + ['radius-server', server]
+ key = config.return_value(base_server + ['key'])
+
+ # delete old configuration node
+ config.delete(base_server)
+ # write new configuration node
+ config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
+
+ # format as tag node
+ config.set_tag(cfg_base + ['radius', 'server'])
+
+ # delete top level tag node
+ if config.exists(cfg_base + ['radius-server']):
+ config.delete(cfg_base + ['radius-server'])
diff --git a/src/migration-scripts/l2tp/1-to-2 b/src/migration-scripts/l2tp/1-to-2
new file mode 100644
index 0000000..468d564
--- /dev/null
+++ b/src/migration-scripts/l2tp/1-to-2
@@ -0,0 +1,28 @@
+# Copyright 2019-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/>.
+
+# T1858: Delete deprecated outside-nexthop
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['vpn', 'l2tp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cfg_base):
+ # Nothing to do
+ return
+
+ if config.exists(cfg_base + ['outside-nexthop']):
+ config.delete(cfg_base + ['outside-nexthop'])
diff --git a/src/migration-scripts/l2tp/2-to-3 b/src/migration-scripts/l2tp/2-to-3
new file mode 100644
index 0000000..00fabb6
--- /dev/null
+++ b/src/migration-scripts/l2tp/2-to-3
@@ -0,0 +1,92 @@
+# Copyright 2020-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/>.
+
+# T2264: combine IPv4/IPv6 name-server CLI syntax
+# T2264: combine WINS CLI syntax
+# T2264: remove RADIUS req-limit node
+# T2264: migrate IPv6 prefix node to common CLI style
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'l2tp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Migrate IPv4 DNS servers
+ dns_base = base + ['dns-servers']
+ if config.exists(dns_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(dns_base + [server]):
+ dns = config.return_value(dns_base + [server])
+ config.set(base + ['name-server'], value=dns, replace=False)
+
+ config.delete(dns_base)
+
+ # Migrate IPv6 DNS servers
+ dns_base = base + ['dnsv6-servers']
+ if config.exists(dns_base):
+ for server in config.return_values(dns_base):
+ config.set(base + ['name-server'], value=server, replace=False)
+
+ config.delete(dns_base)
+
+ # Migrate IPv4 WINS servers
+ wins_base = base + ['wins-servers']
+ if config.exists(wins_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(wins_base + [server]):
+ wins = config.return_value(wins_base + [server])
+ config.set(base + ['wins-server'], value=wins, replace=False)
+
+ config.delete(wins_base)
+
+
+ # Remove RADIUS server req-limit node
+ radius_base = base + ['authentication', 'radius']
+ if config.exists(radius_base):
+ for server in config.list_nodes(radius_base + ['server']):
+ if config.exists(radius_base + ['server', server, 'req-limit']):
+ config.delete(radius_base + ['server', server, 'req-limit'])
+
+ # Migrate IPv6 prefixes
+ ipv6_base = base + ['client-ipv6-pool']
+ if config.exists(ipv6_base + ['prefix']):
+ prefix_old = config.return_values(ipv6_base + ['prefix'])
+ # delete old prefix CLI nodes
+ config.delete(ipv6_base + ['prefix'])
+ # create ned prefix tag node
+ config.set(ipv6_base + ['prefix'])
+ config.set_tag(ipv6_base + ['prefix'])
+
+ for p in prefix_old:
+ prefix = p.split(',')[0]
+ mask = p.split(',')[1]
+ config.set(ipv6_base + ['prefix', prefix, 'mask'], value=mask)
+
+ if config.exists(ipv6_base + ['delegate-prefix']):
+ prefix_old = config.return_values(ipv6_base + ['delegate-prefix'])
+ # delete old delegate prefix CLI nodes
+ config.delete(ipv6_base + ['delegate-prefix'])
+ # create ned delegation tag node
+ config.set(ipv6_base + ['delegate'])
+ config.set_tag(ipv6_base + ['delegate'])
+
+ for p in prefix_old:
+ prefix = p.split(',')[0]
+ mask = p.split(',')[1]
+ config.set(ipv6_base + ['delegate', prefix, 'delegate-prefix'], value=mask)
diff --git a/src/migration-scripts/l2tp/3-to-4 b/src/migration-scripts/l2tp/3-to-4
new file mode 100644
index 0000000..01c3fa8
--- /dev/null
+++ b/src/migration-scripts/l2tp/3-to-4
@@ -0,0 +1,148 @@
+# 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/>.
+
+# T2816: T3642: Move IPSec/L2TP code into vpn_ipsec.py and update to use PKI.
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.utils.process import run
+
+base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings']
+pki_base = ['pki']
+
+AUTH_DIR = '/config/auth'
+
+def wrapped_pem_to_config_value(pem):
+ return "".join(pem.strip().split("\n")[1:-1])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(base + ['authentication', 'x509']):
+ return
+
+ x509_base = base + ['authentication', 'x509']
+ pki_name = 'l2tp_remote_access'
+
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ if config.exists(x509_base + ['ca-cert-file']):
+ cert_file = config.return_value(x509_base + ['ca-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['ca-certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate CA certificate on l2tp remote-access config')
+
+ config.delete(x509_base + ['ca-cert-file'])
+
+ if config.exists(x509_base + ['crl-file']):
+ crl_file = config.return_value(x509_base + ['crl-file'])
+ crl_path = os.path.join(AUTH_DIR, crl_file)
+ crl = None
+
+ if os.path.isfile(crl_path):
+ if not os.access(crl_path, os.R_OK):
+ run(f'sudo chmod 644 {crl_path}')
+
+ with open(crl_path, 'r') as f:
+ crl_data = f.read()
+ crl = load_certificate(crl_data, wrap_tags=False)
+
+ if crl:
+ crl_pem = encode_certificate(crl)
+ config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
+ else:
+ print(f'Failed to migrate CRL on l2tp remote-access config')
+
+ config.delete(x509_base + ['crl-file'])
+
+ if config.exists(x509_base + ['server-cert-file']):
+ cert_file = config.return_value(x509_base + ['server-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on l2tp remote-access config')
+
+ config.delete(x509_base + ['server-cert-file'])
+
+ if config.exists(x509_base + ['server-key-file']):
+ key_file = config.return_value(x509_base + ['server-key-file'])
+ key_passphrase = None
+
+ if config.exists(x509_base + ['server-key-password']):
+ key_passphrase = config.return_value(x509_base + ['server-key-password'])
+
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=key_passphrase)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+
+ if key_passphrase:
+ config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected'])
+ config.set(x509_base + ['private-key-passphrase'], value=key_passphrase)
+ else:
+ print(f'Failed to migrate private key on l2tp remote-access config')
+
+ config.delete(x509_base + ['server-key-file'])
+ if config.exists(x509_base + ['server-key-password']):
+ config.delete(x509_base + ['server-key-password'])
diff --git a/src/migration-scripts/l2tp/4-to-5 b/src/migration-scripts/l2tp/4-to-5
new file mode 100644
index 0000000..56d451b
--- /dev/null
+++ b/src/migration-scripts/l2tp/4-to-5
@@ -0,0 +1,68 @@
+# Copyright 2023-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/>.
+
+# - move all pool to named pools
+# 'start-stop' migrate to namedpool 'default-range-pool'
+# 'subnet' migrate to namedpool 'default-subnet-pool'
+# 'default-subnet-pool' is the next pool for 'default-range-pool'
+
+from vyos.configtree import ConfigTree
+from vyos.base import Warning
+
+base = ['vpn', 'l2tp', 'remote-access']
+pool_base = base + ['client-ip-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ default_pool = ''
+ range_pool_name = 'default-range-pool'
+
+ if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+ def is_legalrange(ip1: str, ip2: str, mask: str):
+ from ipaddress import IPv4Interface
+ interface1 = IPv4Interface(f'{ip1}/{mask}')
+
+ interface2 = IPv4Interface(f'{ip2}/{mask}')
+ return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+
+ start_ip = config.return_value(pool_base + ['start'])
+ stop_ip = config.return_value(pool_base + ['stop'])
+ if is_legalrange(start_ip, stop_ip,'24'):
+ ip_range = f'{start_ip}-{stop_ip}'
+ config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+ default_pool = range_pool_name
+ else:
+ Warning(
+ f'L2TP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+
+ config.delete(pool_base + ['start'])
+ config.delete(pool_base + ['stop'])
+
+ if config.exists(pool_base + ['subnet']):
+ for subnet in config.return_values(pool_base + ['subnet']):
+ config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+
+ config.delete(pool_base + ['subnet'])
+ default_pool = range_pool_name
+
+ if default_pool:
+ config.set(base + ['default-pool'], value=default_pool)
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/l2tp/5-to-6 b/src/migration-scripts/l2tp/5-to-6
new file mode 100644
index 0000000..cc9f948
--- /dev/null
+++ b/src/migration-scripts/l2tp/5-to-6
@@ -0,0 +1,88 @@
+# Copyright 2023-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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'l2tp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ #migrate idle to ppp option lcp-echo-timeout
+ idle_path = base + ['idle']
+ if config.exists(idle_path):
+ config.set(base + ['ppp-options', 'lcp-echo-timeout'],
+ value=config.return_value(idle_path))
+ config.delete(idle_path)
+
+ #migrate mppe from authentication to ppp-otion
+ mppe_path = base + ['authentication', 'mppe']
+ if config.exists(mppe_path):
+ config.set(base + ['ppp-options', 'mppe'],
+ value=config.return_value(mppe_path))
+ config.delete(mppe_path)
+
+ #migrate require to protocol
+ require_path = base + ['authentication', 'require']
+ if config.exists(require_path):
+ protocols = list(config.return_values(require_path))
+ for protocol in protocols:
+ config.set(base + ['authentication', 'protocols'], value=protocol,
+ replace=False)
+ config.delete(require_path)
+ else:
+ config.set(base + ['authentication', 'protocols'], value='mschap-v2')
+
+ #migrate default gateway if not exist
+ if not config.exists(base + ['gateway-address']):
+ config.set(base + ['gateway-address'], value='10.255.255.0')
+
+ #migrate authentication radius timeout
+ rad_timeout_path = base + ['authentication', 'radius', 'timeout']
+ if config.exists(rad_timeout_path):
+ if int(config.return_value(rad_timeout_path)) > 60:
+ config.set(rad_timeout_path, value=60)
+
+ #migrate authentication radius acct timeout
+ rad_acct_timeout_path = base + ['authentication', 'radius', 'acct-timeout']
+ if config.exists(rad_acct_timeout_path):
+ if int(config.return_value(rad_acct_timeout_path)) > 60:
+ config.set(rad_acct_timeout_path,value=60)
+
+ #migrate authentication radius max-try
+ rad_max_try_path = base + ['authentication', 'radius', 'max-try']
+ if config.exists(rad_max_try_path):
+ if int(config.return_value(rad_max_try_path)) > 20:
+ config.set(rad_max_try_path, value=20)
+
+ #migrate dae-server to dynamic-author
+ dae_path_old = base + ['authentication', 'radius', 'dae-server']
+ dae_path_new = base + ['authentication', 'radius', 'dynamic-author']
+
+ if config.exists(dae_path_old + ['ip-address']):
+ config.set(dae_path_new + ['server'],
+ value=config.return_value(dae_path_old + ['ip-address']))
+
+ if config.exists(dae_path_old + ['port']):
+ config.set(dae_path_new + ['port'],
+ value=config.return_value(dae_path_old + ['port']))
+
+ if config.exists(dae_path_old + ['secret']):
+ config.set(dae_path_new + ['key'],
+ value=config.return_value(dae_path_old + ['secret']))
+
+ if config.exists(dae_path_old):
+ config.delete(dae_path_old)
diff --git a/src/migration-scripts/l2tp/6-to-7 b/src/migration-scripts/l2tp/6-to-7
new file mode 100644
index 0000000..4dba597
--- /dev/null
+++ b/src/migration-scripts/l2tp/6-to-7
@@ -0,0 +1,39 @@
+# Copyright 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/>.
+
+# Migrating to named ipv6 pools
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'l2tp', 'remote-access']
+pool_base = base + ['client-ipv6-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ ipv6_pool_name = 'ipv6-pool'
+ config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+ if config.exists(pool_base + ['prefix']):
+ config.delete(pool_base + ['prefix'])
+ config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+ if config.exists(pool_base + ['delegate']):
+ config.delete(pool_base + ['delegate'])
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/l2tp/7-to-8 b/src/migration-scripts/l2tp/7-to-8
new file mode 100644
index 0000000..527906f
--- /dev/null
+++ b/src/migration-scripts/l2tp/7-to-8
@@ -0,0 +1,47 @@
+# Copyright 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 from 'ccp-disable' to 'ppp-options.disable-ccp'
+# Migration ipv6 options
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'l2tp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ #CCP migration
+ if config.exists(base + ['ccp-disable']):
+ config.delete(base + ['ccp-disable'])
+ config.set(base + ['ppp-options', 'disable-ccp'])
+
+ #IPV6 options migrations
+ if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
+ intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
+ if intf_peer_id == 'ipv4':
+ intf_peer_id = 'ipv4-addr'
+ config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
+ config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
+
+ if config.exists(base + ['ppp-options','ipv6-intf-id']):
+ intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
+ config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
+ config.delete(base + ['ppp-options','ipv6-intf-id'])
+
+ if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
+ config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
+ config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
diff --git a/src/migration-scripts/l2tp/8-to-9 b/src/migration-scripts/l2tp/8-to-9
new file mode 100644
index 0000000..e6b689e
--- /dev/null
+++ b/src/migration-scripts/l2tp/8-to-9
@@ -0,0 +1,28 @@
+# Copyright 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/>.
+
+# Deleted 'dhcp-interface' from l2tp
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'l2tp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ # deleting unused dhcp-interface
+ if config.exists(base + ['dhcp-interface']):
+ config.delete(base + ['dhcp-interface'])
diff --git a/src/migration-scripts/lldp/0-to-1 b/src/migration-scripts/lldp/0-to-1
new file mode 100644
index 0000000..c16e7e8
--- /dev/null
+++ b/src/migration-scripts/lldp/0-to-1
@@ -0,0 +1,31 @@
+# Copyright 2020-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/>.
+
+# Delete "set service lldp interface <interface> location civic-based" option
+# as it was broken most of the time anyways
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'lldp', 'interface']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Delete nodes with abandoned CLI syntax
+ for interface in config.list_nodes(base):
+ if config.exists(base + [interface, 'location', 'civic-based']):
+ config.delete(base + [interface, 'location', 'civic-based'])
diff --git a/src/migration-scripts/lldp/1-to-2 b/src/migration-scripts/lldp/1-to-2
new file mode 100644
index 0000000..7f233a7
--- /dev/null
+++ b/src/migration-scripts/lldp/1-to-2
@@ -0,0 +1,30 @@
+# Copyright 2023-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/>.
+
+# T5855: migrate "set service lldp snmp enable" -> `set service lldp snmp"
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'lldp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['snmp']):
+ enabled = config.exists(base + ['snmp', 'enable'])
+ config.delete(base + ['snmp'])
+ if enabled: config.set(base + ['snmp'])
diff --git a/src/migration-scripts/monitoring/0-to-1 b/src/migration-scripts/monitoring/0-to-1
new file mode 100644
index 0000000..92f8243
--- /dev/null
+++ b/src/migration-scripts/monitoring/0-to-1
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+
+# Copyright 2022-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/>.
+
+# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'monitoring', 'telegraf']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['authentication', 'organization']):
+ tmp = config.return_value(base + ['authentication', 'organization'])
+ config.delete(base + ['authentication', 'organization'])
+ config.set(base + ['influxdb', 'authentication', 'organization'], value=tmp)
+
+ if config.exists(base + ['authentication', 'token']):
+ tmp = config.return_value(base + ['authentication', 'token'])
+ config.delete(base + ['authentication', 'token'])
+ config.set(base + ['influxdb', 'authentication', 'token'], value=tmp)
+
+ if config.exists(base + ['bucket']):
+ tmp = config.return_value(base + ['bucket'])
+ config.delete(base + ['bucket'])
+ config.set(base + ['influxdb', 'bucket'], value=tmp)
+
+ if config.exists(base + ['port']):
+ tmp = config.return_value(base + ['port'])
+ config.delete(base + ['port'])
+ config.set(base + ['influxdb', 'port'], value=tmp)
+
+ if config.exists(base + ['url']):
+ tmp = config.return_value(base + ['url'])
+ config.delete(base + ['url'])
+ config.set(base + ['influxdb', 'url'], value=tmp)
diff --git a/src/migration-scripts/nat/4-to-5 b/src/migration-scripts/nat/4-to-5
new file mode 100644
index 0000000..e1919da
--- /dev/null
+++ b/src/migration-scripts/nat/4-to-5
@@ -0,0 +1,45 @@
+# Copyright 2020-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/>.
+
+# Drop the enable/disable from the nat "log" node. If log node is specified
+# it is "enabled"
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat']):
+ # Nothing to do
+ return
+
+ for direction in ['source', 'destination']:
+ # If a node doesn't exist, we obviously have nothing to do.
+ if not config.exists(['nat', direction]):
+ continue
+
+ # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+ # but there are no rules under it.
+ if not config.list_nodes(['nat', direction]):
+ continue
+
+ for rule in config.list_nodes(['nat', direction, 'rule']):
+ base = ['nat', direction, 'rule', rule]
+
+ # Check if the log node exists and if log is enabled,
+ # migrate it to the new valueless 'log' node
+ if config.exists(base + ['log']):
+ tmp = config.return_value(base + ['log'])
+ config.delete(base + ['log'])
+ if tmp == 'enable':
+ config.set(base + ['log'])
diff --git a/src/migration-scripts/nat/5-to-6 b/src/migration-scripts/nat/5-to-6
new file mode 100644
index 0000000..a583d4e
--- /dev/null
+++ b/src/migration-scripts/nat/5-to-6
@@ -0,0 +1,82 @@
+# Copyright 2023-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/>.
+
+# T5643: move from 'set nat [source|destination] rule X [inbound-interface|outbound interface] <iface>'
+# to
+# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>'
+
+# T6100: Migration from 1.3.X to 1.4
+# Change IP/netmask to Network/netmask in
+# 'set nat [source|destination] rule X [source| destination| translation] address <IP/Netmask| !IP/Netmask>'
+
+import ipaddress
+
+from vyos.configtree import ConfigTree
+
+
+def _func_T5643(conf, base_path):
+ for iface in ['inbound-interface', 'outbound-interface']:
+ if conf.exists(base_path + [iface]):
+ tmp = conf.return_value(base_path + [iface])
+ if tmp:
+ conf.delete(base_path + [iface])
+ conf.set(base_path + [iface, 'interface-name'], value=tmp)
+ return
+
+
+def _func_T6100(conf, base_path):
+ for addr_type in ['source', 'destination', 'translation']:
+ base_addr_type = base_path + [addr_type]
+ if not conf.exists(base_addr_type) or not conf.exists(
+ base_addr_type + ['address']):
+ continue
+
+ address = conf.return_value(base_addr_type + ['address'])
+
+ if not address or '/' not in address:
+ continue
+
+ negative = ''
+ network = address
+ if '!' in address:
+ negative = '!'
+ network = str(address.split(negative)[1])
+
+ network_ip = ipaddress.ip_network(network, strict=False)
+ if str(network_ip) != network:
+ network = f'{negative}{str(network_ip)}'
+ conf.set(base_addr_type + ['address'], value=network)
+ return
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat']):
+ # Nothing to do
+ return
+
+ for direction in ['source', 'destination']:
+ # If a node doesn't exist, we obviously have nothing to do.
+ if not config.exists(['nat', direction]):
+ continue
+
+ # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+ # but there are no rules under it.
+ if not config.list_nodes(['nat', direction]):
+ continue
+
+ for rule in config.list_nodes(['nat', direction, 'rule']):
+ base = ['nat', direction, 'rule', rule]
+ _func_T5643(config,base)
+ _func_T6100(config,base)
diff --git a/src/migration-scripts/nat/6-to-7 b/src/migration-scripts/nat/6-to-7
new file mode 100644
index 0000000..e9b90fc
--- /dev/null
+++ b/src/migration-scripts/nat/6-to-7
@@ -0,0 +1,54 @@
+# Copyright 2023-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/>.
+
+# T5681: Firewall re-writing. Simplify cli when mathcing interface
+# From
+# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>'
+# 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-group <iface_group>'
+# to
+# 'set nat [source|destination] rule X [inbound-interface|outbound interface] name <iface>'
+# 'set nat [source|destination] rule X [inbound-interface|outbound interface] group <iface_group>'
+# Also remove command if interface == any
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat']):
+ # Nothing to do
+ return
+
+ for direction in ['source', 'destination']:
+ # If a node doesn't exist, we obviously have nothing to do.
+ if not config.exists(['nat', direction]):
+ continue
+
+ # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+ # but there are no rules under it.
+ if not config.list_nodes(['nat', direction]):
+ continue
+
+ for rule in config.list_nodes(['nat', direction, 'rule']):
+ base = ['nat', direction, 'rule', rule]
+ for iface in ['inbound-interface','outbound-interface']:
+ if config.exists(base + [iface]):
+ if config.exists(base + [iface, 'interface-name']):
+ tmp = config.return_value(base + [iface, 'interface-name'])
+ if tmp != 'any':
+ config.delete(base + [iface, 'interface-name'])
+ if '+' in tmp:
+ tmp = tmp.replace('+', '*')
+ config.set(base + [iface, 'name'], value=tmp)
+ else:
+ config.delete(base + [iface])
diff --git a/src/migration-scripts/nat/7-to-8 b/src/migration-scripts/nat/7-to-8
new file mode 100644
index 0000000..9ae389e
--- /dev/null
+++ b/src/migration-scripts/nat/7-to-8
@@ -0,0 +1,43 @@
+# Copyright 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/>.
+
+# T6345: random - In kernel 5.0 and newer this is the same as fully-random.
+# In earlier kernels the port mapping will be randomized using a seeded
+# MD5 hash mix using source and destination address and destination port.
+# drop fully-random from CLI
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat']):
+ # Nothing to do
+ return
+
+ for direction in ['source', 'destination']:
+ # If a node doesn't exist, we obviously have nothing to do.
+ if not config.exists(['nat', direction]):
+ continue
+
+ # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+ # but there are no rules under it.
+ if not config.list_nodes(['nat', direction]):
+ continue
+
+ for rule in config.list_nodes(['nat', direction, 'rule']):
+ port_mapping = ['nat', direction, 'rule', rule, 'translation', 'options', 'port-mapping']
+ if config.exists(port_mapping):
+ tmp = config.return_value(port_mapping)
+ if tmp == 'fully-random':
+ config.set(port_mapping, value='random')
diff --git a/src/migration-scripts/nat66/0-to-1 b/src/migration-scripts/nat66/0-to-1
new file mode 100644
index 0000000..b3c6bf4
--- /dev/null
+++ b/src/migration-scripts/nat66/0-to-1
@@ -0,0 +1,52 @@
+# Copyright 2020-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/>.
+
+from vyos.configtree import ConfigTree
+
+def merge_npt(config,base,rule):
+ merge_base = ['nat66','source','rule',rule]
+ # Configure migration functions
+ if config.exists(base + ['description']):
+ tmp = config.return_value(base + ['description'])
+ config.set(merge_base + ['description'],value=tmp)
+
+ if config.exists(base + ['disable']):
+ tmp = config.return_value(base + ['disable'])
+ config.set(merge_base + ['disable'],value=tmp)
+
+ if config.exists(base + ['outbound-interface']):
+ tmp = config.return_value(base + ['outbound-interface'])
+ config.set(merge_base + ['outbound-interface'],value=tmp)
+
+ if config.exists(base + ['source','prefix']):
+ tmp = config.return_value(base + ['source','prefix'])
+ config.set(merge_base + ['source','prefix'],value=tmp)
+
+ if config.exists(base + ['translation','prefix']):
+ tmp = config.return_value(base + ['translation','prefix'])
+ config.set(merge_base + ['translation','address'],value=tmp)
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat', 'nptv6']):
+ # Nothing to do
+ return
+
+ for rule in config.list_nodes(['nat', 'nptv6', 'rule']):
+ base = ['nat', 'nptv6', 'rule', rule]
+ # Merge 'nat nptv6' to 'nat66 source'
+ merge_npt(config,base,rule)
+
+ # Delete the original NPT configuration
+ config.delete(['nat','nptv6']);
diff --git a/src/migration-scripts/nat66/1-to-2 b/src/migration-scripts/nat66/1-to-2
new file mode 100644
index 0000000..f49940a
--- /dev/null
+++ b/src/migration-scripts/nat66/1-to-2
@@ -0,0 +1,61 @@
+#!/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/>.
+
+# Copyright 2023-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/>.
+
+# T5681: Firewall re-writing. Simplify cli when mathcing interface
+# From
+# 'set nat66 [source|destination] rule X [inbound-interface|outbound interface] <iface>'
+# to
+# 'set nat66 [source|destination] rule X [inbound-interface|outbound interface] name <iface>'
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['nat66']):
+ # Nothing to do
+ return
+
+ for direction in ['source', 'destination']:
+ # If a node doesn't exist, we obviously have nothing to do.
+ if not config.exists(['nat66', direction]):
+ continue
+
+ # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+ # but there are no rules under it.
+ if not config.list_nodes(['nat66', direction]):
+ continue
+
+ for rule in config.list_nodes(['nat66', direction, 'rule']):
+ base = ['nat66', direction, 'rule', rule]
+ for iface in ['inbound-interface','outbound-interface']:
+ if config.exists(base + [iface]):
+ tmp = config.return_value(base + [iface])
+ config.delete(base + [iface])
+ config.set(base + [iface, 'name'], value=tmp)
diff --git a/src/migration-scripts/nat66/2-to-3 b/src/migration-scripts/nat66/2-to-3
new file mode 100644
index 0000000..55d5f4b
--- /dev/null
+++ b/src/migration-scripts/nat66/2-to-3
@@ -0,0 +1,45 @@
+# Copyright 2023-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/>.
+
+# T2898: add ndp-proxy service
+
+from vyos.configtree import ConfigTree
+
+base = ['nat66', 'source']
+new_base = ['service', 'ndp-proxy', 'interface']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for rule in config.list_nodes(base + ['rule']):
+ base_rule = base + ['rule', rule]
+
+ interface = None
+ if config.exists(base_rule + ['outbound-interface', 'name']):
+ interface = config.return_value(base_rule + ['outbound-interface', 'name'])
+ else:
+ continue
+
+ prefix_base = base_rule + ['source', 'prefix']
+ if config.exists(prefix_base):
+ prefix = config.return_value(prefix_base)
+ config.set(new_base + [interface, 'prefix', prefix, 'mode'], value='static')
+ config.set_tag(new_base)
+ config.set_tag(new_base + [interface, 'prefix'])
+
+ if config.exists(base_rule + ['disable']):
+ config.set(new_base + [interface, 'prefix', prefix, 'disable'])
diff --git a/src/migration-scripts/ntp/0-to-1 b/src/migration-scripts/ntp/0-to-1
new file mode 100644
index 0000000..01f5a46
--- /dev/null
+++ b/src/migration-scripts/ntp/0-to-1
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+
+# Copyright 2018-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/>.
+
+# Delete "set system ntp server <n> dynamic" option
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['system', 'ntp', 'server']):
+ # Nothing to do
+ return
+
+ # Delete abandoned leaf node if found inside tag node for
+ # "set system ntp server <n> dynamic"
+ base = ['system', 'ntp', 'server']
+ for server in config.list_nodes(base):
+ if config.exists(base + [server, 'dynamic']):
+ config.delete(base + [server, 'dynamic'])
diff --git a/src/migration-scripts/ntp/1-to-2 b/src/migration-scripts/ntp/1-to-2
new file mode 100644
index 0000000..fd7b082
--- /dev/null
+++ b/src/migration-scripts/ntp/1-to-2
@@ -0,0 +1,53 @@
+# Copyright 2023-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/>.
+
+# T3008: move from ntpd to chrony and migrate "system ntp" to "service ntp"
+
+from vyos.configtree import ConfigTree
+
+base_path = ['system', 'ntp']
+new_base_path = ['service', 'ntp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ 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']):
+ config.set(['service'])
+
+ # copy "system ntp" to "service ntp"
+ config.copy(base_path, new_base_path)
+ config.delete(base_path)
+
+ # chrony does not support the preempt option, drop it
+ for server in config.list_nodes(new_base_path + ['server']):
+ server_base = new_base_path + ['server', server]
+ if config.exists(server_base + ['preempt']):
+ config.delete(server_base + ['preempt'])
+
+ # Rename "allow-clients" -> "allow-client"
+ if config.exists(new_base_path + ['allow-clients']):
+ config.rename(new_base_path + ['allow-clients'], 'allow-client')
+
+ # By default VyOS 1.3 allowed NTP queries for all networks - in chrony we
+ # explicitly disable this behavior and clients need to be specified using the
+ # allow-client CLI option. In order to be fully backwards compatible, we specify
+ # 0.0.0.0/0 and ::/0 as allow networks if not specified otherwise explicitly.
+ if not config.exists(new_base_path + ['allow-client']):
+ config.set(new_base_path + ['allow-client', 'address'], value='0.0.0.0/0', replace=False)
+ config.set(new_base_path + ['allow-client', 'address'], value='::/0', replace=False)
diff --git a/src/migration-scripts/ntp/2-to-3 b/src/migration-scripts/ntp/2-to-3
new file mode 100644
index 0000000..bbda903
--- /dev/null
+++ b/src/migration-scripts/ntp/2-to-3
@@ -0,0 +1,43 @@
+# Copyright 2023-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/>.
+
+# T5154: allow only one ip address per family for parameter 'listen-address'
+# Allow only one interface for parameter 'interface'
+# If more than one are specified, remove such entries
+
+from vyos.configtree import ConfigTree
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+
+base_path = ['service', 'ntp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv4(addr)]) > 1):
+ for addr in config.return_values(base_path + ['listen-address']):
+ if is_ipv4(addr):
+ config.delete_value(base_path + ['listen-address'], addr)
+
+ if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv6(addr)]) > 1):
+ for addr in config.return_values(base_path + ['listen-address']):
+ if is_ipv6(addr):
+ config.delete_value(base_path + ['listen-address'], addr)
+
+ if config.exists(base_path + ['interface']):
+ if len(config.return_values(base_path + ['interface'])) > 1:
+ config.delete(base_path + ['interface'])
diff --git a/src/migration-scripts/openconnect/0-to-1 b/src/migration-scripts/openconnect/0-to-1
new file mode 100644
index 0000000..aa5a97e
--- /dev/null
+++ b/src/migration-scripts/openconnect/0-to-1
@@ -0,0 +1,116 @@
+# 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/>.
+
+# - Update SSL to use PKI configuration
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.utils.process import run
+
+base = ['vpn', 'openconnect']
+pki_base = ['pki']
+
+AUTH_DIR = '/config/auth'
+
+def wrapped_pem_to_config_value(pem):
+ return "".join(pem.strip().split("\n")[1:-1])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(base + ['ssl']):
+ return
+
+ x509_base = base + ['ssl']
+ pki_name = 'openconnect'
+
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ if config.exists(x509_base + ['ca-cert-file']):
+ cert_file = config.return_value(x509_base + ['ca-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['ca-certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate CA certificate on openconnect config')
+
+ config.delete(x509_base + ['ca-cert-file'])
+
+ if config.exists(x509_base + ['cert-file']):
+ cert_file = config.return_value(x509_base + ['cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on openconnect config')
+
+ config.delete(x509_base + ['cert-file'])
+
+ if config.exists(x509_base + ['key-file']):
+ key_file = config.return_value(x509_base + ['key-file'])
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=None)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+ else:
+ print(f'Failed to migrate private key on openconnect config')
+
+ config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/openconnect/1-to-2 b/src/migration-scripts/openconnect/1-to-2
new file mode 100644
index 0000000..4f74b44
--- /dev/null
+++ b/src/migration-scripts/openconnect/1-to-2
@@ -0,0 +1,35 @@
+# Copyright 2022-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/>.
+
+# Delete depricated outside-nexthop address
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['vpn', 'openconnect']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cfg_base):
+ # Nothing to do
+ return
+
+ if config.exists(cfg_base + ['authentication', 'mode']):
+ if config.return_value(cfg_base + ['authentication', 'mode']) == 'radius':
+ # if "mode value radius", change to "mode + valueless node radius"
+ config.delete_value(cfg_base + ['authentication','mode'], 'radius')
+ config.set(cfg_base + ['authentication', 'mode', 'radius'], value=None)
+ elif config.return_value(cfg_base + ['authentication', 'mode']) == 'local':
+ # if "mode local", change to "mode + node local value password"
+ config.delete_value(cfg_base + ['authentication', 'mode'], 'local')
+ config.set(cfg_base + ['authentication', 'mode', 'local'], value='password')
diff --git a/src/migration-scripts/openconnect/2-to-3 b/src/migration-scripts/openconnect/2-to-3
new file mode 100644
index 0000000..00e13ec
--- /dev/null
+++ b/src/migration-scripts/openconnect/2-to-3
@@ -0,0 +1,30 @@
+# Copyright 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/>.
+
+# T4982: Retain prior default TLS version (v1.0) when upgrading installations with existing openconnect configurations
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['vpn', 'openconnect']
+
+def migrate(config: ConfigTree) -> None:
+ # bail out early if service is unconfigured
+ if not config.exists(cfg_base):
+ return
+
+ # new default is TLS 1.2 - set explicit old default value of TLS 1.0 for upgraded configurations to keep compatibility
+ tls_min_path = cfg_base + ['tls-version-min']
+ if not config.exists(tls_min_path):
+ config.set(tls_min_path, value='1.0')
diff --git a/src/migration-scripts/openvpn/0-to-1 b/src/migration-scripts/openvpn/0-to-1
new file mode 100644
index 0000000..e5db731
--- /dev/null
+++ b/src/migration-scripts/openvpn/0-to-1
@@ -0,0 +1,43 @@
+# Copyright 2023-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/>.
+
+# Removes outdated ciphers (DES and Blowfish) from OpenVPN configs
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['interfaces', 'openvpn']):
+ # Nothing to do
+ return
+
+ ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'])
+ for i in ovpn_intfs:
+ # Remove DES and Blowfish from 'encryption cipher'
+ cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher']
+ if config.exists(cipher_path):
+ cipher = config.return_value(cipher_path)
+ if cipher in ['des', 'bf128', 'bf256']:
+ config.delete(cipher_path)
+
+ ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']
+ if config.exists(ncp_cipher_path):
+ ncp_ciphers = config.return_values(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'])
+ if 'des' in ncp_ciphers:
+ config.delete_value(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'], 'des')
+
+ # Clean up the encryption subtree if the migration procedure left it empty
+ if config.exists(['interfaces', 'openvpn', i, 'encryption']) and \
+ (config.list_nodes(['interfaces', 'openvpn', i, 'encryption']) == []):
+ config.delete(['interfaces', 'openvpn', i, 'encryption'])
diff --git a/src/migration-scripts/openvpn/1-to-2 b/src/migration-scripts/openvpn/1-to-2
new file mode 100644
index 0000000..2baa730
--- /dev/null
+++ b/src/migration-scripts/openvpn/1-to-2
@@ -0,0 +1,51 @@
+#!/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/>.
+#
+# Removes --cipher option (deprecated) from OpenVPN configs
+# and moves it to --data-ciphers for server and client modes
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False)
+ for i in ovpn_intfs:
+ # Remove 'encryption cipher' and add this value to 'encryption ncp-ciphers'
+ # for server and client mode.
+ # Site-to-site mode still can use --cipher option
+ cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher']
+ ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']
+ if config.exists(cipher_path):
+ if config.exists(['interfaces', 'openvpn', i, 'shared-secret-key']):
+ continue
+ cipher = config.return_value(cipher_path)
+ config.delete(cipher_path)
+ if cipher == 'none':
+ if not config.exists(ncp_cipher_path):
+ config.delete(['interfaces', 'openvpn', i, 'encryption'])
+ continue
+
+ ncp_ciphers = []
+ if config.exists(ncp_cipher_path):
+ ncp_ciphers = config.return_values(ncp_cipher_path)
+ config.delete(ncp_cipher_path)
+
+ # need to add the deleted cipher at the first place in the list
+ if cipher in ncp_ciphers:
+ ncp_ciphers.remove(cipher)
+ ncp_ciphers.insert(0, cipher)
+
+ for c in ncp_ciphers:
+ config.set(ncp_cipher_path, value=c, replace=False)
diff --git a/src/migration-scripts/openvpn/2-to-3 b/src/migration-scripts/openvpn/2-to-3
new file mode 100644
index 0000000..4e6b3c8
--- /dev/null
+++ b/src/migration-scripts/openvpn/2-to-3
@@ -0,0 +1,39 @@
+#!/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/>.
+#
+# Adds an explicit old default for 'server topology'
+# to keep old configs working as before even though the default has changed.
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False)
+ for i in ovpn_intfs:
+ mode = config.return_value(['interfaces', 'openvpn', i, 'mode'])
+ if mode != 'server':
+ # If it's a client or a site-to-site OpenVPN interface,
+ # the topology setting is not applicable
+ # and will cause commit errors on load,
+ # so we must not change such interfaces.
+ continue
+ else:
+ # The default OpenVPN server topology was changed from net30 to subnet
+ # because net30 is deprecated and causes problems with Windows clients.
+ # We add 'net30' to old configs if topology is not set there
+ # to ensure that if anyone relies on net30, their configs work as before.
+ topology_path = ['interfaces', 'openvpn', i, 'server', 'topology']
+ if not config.exists(topology_path):
+ config.set(topology_path, value='net30', replace=False)
diff --git a/src/migration-scripts/openvpn/3-to-4 b/src/migration-scripts/openvpn/3-to-4
new file mode 100644
index 0000000..0529491
--- /dev/null
+++ b/src/migration-scripts/openvpn/3-to-4
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+# Copyright 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/>.
+# Renames ncp-ciphers option to data-ciphers
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False)
+ for i in ovpn_intfs:
+ #Rename 'encryption ncp-ciphers' with 'encryption data-ciphers'
+ ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']
+ if config.exists(ncp_cipher_path):
+ config.rename(ncp_cipher_path, 'data-ciphers')
diff --git a/src/migration-scripts/ospf/0-to-1 b/src/migration-scripts/ospf/0-to-1
new file mode 100644
index 0000000..a1f8109
--- /dev/null
+++ b/src/migration-scripts/ospf/0-to-1
@@ -0,0 +1,66 @@
+# 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/>.
+
+# T3753: upgrade to FRR8 and move CLI options to better fit with the new FRR CLI
+
+from vyos.configtree import ConfigTree
+
+def ospf_passive_migration(config, ospf_base):
+ if config.exists(ospf_base):
+ if config.exists(ospf_base + ['passive-interface']):
+ default = False
+ for interface in config.return_values(ospf_base + ['passive-interface']):
+ if interface == 'default':
+ default = True
+ continue
+ config.set(ospf_base + ['interface', interface, 'passive'])
+ config.set_tag(ospf_base + ['interface'])
+
+ config.delete(ospf_base + ['passive-interface'])
+ if default:
+ config.set(ospf_base + ['passive-interface'], value='default')
+
+ if config.exists(ospf_base + ['passive-interface-exclude']):
+ for interface in config.return_values(ospf_base + ['passive-interface-exclude']):
+ config.set(ospf_base + ['interface', interface, 'passive', 'disable'])
+ config.set_tag(ospf_base + ['interface'])
+ config.delete(ospf_base + ['passive-interface-exclude'])
+
+ospfv3_base = ['protocols', 'ospfv3']
+
+def migrate(config: ConfigTree) -> None:
+ if config.exists(ospfv3_base):
+ area_base = ospfv3_base + ['area']
+ if config.exists(area_base):
+ for area in config.list_nodes(area_base):
+ if not config.exists(area_base + [area, 'interface']):
+ continue
+
+ for interface in config.return_values(area_base + [area, 'interface']):
+ config.set(ospfv3_base + ['interface', interface, 'area'], value=area)
+ config.set_tag(ospfv3_base + ['interface'])
+
+ config.delete(area_base + [area, 'interface'])
+
+ # Migrate OSPF syntax in default VRF
+ ospf_base = ['protocols', 'ospf']
+ ospf_passive_migration(config, ospf_base)
+
+ vrf_base = ['vrf', 'name']
+ if config.exists(vrf_base):
+ for vrf in config.list_nodes(vrf_base):
+ vrf_ospf_base = vrf_base + [vrf, 'protocols', 'ospf']
+ if config.exists(vrf_ospf_base):
+ ospf_passive_migration(config, vrf_ospf_base)
diff --git a/src/migration-scripts/ospf/1-to-2 b/src/migration-scripts/ospf/1-to-2
new file mode 100644
index 0000000..5368d8d
--- /dev/null
+++ b/src/migration-scripts/ospf/1-to-2
@@ -0,0 +1,60 @@
+# Copyright 2023-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/>.
+
+# T5150: Rework CLI definitions to apply route-maps between routing daemons
+# and zebra/kernel
+
+from vyos.configtree import ConfigTree
+
+ospf_base = ['protocols', 'ospf']
+
+def migrate(config: ConfigTree) -> None:
+ # Check if OSPF is configured - if so, migrate the CLI node
+ if config.exists(ospf_base):
+ if config.exists(ospf_base + ['route-map']):
+ tmp = config.return_value(ospf_base + ['route-map'])
+
+ config.set(['system', 'ip', 'protocol', 'ospf', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ip', 'protocol'])
+ config.delete(ospf_base + ['route-map'])
+
+ ospfv3_base = ['protocols', 'ospfv3']
+ # Check if OSPFv3 is configured - if so, migrate the CLI node
+ if config.exists(ospfv3_base):
+ if config.exists(ospfv3_base + ['route-map']):
+ tmp = config.return_value(ospfv3_base + ['route-map'])
+
+ config.set(['system', 'ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ipv6', 'protocol'])
+ config.delete(ospfv3_base + ['route-map'])
+
+ # Check if vrf names are configured. Check if OSPF/OSPFv3 is configured - if so,
+ # migrate the CLI node(s)
+ if config.exists(['vrf', 'name']):
+ for vrf in config.list_nodes(['vrf', 'name']):
+ vrf_base = ['vrf', 'name', vrf]
+ if config.exists(vrf_base + ['protocols', 'ospf', 'route-map']):
+ tmp = config.return_value(vrf_base + ['protocols', 'ospf', 'route-map'])
+
+ config.set(vrf_base + ['ip', 'protocol', 'ospf', 'route-map'], value=tmp)
+ config.set_tag(vrf_base + ['ip', 'protocol', 'ospf'])
+ config.delete(vrf_base + ['protocols', 'ospf', 'route-map'])
+
+ if config.exists(vrf_base + ['protocols', 'ospfv3', 'route-map']):
+ tmp = config.return_value(vrf_base + ['protocols', 'ospfv3', 'route-map'])
+
+ config.set(vrf_base + ['ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
+ config.set_tag(vrf_base + ['ipv6', 'protocol', 'ospfv6'])
+ config.delete(vrf_base + ['protocols', 'ospfv3', 'route-map'])
diff --git a/src/migration-scripts/pim/0-to-1 b/src/migration-scripts/pim/0-to-1
new file mode 100644
index 0000000..ce24b23
--- /dev/null
+++ b/src/migration-scripts/pim/0-to-1
@@ -0,0 +1,54 @@
+# Copyright 2023-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/>.
+
+# T5736: igmp: migrate "protocols igmp" to "protocols pim"
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'igmp']
+pim_base = ['protocols', 'pim']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base + ['interface']):
+ base_igmp_iface = base + ['interface', interface]
+ pim_base_iface = pim_base + ['interface', interface]
+
+ # Create IGMP note under PIM interface
+ if not config.exists(pim_base_iface + ['igmp']):
+ config.set(pim_base_iface + ['igmp'])
+
+ if config.exists(base_igmp_iface + ['join']):
+ config.copy(base_igmp_iface + ['join'], pim_base_iface + ['igmp', 'join'])
+ config.set_tag(pim_base_iface + ['igmp', 'join'])
+
+ new_join_base = pim_base_iface + ['igmp', 'join']
+ for address in config.list_nodes(new_join_base):
+ if config.exists(new_join_base + [address, 'source']):
+ config.rename(new_join_base + [address, 'source'], 'source-address')
+
+ if config.exists(base_igmp_iface + ['query-interval']):
+ config.copy(base_igmp_iface + ['query-interval'], pim_base_iface + ['igmp', 'query-interval'])
+
+ if config.exists(base_igmp_iface + ['query-max-response-time']):
+ config.copy(base_igmp_iface + ['query-max-response-time'], pim_base_iface + ['igmp', 'query-max-response-time'])
+
+ if config.exists(base_igmp_iface + ['version']):
+ config.copy(base_igmp_iface + ['version'], pim_base_iface + ['igmp', 'version'])
+
+ config.delete(base)
diff --git a/src/migration-scripts/policy/0-to-1 b/src/migration-scripts/policy/0-to-1
new file mode 100644
index 0000000..837946c
--- /dev/null
+++ b/src/migration-scripts/policy/0-to-1
@@ -0,0 +1,43 @@
+# 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/>.
+
+# T3631: route-map: migrate "set extcommunity-rt" and "set extcommunity-soo"
+# to "set extcommunity rt|soo" to match FRR syntax
+
+from vyos.configtree import ConfigTree
+
+base = ['policy', 'route-map']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for route_map in config.list_nodes(base):
+ if not config.exists(base + [route_map, 'rule']):
+ continue
+ for rule in config.list_nodes(base + [route_map, 'rule']):
+ base_rule = base + [route_map, 'rule', rule]
+
+ if config.exists(base_rule + ['set', 'extcommunity-rt']):
+ tmp = config.return_value(base_rule + ['set', 'extcommunity-rt'])
+ config.delete(base_rule + ['set', 'extcommunity-rt'])
+ config.set(base_rule + ['set', 'extcommunity', 'rt'], value=tmp)
+
+
+ if config.exists(base_rule + ['set', 'extcommunity-soo']):
+ tmp = config.return_value(base_rule + ['set', 'extcommunity-soo'])
+ config.delete(base_rule + ['set', 'extcommunity-soo'])
+ config.set(base_rule + ['set', 'extcommunity', 'soo'], value=tmp)
diff --git a/src/migration-scripts/policy/1-to-2 b/src/migration-scripts/policy/1-to-2
new file mode 100644
index 0000000..ba3e48d
--- /dev/null
+++ b/src/migration-scripts/policy/1-to-2
@@ -0,0 +1,67 @@
+# Copyright 2022-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/>.
+
+# T4170: rename "policy ipv6-route" to "policy route6" to match common
+# IPv4/IPv6 schema
+# T4178: Update tcp flags to use multi value node
+
+from vyos.configtree import ConfigTree
+
+base = ['policy']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['ipv6-route']):
+ config.rename(base + ['ipv6-route'],'route6')
+ config.set_tag(['policy', 'route6'])
+
+ for route in ['route', 'route6']:
+ if config.exists(base + [route]):
+ for name in config.list_nodes(base + [route]):
+ if config.exists(base + [route, name, 'rule']):
+ for rule in config.list_nodes(base + [route, name, 'rule']):
+ rule_tcp_flags = base + [route, name, 'rule', rule, 'tcp', 'flags']
+
+ if config.exists(rule_tcp_flags):
+ tmp = config.return_value(rule_tcp_flags)
+ config.delete(rule_tcp_flags)
+ for flag in tmp.split(","):
+ for flag in tmp.split(","):
+ if flag[0] == '!':
+ config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+ else:
+ config.set(rule_tcp_flags + [flag.lower()])
+
+ if config.exists(['interfaces']):
+ def if_policy_rename(config, path):
+ if config.exists(path + ['policy', 'ipv6-route']):
+ config.rename(path + ['policy', 'ipv6-route'], 'route6')
+
+ for if_type in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', if_type]):
+ if_path = ['interfaces', if_type, ifname]
+ if_policy_rename(config, if_path)
+
+ for vif_type in ['vif', 'vif-s']:
+ if config.exists(if_path + [vif_type]):
+ for vifname in config.list_nodes(if_path + [vif_type]):
+ if_policy_rename(config, if_path + [vif_type, vifname])
+
+ if config.exists(if_path + [vif_type, vifname, 'vif-c']):
+ for vifcname in config.list_nodes(if_path + [vif_type, vifname, 'vif-c']):
+ if_policy_rename(config, if_path + [vif_type, vifname, 'vif-c', vifcname])
diff --git a/src/migration-scripts/policy/2-to-3 b/src/migration-scripts/policy/2-to-3
new file mode 100644
index 0000000..399a553
--- /dev/null
+++ b/src/migration-scripts/policy/2-to-3
@@ -0,0 +1,38 @@
+# Copyright 2022-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/>.
+
+# T3976: change cli
+# from: set policy route-map FOO rule 10 match ipv6 nexthop 'h:h:h:h:h:h:h:h'
+# to: set policy route-map FOO rule 10 match ipv6 nexthop address 'h:h:h:h:h:h:h:h'
+
+from vyos.configtree import ConfigTree
+
+base = ['policy', 'route-map']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for route_map in config.list_nodes(base):
+ if not config.exists(base + [route_map, 'rule']):
+ continue
+ for rule in config.list_nodes(base + [route_map, 'rule']):
+ base_rule = base + [route_map, 'rule', rule]
+
+ if config.exists(base_rule + ['match', 'ipv6', 'nexthop']):
+ tmp = config.return_value(base_rule + ['match', 'ipv6', 'nexthop'])
+ config.delete(base_rule + ['match', 'ipv6', 'nexthop'])
+ config.set(base_rule + ['match', 'ipv6', 'nexthop', 'address'], value=tmp)
diff --git a/src/migration-scripts/policy/3-to-4 b/src/migration-scripts/policy/3-to-4
new file mode 100644
index 0000000..5d4959d
--- /dev/null
+++ b/src/migration-scripts/policy/3-to-4
@@ -0,0 +1,143 @@
+# Copyright 2022-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/>.
+
+# T4660: change cli
+# from: set policy route-map FOO rule 10 set community 'TEXT'
+# Multiple value
+# to: set policy route-map FOO rule 10 set community replace <community>
+# Multiple value
+# to: set policy route-map FOO rule 10 set community add <community>
+# to: set policy route-map FOO rule 10 set community none
+#
+# from: set policy route-map FOO rule 10 set large-community 'TEXT'
+# Multiple value
+# to: set policy route-map FOO rule 10 set large-community replace <community>
+# Multiple value
+# to: set policy route-map FOO rule 10 set large-community add <community>
+# to: set policy route-map FOO rule 10 set large-community none
+#
+# from: set policy route-map FOO rule 10 set extecommunity [rt|soo] 'TEXT'
+# Multiple value
+# to: set policy route-map FOO rule 10 set extcommunity [rt|soo] <community>
+
+from vyos.configtree import ConfigTree
+
+
+# Migration function for large and regular communities
+def community_migrate(config: ConfigTree, rule: list[str]) -> bool:
+ """
+
+ :param config: configuration object
+ :type config: ConfigTree
+ :param rule: Path to variable
+ :type rule: list[str]
+ :return: True if additive presents in community string
+ :rtype: bool
+ """
+ community_list = list((config.return_value(rule)).split(" "))
+ config.delete(rule)
+ if 'none' in community_list:
+ config.set(rule + ['none'])
+ return False
+ else:
+ community_action: str = 'replace'
+ if 'additive' in community_list:
+ community_action = 'add'
+ community_list.remove('additive')
+ for community in community_list:
+ config.set(rule + [community_action], value=community,
+ replace=False)
+ if community_action == 'replace':
+ return False
+ else:
+ return True
+
+
+# Migration function for extcommunities
+def extcommunity_migrate(config: ConfigTree, rule: list[str]) -> None:
+ """
+
+ :param config: configuration object
+ :type config: ConfigTree
+ :param rule: Path to variable
+ :type rule: list[str]
+ """
+ # if config.exists(rule + ['bandwidth']):
+ # bandwidth: str = config.return_value(rule + ['bandwidth'])
+ # config.delete(rule + ['bandwidth'])
+ # config.set(rule + ['bandwidth'], value=bandwidth)
+
+ if config.exists(rule + ['rt']):
+ community_list = list((config.return_value(rule + ['rt'])).split(" "))
+ config.delete(rule + ['rt'])
+ for community in community_list:
+ config.set(rule + ['rt'], value=community, replace=False)
+
+ if config.exists(rule + ['soo']):
+ community_list = list((config.return_value(rule + ['soo'])).split(" "))
+ config.delete(rule + ['soo'])
+ for community in community_list:
+ config.set(rule + ['soo'], value=community, replace=False)
+
+
+base: list[str] = ['policy', 'route-map']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for route_map in config.list_nodes(base):
+ if not config.exists(base + [route_map, 'rule']):
+ continue
+ for rule in config.list_nodes(base + [route_map, 'rule']):
+ base_rule: list[str] = base + [route_map, 'rule', rule, 'set']
+
+ # IF additive presents in coummunity then comm-list is redundant
+ isAdditive: bool = True
+ #### Change Set community ########
+ if config.exists(base_rule + ['community']):
+ isAdditive = community_migrate(config,
+ base_rule + ['community'])
+
+ #### Change Set community-list delete migrate ########
+ if config.exists(base_rule + ['comm-list', 'comm-list']):
+ if isAdditive:
+ tmp = config.return_value(
+ base_rule + ['comm-list', 'comm-list'])
+ config.delete(base_rule + ['comm-list'])
+ config.set(base_rule + ['community', 'delete'], value=tmp)
+ else:
+ config.delete(base_rule + ['comm-list'])
+
+ isAdditive = False
+ #### Change Set large-community ########
+ if config.exists(base_rule + ['large-community']):
+ isAdditive = community_migrate(config,
+ base_rule + ['large-community'])
+
+ #### Change Set large-community delete by List ########
+ if config.exists(base_rule + ['large-comm-list-delete']):
+ if isAdditive:
+ tmp = config.return_value(
+ base_rule + ['large-comm-list-delete'])
+ config.delete(base_rule + ['large-comm-list-delete'])
+ config.set(base_rule + ['large-community', 'delete'],
+ value=tmp)
+ else:
+ config.delete(base_rule + ['large-comm-list-delete'])
+
+ #### Change Set extcommunity ########
+ extcommunity_migrate(config, base_rule + ['extcommunity'])
diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5
new file mode 100644
index 0000000..0ecfdfd
--- /dev/null
+++ b/src/migration-scripts/policy/4-to-5
@@ -0,0 +1,106 @@
+# Copyright 2022-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/>.
+
+# T2199: Migrate interface policy nodes to policy route <name> interface <ifname>
+
+from vyos.configtree import ConfigTree
+
+base4 = ['policy', 'route']
+base6 = ['policy', 'route6']
+
+def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None):
+ """Delete unexpected policy on interfaces in cases when
+ policy does not exist but inreface has a policy configuration
+ Example T5941:
+ set interfaces bonding bond0 vif 995 policy
+ """
+ if_path = ['interfaces', iftype, ifname]
+
+ if vif:
+ if_path += ['vif', vif]
+ elif vifs:
+ if_path += ['vif-s', vifs]
+ if vifc:
+ if_path += ['vif-c', vifc]
+
+ if not config.exists(if_path + ['policy']):
+ return
+
+ config.delete(if_path + ['policy'])
+
+def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None):
+ if_path = ['interfaces', iftype, ifname]
+ ifname_full = ifname
+
+ if vif:
+ if_path += ['vif', vif]
+ ifname_full = f'{ifname}.{vif}'
+ elif vifs:
+ if_path += ['vif-s', vifs]
+ ifname_full = f'{ifname}.{vifs}'
+ if vifc:
+ if_path += ['vif-c', vifc]
+ ifname_full = f'{ifname}.{vifs}.{vifc}'
+
+ if not config.exists(if_path + ['policy']):
+ return
+
+ if config.exists(if_path + ['policy', 'route']):
+ route_name = config.return_value(if_path + ['policy', 'route'])
+ config.set(base4 + [route_name, 'interface'], value=ifname_full, replace=False)
+
+ if config.exists(if_path + ['policy', 'route6']):
+ route_name = config.return_value(if_path + ['policy', 'route6'])
+ config.set(base6 + [route_name, 'interface'], value=ifname_full, replace=False)
+
+ config.delete(if_path + ['policy'])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base4) and not config.exists(base6):
+ # Delete orphaned nodes on interfaces T5941
+ for iftype in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', iftype]):
+ delete_orphaned_interface_policy(config, iftype, ifname)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif']):
+ for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+ for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
+
+ # Nothing to do
+ return
+
+ for iftype in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', iftype]):
+ migrate_interface(config, iftype, ifname)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif']):
+ for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+ migrate_interface(config, iftype, ifname, vif=vif)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+ for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+ migrate_interface(config, iftype, ifname, vifs=vifs)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
diff --git a/src/migration-scripts/policy/5-to-6 b/src/migration-scripts/policy/5-to-6
new file mode 100644
index 0000000..acba0b4
--- /dev/null
+++ b/src/migration-scripts/policy/5-to-6
@@ -0,0 +1,42 @@
+# Copyright 2023-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/>.
+
+# T5165: Migrate policy local-route rule <tag> destination|source
+
+from vyos.configtree import ConfigTree
+
+base4 = ['policy', 'local-route']
+base6 = ['policy', 'local-route6']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base4) and not config.exists(base6):
+ # Nothing to do
+ return
+
+ # replace 'policy local-route{v6} rule <tag> destination|source <x.x.x.x>'
+ # => 'policy local-route{v6} rule <tag> destination|source address <x.x.x.x>'
+ for base in [base4, base6]:
+ if config.exists(base + ['rule']):
+ for rule in config.list_nodes(base + ['rule']):
+ dst_path = base + ['rule', rule, 'destination']
+ src_path = base + ['rule', rule, 'source']
+ # Destination
+ if config.exists(dst_path):
+ for dst_addr in config.return_values(dst_path):
+ config.set(dst_path + ['address'], value=dst_addr, replace=False)
+ # Source
+ if config.exists(src_path):
+ for src_addr in config.return_values(src_path):
+ config.set(src_path + ['address'], value=src_addr, replace=False)
diff --git a/src/migration-scripts/policy/6-to-7 b/src/migration-scripts/policy/6-to-7
new file mode 100644
index 0000000..69aa703
--- /dev/null
+++ b/src/migration-scripts/policy/6-to-7
@@ -0,0 +1,56 @@
+# Copyright 2023-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/>.
+
+# T5729: Switch to valueless whenever is possible.
+# From
+ # set policy [route | route6] ... rule <rule> log enable
+ # set policy [route | route6] ... rule <rule> log disable
+# To
+ # set policy [route | route6] ... rule <rule> log
+ # Remove command if log=disable
+
+from vyos.configtree import ConfigTree
+
+base = ['policy']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for family in ['route', 'route6']:
+ if config.exists(base + [family]):
+
+ for policy_name in config.list_nodes(base + [family]):
+ if config.exists(base + [family, policy_name, 'rule']):
+ for rule in config.list_nodes(base + [family, policy_name, 'rule']):
+ # Log
+ if config.exists(base + [family, policy_name, 'rule', rule, 'log']):
+ log_value = config.return_value(base + [family, policy_name, 'rule', rule, 'log'])
+ config.delete(base + [family, policy_name, 'rule', rule, 'log'])
+ if log_value == 'enable':
+ config.set(base + [family, policy_name, 'rule', rule, 'log'])
+ # State
+ if config.exists(base + [family, policy_name, 'rule', rule, 'state']):
+ flag_enable = 'False'
+ for state in ['established', 'invalid', 'new', 'related']:
+ if config.exists(base + [family, policy_name, 'rule', rule, 'state', state]):
+ state_value = config.return_value(base + [family, policy_name, 'rule', rule, 'state', state])
+ config.delete(base + [family, policy_name, 'rule', rule, 'state', state])
+ if state_value == 'enable':
+ config.set(base + [family, policy_name, 'rule', rule, 'state'], value=state, replace=False)
+ flag_enable = 'True'
+ if flag_enable == 'False':
+ config.delete(base + [family, policy_name, 'rule', rule, 'state'])
diff --git a/src/migration-scripts/policy/7-to-8 b/src/migration-scripts/policy/7-to-8
new file mode 100644
index 0000000..a887f37
--- /dev/null
+++ b/src/migration-scripts/policy/7-to-8
@@ -0,0 +1,36 @@
+# Copyright 2023-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/>.
+
+# T5834: Rename 'enable-default-log' to 'default-log'
+# From
+ # set policy [route | route 6] <route> enable-default-log
+# To
+ # set policy [route | route 6] <route> default-log
+
+from vyos.configtree import ConfigTree
+
+base = ['policy']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for family in ['route', 'route6']:
+ if config.exists(base + [family]):
+
+ for policy_name in config.list_nodes(base + [family]):
+ if config.exists(base + [family, policy_name, 'enable-default-log']):
+ config.rename(base + [family, policy_name, 'enable-default-log'], 'default-log')
diff --git a/src/migration-scripts/pppoe-server/0-to-1 b/src/migration-scripts/pppoe-server/0-to-1
new file mode 100644
index 0000000..8c9a24f
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/0-to-1
@@ -0,0 +1,33 @@
+# Copyright 2020-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/>.
+
+# Convert "service pppoe-server authentication radius-server node key"
+# to: "service pppoe-server authentication radius-server node secret"
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server', 'authentication', 'radius-server']
+
+def migrate(ctree: ConfigTree) -> None:
+ if not ctree.exists(base):
+ # Nothing to do
+ return
+
+ nodes = ctree.list_nodes(base)
+ for node in nodes:
+ if ctree.exists(base + [node, 'key']):
+ val = ctree.return_value(base + [node, 'key'])
+ ctree.set(base + [node, 'secret'], value=val, replace=False)
+ ctree.delete(base + [node, 'key'])
diff --git a/src/migration-scripts/pppoe-server/1-to-2 b/src/migration-scripts/pppoe-server/1-to-2
new file mode 100644
index 0000000..c9c968b
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/1-to-2
@@ -0,0 +1,41 @@
+# Copyright 2020-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/>.
+
+# change mppe node to a leaf node with value prefer
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ mppe_base = base + ['ppp-options', 'mppe']
+ if config.exists(mppe_base):
+ # get current values
+ tmp = config.list_nodes(mppe_base)
+ # drop node(s) first ...
+ config.delete(mppe_base)
+
+ print(tmp)
+ # set new value based on preference
+ if 'require' in tmp:
+ config.set(mppe_base, value='require')
+ elif 'prefer' in tmp:
+ config.set(mppe_base, value='prefer')
+ elif 'deny' in tmp:
+ config.set(mppe_base, value='deny')
diff --git a/src/migration-scripts/pppoe-server/10-to-11 b/src/migration-scripts/pppoe-server/10-to-11
new file mode 100644
index 0000000..6bc138b
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/10-to-11
@@ -0,0 +1,30 @@
+# Copyright 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/>.
+
+# Add the "vlan-mon" option to the configuration to prevent it
+# from disappearing from the configuration file
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ for interface in config.list_nodes(base + ['interface']):
+ base_path = base + ['interface', interface]
+ if config.exists(base_path + ['vlan']):
+ config.set(base_path + ['vlan-mon'])
diff --git a/src/migration-scripts/pppoe-server/2-to-3 b/src/migration-scripts/pppoe-server/2-to-3
new file mode 100644
index 0000000..160cffd
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/2-to-3
@@ -0,0 +1,31 @@
+# Copyright 2020-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/>.
+
+# Convert "service pppoe-server interface ethX" to: "service pppoe-server interface ethX {}"
+
+from vyos.configtree import ConfigTree
+
+cbase = ['service', 'pppoe-server','interface']
+
+def migrate(ctree: ConfigTree) -> None:
+ if not ctree.exists(cbase):
+ return
+
+ nics = ctree.return_values(cbase)
+ # convert leafNode to a tagNode
+ ctree.set(cbase)
+ ctree.set_tag(cbase)
+ for nic in nics:
+ ctree.set(cbase + [nic])
diff --git a/src/migration-scripts/pppoe-server/3-to-4 b/src/migration-scripts/pppoe-server/3-to-4
new file mode 100644
index 0000000..29dd622
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/3-to-4
@@ -0,0 +1,121 @@
+# Copyright 2020-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/>.
+
+# - remove primary/secondary identifier from nameserver
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Migrate IPv4 DNS servers
+ dns_base = base + ['dns-servers']
+ if config.exists(dns_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(dns_base + [server]):
+ dns = config.return_value(dns_base + [server])
+ config.set(base + ['name-server'], value=dns, replace=False)
+
+ config.delete(dns_base)
+
+ # Migrate IPv6 DNS servers
+ dns_base = base + ['dnsv6-servers']
+ if config.exists(dns_base):
+ for server in ['server-1', 'server-2', 'server-3']:
+ if config.exists(dns_base + [server]):
+ dns = config.return_value(dns_base + [server])
+ config.set(base + ['name-server'], value=dns, replace=False)
+
+ config.delete(dns_base)
+
+ # Migrate IPv4 WINS servers
+ wins_base = base + ['wins-servers']
+ if config.exists(wins_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(wins_base + [server]):
+ wins = config.return_value(wins_base + [server])
+ config.set(base + ['wins-server'], value=wins, replace=False)
+
+ config.delete(wins_base)
+
+ # Migrate radius-settings node to RADIUS and use this as base for the
+ # later migration of the RADIUS servers - this will save a lot of code
+ radius_settings = base + ['authentication', 'radius-settings']
+ if config.exists(radius_settings):
+ config.rename(radius_settings, 'radius')
+
+ # Migrate RADIUS dynamic author / change of authorisation server
+ dae_old = base + ['authentication', 'radius', 'dae-server']
+ if config.exists(dae_old):
+ config.rename(dae_old, 'dynamic-author')
+ dae_new = base + ['authentication', 'radius', 'dynamic-author']
+
+ if config.exists(dae_new + ['ip-address']):
+ config.rename(dae_new + ['ip-address'], 'server')
+
+ if config.exists(dae_new + ['secret']):
+ config.rename(dae_new + ['secret'], 'key')
+
+ # Migrate RADIUS server
+ radius_server = base + ['authentication', 'radius-server']
+ if config.exists(radius_server):
+ new_base = base + ['authentication', 'radius', 'server']
+ config.set(new_base)
+ config.set_tag(new_base)
+ for server in config.list_nodes(radius_server):
+ old_base = radius_server + [server]
+ config.copy(old_base, new_base + [server])
+
+ # migrate key
+ if config.exists(new_base + [server, 'secret']):
+ config.rename(new_base + [server, 'secret'], 'key')
+
+ # remove old req-limit node
+ if config.exists(new_base + [server, 'req-limit']):
+ config.delete(new_base + [server, 'req-limit'])
+
+ config.delete(radius_server)
+
+ # Migrate IPv6 prefixes
+ ipv6_base = base + ['client-ipv6-pool']
+ if config.exists(ipv6_base + ['prefix']):
+ prefix_old = config.return_values(ipv6_base + ['prefix'])
+ # delete old prefix CLI nodes
+ config.delete(ipv6_base + ['prefix'])
+ # create ned prefix tag node
+ config.set(ipv6_base + ['prefix'])
+ config.set_tag(ipv6_base + ['prefix'])
+
+ for p in prefix_old:
+ prefix = p.split(',')[0]
+ mask = p.split(',')[1]
+ config.set(ipv6_base + ['prefix', prefix, 'mask'], value=mask)
+
+ if config.exists(ipv6_base + ['delegate-prefix']):
+ prefix_old = config.return_values(ipv6_base + ['delegate-prefix'])
+ # delete old delegate prefix CLI nodes
+ config.delete(ipv6_base + ['delegate-prefix'])
+ # create ned delegation tag node
+ config.set(ipv6_base + ['delegate'])
+ config.set_tag(ipv6_base + ['delegate'])
+
+ for p in prefix_old:
+ prefix = p.split(',')[0]
+ mask = p.split(',')[1]
+ config.set(ipv6_base + ['delegate', prefix, 'delegation-prefix'], value=mask)
diff --git a/src/migration-scripts/pppoe-server/4-to-5 b/src/migration-scripts/pppoe-server/4-to-5
new file mode 100644
index 0000000..03fbfb2
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/4-to-5
@@ -0,0 +1,30 @@
+# Copyright 2020-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/>.
+
+# - rename local-ip to gateway-address
+
+from vyos.configtree import ConfigTree
+
+base_path = ['service', 'pppoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ config_gw = base_path + ['local-ip']
+ if config.exists(config_gw):
+ config.rename(config_gw, 'gateway-address')
+ config.delete(config_gw)
diff --git a/src/migration-scripts/pppoe-server/5-to-6 b/src/migration-scripts/pppoe-server/5-to-6
new file mode 100644
index 0000000..13de8f8
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/5-to-6
@@ -0,0 +1,33 @@
+# Copyright 2022-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/>.
+
+# - T4703: merge vlan-id and vlan-range to vlan CLI node
+
+from vyos.configtree import ConfigTree
+
+base_path = ['service', 'pppoe-server', 'interface']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(base_path):
+ for vlan in ['vlan-id', 'vlan-range']:
+ if config.exists(base_path + [interface, vlan]):
+ print(interface, vlan)
+ for tmp in config.return_values(base_path + [interface, vlan]):
+ config.set(base_path + [interface, 'vlan'], value=tmp, replace=False)
+ config.delete(base_path + [interface, vlan])
diff --git a/src/migration-scripts/pppoe-server/6-to-7 b/src/migration-scripts/pppoe-server/6-to-7
new file mode 100644
index 0000000..79745a0
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/6-to-7
@@ -0,0 +1,99 @@
+# Copyright 2023-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/>.
+
+# - move all pool to named pools
+# 'start-stop' migrate to namedpool 'default-range-pool'
+# 'subnet' migrate to namedpool 'default-subnet-pool'
+# 'default-subnet-pool' is the next pool for 'default-range-pool'
+# - There is only one gateway-address, take the first which is configured
+# - default-pool by migration.
+# 1. If authentication mode = 'local' then it is first named pool.
+# If there are not named pools, namedless pool will be default.
+# 2. If authentication mode = 'radius' then namedless pool will be default
+
+from vyos.configtree import ConfigTree
+from vyos.base import Warning
+
+base = ['service', 'pppoe-server']
+pool_base = base + ['client-ip-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ default_pool = ''
+ range_pool_name = 'default-range-pool'
+
+ #Default nameless pools migrations
+ if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+ def is_legalrange(ip1: str, ip2: str, mask: str):
+ from ipaddress import IPv4Interface
+ interface1 = IPv4Interface(f'{ip1}/{mask}')
+ interface2 = IPv4Interface(f'{ip2}/{mask}')
+ return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+
+ start_ip = config.return_value(pool_base + ['start'])
+ stop_ip = config.return_value(pool_base + ['stop'])
+ if is_legalrange(start_ip, stop_ip, '24'):
+ ip_range = f'{start_ip}-{stop_ip}'
+ config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+ default_pool = range_pool_name
+ else:
+ Warning(
+ f'PPPoE client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+ config.delete(pool_base + ['start'])
+ config.delete(pool_base + ['stop'])
+
+ if config.exists(pool_base + ['subnet']):
+ default_pool = range_pool_name
+ for subnet in config.return_values(pool_base + ['subnet']):
+ config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+ config.delete(pool_base + ['subnet'])
+
+ gateway = ''
+ if config.exists(base + ['gateway-address']):
+ gateway = config.return_value(base + ['gateway-address'])
+
+ #named pool migration
+ namedpools_base = pool_base + ['name']
+ if config.exists(namedpools_base):
+ if config.exists(base + ['authentication', 'mode']):
+ if config.return_value(base + ['authentication', 'mode']) == 'local':
+ if config.list_nodes(namedpools_base):
+ default_pool = config.list_nodes(namedpools_base)[0]
+
+ for pool_name in config.list_nodes(namedpools_base):
+ pool_path = namedpools_base + [pool_name]
+ if config.exists(pool_path + ['subnet']):
+ subnet = config.return_value(pool_path + ['subnet'])
+ config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
+ if config.exists(pool_path + ['next-pool']):
+ next_pool = config.return_value(pool_path + ['next-pool'])
+ config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
+ if not gateway:
+ if config.exists(pool_path + ['gateway-address']):
+ gateway = config.return_value(pool_path + ['gateway-address'])
+
+ config.delete(namedpools_base)
+
+ if gateway:
+ config.set(base + ['gateway-address'], value=gateway)
+ if default_pool:
+ config.set(base + ['default-pool'], value=default_pool)
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/pppoe-server/7-to-8 b/src/migration-scripts/pppoe-server/7-to-8
new file mode 100644
index 0000000..90e4fa0
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/7-to-8
@@ -0,0 +1,40 @@
+# Copyright 2023-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/>.
+
+# Migrating to named ipv6 pools
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server']
+pool_base = base + ['client-ipv6-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ ipv6_pool_name = 'ipv6-pool'
+ config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+ if config.exists(pool_base + ['prefix']):
+ config.delete(pool_base + ['prefix'])
+ config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+ if config.exists(pool_base + ['delegate']):
+ config.delete(pool_base + ['delegate'])
+
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/pppoe-server/8-to-9 b/src/migration-scripts/pppoe-server/8-to-9
new file mode 100644
index 0000000..e7e0aaa
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/8-to-9
@@ -0,0 +1,48 @@
+# Copyright 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/>.
+
+# Change from 'ccp' to 'disable-ccp' in ppp-option section
+# Migration ipv6 options
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ #CCP migration
+ if config.exists(base + ['ppp-options', 'ccp']):
+ config.delete(base + ['ppp-options', 'ccp'])
+ else:
+ config.set(base + ['ppp-options', 'disable-ccp'])
+
+ #IPV6 options migrations
+ if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
+ intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
+ if intf_peer_id == 'ipv4':
+ intf_peer_id = 'ipv4-addr'
+ config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
+ config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
+
+ if config.exists(base + ['ppp-options','ipv6-intf-id']):
+ intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
+ config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
+ config.delete(base + ['ppp-options','ipv6-intf-id'])
+
+ if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
+ config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
+ config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
diff --git a/src/migration-scripts/pppoe-server/9-to-10 b/src/migration-scripts/pppoe-server/9-to-10
new file mode 100644
index 0000000..d3475e8
--- /dev/null
+++ b/src/migration-scripts/pppoe-server/9-to-10
@@ -0,0 +1,38 @@
+# Copyright 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/>.
+
+# Migration of pado-delay options
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'pppoe-server', 'pado-delay']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ pado_delay = {}
+ for delay in config.list_nodes(base):
+ sessions = config.return_value(base + [delay, 'sessions'])
+ pado_delay[delay] = sessions
+
+ # need to define delay for latest sessions
+ sorted_delays = dict(sorted(pado_delay.items(), key=lambda k_v: int(k_v[1])))
+ last_delay = list(sorted_delays)[-1]
+
+ # Rename last delay -> disable
+ tmp = base + [last_delay]
+ if config.exists(tmp):
+ config.rename(tmp, 'disable')
diff --git a/src/migration-scripts/pptp/0-to-1 b/src/migration-scripts/pptp/0-to-1
new file mode 100644
index 0000000..dd0b6f5
--- /dev/null
+++ b/src/migration-scripts/pptp/0-to-1
@@ -0,0 +1,54 @@
+# Copyright 2018-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/>.
+
+# Unclutter PPTP VPN configuiration - move radius-server top level tag
+# nodes to a regular node which now also configures the radius source address
+# used when querying a radius server
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['vpn', 'pptp', 'remote-access', 'authentication']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cfg_base):
+ # Nothing to do
+ return
+
+ # Migrate "vpn pptp authentication radius-source-address" to new
+ # "vpn pptp authentication radius source-address"
+ if config.exists(cfg_base + ['radius-source-address']):
+ address = config.return_value(cfg_base + ['radius-source-address'])
+ # delete old configuration node
+ config.delete(cfg_base + ['radius-source-address'])
+ # write new configuration node
+ config.set(cfg_base + ['radius', 'source-address'], value=address)
+
+ # Migrate "vpn pptp authentication radius-server" tag node to new
+ # "vpn pptp authentication radius server" tag node
+ for server in config.list_nodes(cfg_base + ['radius-server']):
+ base_server = cfg_base + ['radius-server', server]
+ key = config.return_value(base_server + ['key'])
+
+ # delete old configuration node
+ config.delete(base_server)
+ # write new configuration node
+ config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
+
+ # format as tag node
+ config.set_tag(cfg_base + ['radius', 'server'])
+
+ # delete top level tag node
+ if config.exists(cfg_base + ['radius-server']):
+ config.delete(cfg_base + ['radius-server'])
diff --git a/src/migration-scripts/pptp/1-to-2 b/src/migration-scripts/pptp/1-to-2
new file mode 100644
index 0000000..1e76011
--- /dev/null
+++ b/src/migration-scripts/pptp/1-to-2
@@ -0,0 +1,53 @@
+# Copyright 2020-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 dns-servers node to common name-servers
+# - remove radios req-limit node
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'pptp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Migrate IPv4 DNS servers
+ dns_base = base + ['dns-servers']
+ if config.exists(dns_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(dns_base + [server]):
+ dns = config.return_value(dns_base + [server])
+ config.set(base + ['name-server'], value=dns, replace=False)
+
+ config.delete(dns_base)
+
+ # Migrate IPv4 WINS servers
+ wins_base = base + ['wins-servers']
+ if config.exists(wins_base):
+ for server in ['server-1', 'server-2']:
+ if config.exists(wins_base + [server]):
+ wins = config.return_value(wins_base + [server])
+ config.set(base + ['wins-server'], value=wins, replace=False)
+
+ config.delete(wins_base)
+
+ # Remove RADIUS server req-limit node
+ radius_base = base + ['authentication', 'radius']
+ if config.exists(radius_base):
+ for server in config.list_nodes(radius_base + ['server']):
+ if config.exists(radius_base + ['server', server, 'req-limit']):
+ config.delete(radius_base + ['server', server, 'req-limit'])
diff --git a/src/migration-scripts/pptp/2-to-3 b/src/migration-scripts/pptp/2-to-3
new file mode 100644
index 0000000..8b0d6d8
--- /dev/null
+++ b/src/migration-scripts/pptp/2-to-3
@@ -0,0 +1,55 @@
+# Copyright 2023-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/>.
+
+# - move all pool to named pools
+# 'start-stop' migrate to namedpool 'default-range-pool'
+# 'default-subnet-pool' is the next pool for 'default-range-pool'
+
+from vyos.configtree import ConfigTree
+from vyos.base import Warning
+
+base = ['vpn', 'pptp', 'remote-access']
+pool_base = base + ['client-ip-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ range_pool_name = 'default-range-pool'
+
+ if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+ def is_legalrange(ip1: str, ip2: str, mask: str):
+ from ipaddress import IPv4Interface
+ interface1 = IPv4Interface(f'{ip1}/{mask}')
+ interface2 = IPv4Interface(f'{ip2}/{mask}')
+ return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+
+ start_ip = config.return_value(pool_base + ['start'])
+ stop_ip = config.return_value(pool_base + ['stop'])
+ if is_legalrange(start_ip, stop_ip, '24'):
+ ip_range = f'{start_ip}-{stop_ip}'
+ config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+ config.set(base + ['default-pool'], value=range_pool_name)
+ else:
+ Warning(
+ f'PPTP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+
+ config.delete(pool_base + ['start'])
+ config.delete(pool_base + ['stop'])
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/pptp/3-to-4 b/src/migration-scripts/pptp/3-to-4
new file mode 100644
index 0000000..2dabd84
--- /dev/null
+++ b/src/migration-scripts/pptp/3-to-4
@@ -0,0 +1,29 @@
+# Copyright 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/>.
+
+# - Move 'mppe' from 'authentication' node to 'ppp-options'
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'pptp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['authentication','mppe']):
+ mppe = config.return_value(base + ['authentication','mppe'])
+ config.set(base + ['ppp-options', 'mppe'], value=mppe, replace=True)
+ config.delete(base + ['authentication','mppe'])
diff --git a/src/migration-scripts/pptp/4-to-5 b/src/migration-scripts/pptp/4-to-5
new file mode 100644
index 0000000..c906f58
--- /dev/null
+++ b/src/migration-scripts/pptp/4-to-5
@@ -0,0 +1,43 @@
+# Copyright 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/>.
+
+# - Move 'require' from 'protocols' in 'authentication' node
+# - Migrate to new default values in radius timeout and acct-timeout
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'pptp', 'remote-access']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ #migrate require to protocols
+ require_path = base + ['authentication', 'require']
+ if config.exists(require_path):
+ protocols = list(config.return_values(require_path))
+ for protocol in protocols:
+ config.set(base + ['authentication', 'protocols'], value=protocol,
+ replace=False)
+ config.delete(require_path)
+ else:
+ config.set(base + ['authentication', 'protocols'], value='mschap-v2')
+
+ radius_path = base + ['authentication', 'radius']
+ if config.exists(radius_path):
+ if not config.exists(radius_path + ['timeout']):
+ config.set(radius_path + ['timeout'], value=3)
+ if not config.exists(radius_path + ['acct-timeout']):
+ config.set(radius_path + ['acct-timeout'], value=3)
diff --git a/src/migration-scripts/qos/1-to-2 b/src/migration-scripts/qos/1-to-2
new file mode 100644
index 0000000..c43d8fa
--- /dev/null
+++ b/src/migration-scripts/qos/1-to-2
@@ -0,0 +1,168 @@
+# Copyright 2022-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/>.
+
+from vyos.base import Warning
+from vyos.configtree import ConfigTree
+from vyos.utils.file import read_file
+
+def bandwidth_percent_to_val(interface, percent) -> int:
+ speed = read_file(f'/sys/class/net/{interface}/speed')
+ if not speed.isnumeric():
+ Warning('Interface speed cannot be determined (assuming 10 Mbit/s)')
+ speed = 10
+ speed = int(speed) *1000000 # convert to MBit/s
+ return speed * int(percent) // 100 # integer division
+
+
+def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None):
+ """Delete unexpected traffic-policy on interfaces in cases when
+ policy does not exist but inreface has a policy configuration
+ Example T5941:
+ set interfaces bonding bond0 vif 995 traffic-policy
+ """
+ if_path = ['interfaces', iftype, ifname]
+
+ if vif:
+ if_path += ['vif', vif]
+ elif vifs:
+ if_path += ['vif-s', vifs]
+ if vifc:
+ if_path += ['vif-c', vifc]
+
+ if not config.exists(if_path + ['traffic-policy']):
+ return
+
+ config.delete(if_path + ['traffic-policy'])
+
+
+def migrate(config: ConfigTree) -> None:
+ base = ['traffic-policy']
+
+ if not config.exists(base):
+ # Delete orphaned nodes on interfaces T5941
+ for iftype in config.list_nodes(['interfaces']):
+ for ifname in config.list_nodes(['interfaces', iftype]):
+ delete_orphaned_interface_policy(config, iftype, ifname)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif']):
+ for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+ for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
+
+ if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+ delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
+
+ # Nothing to do
+ return
+
+ iface_config = {}
+
+ if config.exists(['interfaces']):
+ def get_qos(config, interface, interface_base):
+ if config.exists(interface_base):
+ tmp = { interface : {} }
+ if config.exists(interface_base + ['in']):
+ tmp[interface]['ingress'] = config.return_value(interface_base + ['in'])
+ if config.exists(interface_base + ['out']):
+ tmp[interface]['egress'] = config.return_value(interface_base + ['out'])
+ config.delete(interface_base)
+ return tmp
+ return None
+
+ # Migrate "interface ethernet eth0 traffic-policy in|out" to "qos interface eth0 ingress|egress"
+ for type in config.list_nodes(['interfaces']):
+ for interface in config.list_nodes(['interfaces', type]):
+ interface_base = ['interfaces', type, interface, 'traffic-policy']
+ tmp = get_qos(config, interface, interface_base)
+ if tmp: iface_config.update(tmp)
+
+ vif_path = ['interfaces', type, interface, 'vif']
+ if config.exists(vif_path):
+ for vif in config.list_nodes(vif_path):
+ vif_interface_base = vif_path + [vif, 'traffic-policy']
+ ifname = f'{interface}.{vif}'
+ tmp = get_qos(config, ifname, vif_interface_base)
+ if tmp: iface_config.update(tmp)
+
+ vif_s_path = ['interfaces', type, interface, 'vif-s']
+ if config.exists(vif_s_path):
+ for vif_s in config.list_nodes(vif_s_path):
+ vif_s_interface_base = vif_s_path + [vif_s, 'traffic-policy']
+ ifname = f'{interface}.{vif_s}'
+ tmp = get_qos(config, ifname, vif_s_interface_base)
+ if tmp: iface_config.update(tmp)
+
+ # vif-c interfaces MUST be migrated before their parent vif-s
+ # interface as the migrate_*() functions delete the path!
+ vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
+ if config.exists(vif_c_path):
+ for vif_c in config.list_nodes(vif_c_path):
+ vif_c_interface_base = vif_c_path + [vif_c, 'traffic-policy']
+ ifname = f'{interface}.{vif_s}.{vif_c}'
+ tmp = get_qos(config, ifname, vif_s_interface_base)
+ if tmp: iface_config.update(tmp)
+
+
+ # Now we have the information which interface uses which QoS policy.
+ # Interface binding will be moved to the qos CLi tree
+ config.set(['qos'])
+ config.copy(base, ['qos', 'policy'])
+ config.delete(base)
+
+ # Now map the interface policy binding to the new CLI syntax
+ if len(iface_config):
+ config.set(['qos', 'interface'])
+ config.set_tag(['qos', 'interface'])
+
+ for interface, interface_config in iface_config.items():
+ config.set(['qos', 'interface', interface])
+ config.set_tag(['qos', 'interface', interface])
+ if 'ingress' in interface_config:
+ config.set(['qos', 'interface', interface, 'ingress'], value=interface_config['ingress'])
+ if 'egress' in interface_config:
+ config.set(['qos', 'interface', interface, 'egress'], value=interface_config['egress'])
+
+ # Remove "burst" CLI node from network emulator
+ netem_base = ['qos', 'policy', 'network-emulator']
+ if config.exists(netem_base):
+ for policy_name in config.list_nodes(netem_base):
+ if config.exists(netem_base + [policy_name, 'burst']):
+ config.delete(netem_base + [policy_name, 'burst'])
+
+ # Change bandwidth unit MBit -> mbit as tc only supports mbit
+ base = ['qos', 'policy']
+ if config.exists(base):
+ for policy_type in config.list_nodes(base):
+ for policy in config.list_nodes(base + [policy_type]):
+ policy_base = base + [policy_type, policy]
+ if config.exists(policy_base + ['bandwidth']):
+ tmp = config.return_value(policy_base + ['bandwidth'])
+ config.set(policy_base + ['bandwidth'], value=tmp.lower())
+
+ if config.exists(policy_base + ['class']):
+ for cls in config.list_nodes(policy_base + ['class']):
+ cls_base = policy_base + ['class', cls]
+ if config.exists(cls_base + ['bandwidth']):
+ tmp = config.return_value(cls_base + ['bandwidth'])
+ config.set(cls_base + ['bandwidth'], value=tmp.lower())
+
+ if config.exists(policy_base + ['default', 'bandwidth']):
+ if config.exists(policy_base + ['default', 'bandwidth']):
+ tmp = config.return_value(policy_base + ['default', 'bandwidth'])
+ config.set(policy_base + ['default', 'bandwidth'], value=tmp.lower())
diff --git a/src/migration-scripts/quagga/10-to-11 b/src/migration-scripts/quagga/10-to-11
new file mode 100644
index 0000000..15dbbb1
--- /dev/null
+++ b/src/migration-scripts/quagga/10-to-11
@@ -0,0 +1,31 @@
+# Copyright 2023-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/>.
+
+# T5150: Rework CLI definitions to apply route-maps between routing daemons
+# and zebra/kernel
+
+from vyos.configtree import ConfigTree
+
+static_base = ['protocols', 'static']
+
+def migrate(config: ConfigTree) -> None:
+ # Check if static routes are configured - if so, migrate the CLI node
+ if config.exists(static_base):
+ if config.exists(static_base + ['route-map']):
+ tmp = config.return_value(static_base + ['route-map'])
+
+ config.set(['system', 'ip', 'protocol', 'static', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ip', 'protocol'])
+ config.delete(static_base + ['route-map'])
diff --git a/src/migration-scripts/quagga/2-to-3 b/src/migration-scripts/quagga/2-to-3
new file mode 100644
index 0000000..d62c387
--- /dev/null
+++ b/src/migration-scripts/quagga/2-to-3
@@ -0,0 +1,181 @@
+# Copyright 2018-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/>.
+
+from vyos.configtree import ConfigTree
+
+
+def migrate_neighbor(config, neighbor_path, neighbor):
+ if config.exists(neighbor_path):
+ neighbors = config.list_nodes(neighbor_path)
+ for neighbor in neighbors:
+ # Move the valueless options: as-override, next-hop-self, route-reflector-client, route-server-client,
+ # remove-private-as
+ for valueless_option in ['as-override', 'nexthop-self', 'route-reflector-client', 'route-server-client',
+ 'remove-private-as']:
+ if config.exists(neighbor_path + [neighbor, valueless_option]):
+ config.set(neighbor_path + [neighbor] + af_path + [valueless_option])
+ config.delete(neighbor_path + [neighbor, valueless_option])
+
+ # Move filter options: distribute-list, filter-list, prefix-list, and route-map
+ # They share the same syntax inside so we can group them
+ for filter_type in ['distribute-list', 'filter-list', 'prefix-list', 'route-map']:
+ if config.exists(neighbor_path + [neighbor, filter_type]):
+ for filter_dir in ['import', 'export']:
+ if config.exists(neighbor_path + [neighbor, filter_type, filter_dir]):
+ filter_name = config.return_value(neighbor_path + [neighbor, filter_type, filter_dir])
+ config.set(neighbor_path + [neighbor] + af_path + [filter_type, filter_dir], value=filter_name)
+ config.delete(neighbor_path + [neighbor, filter_type])
+
+ # Move simple leaf node options: maximum-prefix, unsuppress-map, weight
+ for leaf_option in ['maximum-prefix', 'unsuppress-map', 'weight']:
+ if config.exists(neighbor_path + [neighbor, leaf_option]):
+ if config.exists(neighbor_path + [neighbor, leaf_option]):
+ leaf_opt_value = config.return_value(neighbor_path + [neighbor, leaf_option])
+ config.set(neighbor_path + [neighbor] + af_path + [leaf_option], value=leaf_opt_value)
+ config.delete(neighbor_path + [neighbor, leaf_option])
+
+ # The rest is special cases, for better or worse
+
+ # Move allowas-in
+ if config.exists(neighbor_path + [neighbor, 'allowas-in']):
+ if config.exists(neighbor_path + [neighbor, 'allowas-in', 'number']):
+ allowas_in = config.return_value(neighbor_path + [neighbor, 'allowas-in', 'number'])
+ config.set(neighbor_path + [neighbor] + af_path + ['allowas-in', 'number'], value=allowas_in)
+ config.delete(neighbor_path + [neighbor, 'allowas-in'])
+
+ # Move attribute-unchanged options
+ if config.exists(neighbor_path + [neighbor, 'attribute-unchanged']):
+ for attr in ['as-path', 'med', 'next-hop']:
+ if config.exists(neighbor_path + [neighbor, 'attribute-unchanged', attr]):
+ config.set(neighbor_path + [neighbor] + af_path + ['attribute-unchanged', attr])
+ config.delete(neighbor_path + [neighbor, 'attribute-unchanged', attr])
+ config.delete(neighbor_path + [neighbor, 'attribute-unchanged'])
+
+ # Move capability options
+ if config.exists(neighbor_path + [neighbor, 'capability']):
+ # "capability dynamic" is a peer-global option, we only migrate ORF
+ if config.exists(neighbor_path + [neighbor, 'capability', 'orf']):
+ if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list']):
+ for orf in ['send', 'receive']:
+ if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf]):
+ config.set(neighbor_path + [neighbor] + af_path + ['capability', 'orf', 'prefix-list', orf])
+ config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf])
+ config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list'])
+ config.delete(neighbor_path + [neighbor, 'capability', 'orf'])
+
+ # Move default-originate
+ if config.exists(neighbor_path + [neighbor, 'default-originate']):
+ if config.exists(neighbor_path + [neighbor, 'default-originate', 'route-map']):
+ route_map = config.return_value(neighbor_path + [neighbor, 'default-originate', 'route-map'])
+ config.set(neighbor_path + [neighbor] + af_path + ['default-originate', 'route-map'], value=route_map)
+ else:
+ # Empty default-originate node is meaningful so we re-create it
+ config.set(neighbor_path + [neighbor] + af_path + ['default-originate'])
+ config.delete(neighbor_path + [neighbor, 'default-originate'])
+
+ # Move soft-reconfiguration
+ if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration']):
+ if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration', 'inbound']):
+ config.set(neighbor_path + [neighbor] + af_path + ['soft-reconfiguration', 'inbound'])
+ # Empty soft-reconfiguration is meaningless, so we just remove it
+ config.delete(neighbor_path + [neighbor, 'soft-reconfiguration'])
+
+ # Move disable-send-community
+ if config.exists(neighbor_path + [neighbor, 'disable-send-community']):
+ for comm_type in ['standard', 'extended']:
+ if config.exists(neighbor_path + [neighbor, 'disable-send-community', comm_type]):
+ config.set(neighbor_path + [neighbor] + af_path + ['disable-send-community', comm_type])
+ config.delete(neighbor_path + [neighbor, 'disable-send-community', comm_type])
+ config.delete(neighbor_path + [neighbor, 'disable-send-community'])
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['protocols', 'bgp']):
+ # Nothing to do
+ return
+
+ # Just to avoid writing it so many times
+ af_path = ['address-family', 'ipv4-unicast']
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(['protocols', 'bgp'])
+ if asn_list:
+ # There's always just one BGP node, if any
+ asn = asn_list[0]
+ bgp_path = ['protocols', 'bgp', asn]
+ else:
+ # There's actually no BGP, just its empty shell
+ return
+
+ ## Move global IPv4-specific BGP options to "address-family ipv4-unicast"
+
+ # Move networks
+ network_path = ['protocols', 'bgp', asn, 'network']
+ if config.exists(network_path):
+ config.set(bgp_path + af_path + ['network'])
+ config.set_tag(bgp_path + af_path + ['network'])
+
+ networks = config.list_nodes(network_path)
+ for network in networks:
+ config.set(bgp_path + af_path + ['network', network])
+ if config.exists(network_path + [network, 'route-map']):
+ route_map = config.return_value(network_path + [network, 'route-map'])
+ config.set(bgp_path + af_path + ['network', network, 'route-map'], value=route_map)
+ config.delete(network_path)
+
+ # Move aggregate-address statements
+ aggregate_path = ['protocols', 'bgp', asn, 'aggregate-address']
+ if config.exists(aggregate_path):
+ config.set(bgp_path + af_path + ['aggregate-address'])
+ config.set_tag(bgp_path + af_path + ['aggregate-address'])
+
+ aggregates = config.list_nodes(aggregate_path)
+ for aggregate in aggregates:
+ config.set(bgp_path + af_path + ['aggregate-address', aggregate])
+ if config.exists(aggregate_path + [aggregate, 'as-set']):
+ config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'as-set'])
+ if config.exists(aggregate_path + [aggregate, 'summary-only']):
+ config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'summary-only'])
+ config.delete(aggregate_path)
+
+ ## Migrate neighbor options
+ neighbor_path = ['protocols', 'bgp', asn, 'neighbor']
+ if config.exists(neighbor_path):
+ neighbors = config.list_nodes(neighbor_path)
+ for neighbor in neighbors:
+ migrate_neighbor(config, neighbor_path, neighbor)
+
+ peer_group_path = ['protocols', 'bgp', asn, 'peer-group']
+ if config.exists(peer_group_path):
+ peer_groups = config.list_nodes(peer_group_path)
+ for peer_group in peer_groups:
+ migrate_neighbor(config, peer_group_path, peer_group)
+
+ ## Migrate redistribute statements
+ redistribute_path = ['protocols', 'bgp', asn, 'redistribute']
+ if config.exists(redistribute_path):
+ config.set(bgp_path + af_path + ['redistribute'])
+
+ redistributes = config.list_nodes(redistribute_path)
+ for redistribute in redistributes:
+ config.set(bgp_path + af_path + ['redistribute', redistribute])
+ if config.exists(redistribute_path + [redistribute, 'metric']):
+ redist_metric = config.return_value(redistribute_path + [redistribute, 'metric'])
+ config.set(bgp_path + af_path + ['redistribute', redistribute, 'metric'], value=redist_metric)
+ if config.exists(redistribute_path + [redistribute, 'route-map']):
+ redist_route_map = config.return_value(redistribute_path + [redistribute, 'route-map'])
+ config.set(bgp_path + af_path + ['redistribute', redistribute, 'route-map'], value=redist_route_map)
+
+ config.delete(redistribute_path)
diff --git a/src/migration-scripts/quagga/3-to-4 b/src/migration-scripts/quagga/3-to-4
new file mode 100644
index 0000000..81cf139
--- /dev/null
+++ b/src/migration-scripts/quagga/3-to-4
@@ -0,0 +1,52 @@
+# Copyright 2019-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/>.
+
+# Between 1.2.3 and 1.2.4, FRR added per-neighbor enforce-first-as option.
+# Unfortunately they also removed the global enforce-first-as option,
+# which broke all old configs that used to have it.
+#
+# To emulate the effect of the original option, we insert it in every neighbor
+# if the config used to have the original global option
+
+from vyos.configtree import ConfigTree
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['protocols', 'bgp']):
+ # Nothing to do
+ return
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(['protocols', 'bgp'])
+ if asn_list:
+ # There's always just one BGP node, if any
+ asn = asn_list[0]
+ else:
+ # There's actually no BGP, just its empty shell
+ return
+
+ # Check if BGP enforce-first-as option is set
+ enforce_first_as_path = ['protocols', 'bgp', asn, 'parameters', 'enforce-first-as']
+ if config.exists(enforce_first_as_path):
+ # Delete the obsolete option
+ config.delete(enforce_first_as_path)
+
+ # Now insert it in every peer
+ peers = config.list_nodes(['protocols', 'bgp', asn, 'neighbor'])
+ for p in peers:
+ config.set(['protocols', 'bgp', asn, 'neighbor', p, 'enforce-first-as'])
+ else:
+ # Do nothing
+ return
diff --git a/src/migration-scripts/quagga/4-to-5 b/src/migration-scripts/quagga/4-to-5
new file mode 100644
index 0000000..27b9954
--- /dev/null
+++ b/src/migration-scripts/quagga/4-to-5
@@ -0,0 +1,40 @@
+# Copyright 2020-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/>.
+
+from vyos.configtree import ConfigTree
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['protocols', 'bgp']):
+ # Nothing to do
+ return
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(['protocols', 'bgp'])
+ if asn_list:
+ # There's always just one BGP node, if any
+ asn = asn_list[0]
+ else:
+ # There's actually no BGP, just its empty shell
+ return
+
+ # Check if BGP scan-time parameter exist
+ scan_time_param = ['protocols', 'bgp', asn, 'parameters', 'scan-time']
+ if config.exists(scan_time_param):
+ # Delete BGP scan-time parameter
+ config.delete(scan_time_param)
+ else:
+ # Do nothing
+ return
diff --git a/src/migration-scripts/quagga/5-to-6 b/src/migration-scripts/quagga/5-to-6
new file mode 100644
index 0000000..08fd070
--- /dev/null
+++ b/src/migration-scripts/quagga/5-to-6
@@ -0,0 +1,40 @@
+# Copyright 2020-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/>.
+
+from vyos.configtree import ConfigTree
+
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['protocols', 'bgp']):
+ # Nothing to do
+ return
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(['protocols', 'bgp'])
+ if asn_list:
+ # There's always just one BGP node, if any
+ asn = asn_list[0]
+ else:
+ # There's actually no BGP, just its empty shell
+ return
+
+ # Check if BGP parameter disable-network-import-check exists
+ param = ['protocols', 'bgp', asn, 'parameters', 'disable-network-import-check']
+ if config.exists(param):
+ # Delete parameter
+ config.delete(param)
+ else:
+ # Do nothing
+ return
diff --git a/src/migration-scripts/quagga/6-to-7 b/src/migration-scripts/quagga/6-to-7
new file mode 100644
index 0000000..095baac
--- /dev/null
+++ b/src/migration-scripts/quagga/6-to-7
@@ -0,0 +1,97 @@
+# 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/>.
+
+# - T3037, BGP address-family ipv6-unicast capability dynamic does not exist in
+# FRR, there is only a base, per neighbor dynamic capability, migrate config
+
+from vyos.configtree import ConfigTree
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+
+base = ['protocols', 'bgp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(base)
+ if asn_list:
+ # There's always just one BGP node, if any
+ bgp_base = base + [asn_list[0]]
+
+ for neighbor_type in ['neighbor', 'peer-group']:
+ if not config.exists(bgp_base + [neighbor_type]):
+ continue
+ for neighbor in config.list_nodes(bgp_base + [neighbor_type]):
+ # T2844 - add IPv4 AFI disable-send-community support
+ send_comm_path = bgp_base + [neighbor_type, neighbor, 'disable-send-community']
+ if config.exists(send_comm_path):
+ new_base = bgp_base + [neighbor_type, neighbor, 'address-family', 'ipv4-unicast']
+ config.set(new_base)
+ config.copy(send_comm_path, new_base + ['disable-send-community'])
+ config.delete(send_comm_path)
+
+ cap_dynamic = False
+ peer_group = None
+ for afi in ['ipv4-unicast', 'ipv6-unicast']:
+ afi_path = bgp_base + [neighbor_type, neighbor, 'address-family', afi]
+ # Exit loop early if AFI does not exist
+ if not config.exists(afi_path):
+ continue
+
+ cap_path = afi_path + ['capability', 'dynamic']
+ if config.exists(cap_path):
+ cap_dynamic = True
+ config.delete(cap_path)
+
+ # We have now successfully migrated the address-family
+ # specific dynamic capability to the neighbor/peer-group
+ # level. If this has been the only option under the
+ # address-family nodes, we can clean them up by checking if
+ # no other nodes are left under that tree and if so, delete
+ # the parent.
+ #
+ # We walk from the most inner node to the most outer one.
+ cleanup = -1
+ while len(config.list_nodes(cap_path[:cleanup])) == 0:
+ config.delete(cap_path[:cleanup])
+ cleanup -= 1
+
+ peer_group_path = afi_path + ['peer-group']
+ if config.exists(peer_group_path):
+ if ((is_ipv4(neighbor) and afi == 'ipv4-unicast') or
+ (is_ipv6(neighbor) and afi == 'ipv6-unicast')):
+ peer_group = config.return_value(peer_group_path)
+
+ config.delete(peer_group_path)
+
+ # We have now successfully migrated the address-family
+ # specific peer-group to the neighbor level. If this has
+ # been the only option under the address-family nodes, we
+ # can clean them up by checking if no other nodes are left
+ # under that tree and if so, delete the parent.
+ #
+ # We walk from the most inner node to the most outer one.
+ cleanup = -1
+ while len(config.list_nodes(peer_group_path[:cleanup])) == 0:
+ config.delete(peer_group_path[:cleanup])
+ cleanup -= 1
+
+ if cap_dynamic:
+ config.set(bgp_base + [neighbor_type, neighbor, 'capability', 'dynamic'])
+ if peer_group:
+ config.set(bgp_base + [neighbor_type, neighbor, 'peer-group'], value=peer_group)
diff --git a/src/migration-scripts/quagga/7-to-8 b/src/migration-scripts/quagga/7-to-8
new file mode 100644
index 0000000..d9de26d
--- /dev/null
+++ b/src/migration-scripts/quagga/7-to-8
@@ -0,0 +1,42 @@
+# 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/>.
+
+# - T3391: Migrate "maximum-paths" setting from "protocols bgp asn maximum-paths"
+# under the IPv4 address-family tree. Reason is we currently have no way in
+# configuring this for IPv6 address-family. This mimics the FRR configuration.
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'bgp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Check if BGP is actually configured and obtain the ASN
+ asn_list = config.list_nodes(base)
+ if asn_list:
+ # There's always just one BGP node, if any
+ bgp_base = base + [asn_list[0]]
+
+ maximum_paths = bgp_base + ['maximum-paths']
+ if config.exists(maximum_paths):
+ for bgp_type in ['ebgp', 'ibgp']:
+ if config.exists(maximum_paths + [bgp_type]):
+ new_base = bgp_base + ['address-family', 'ipv4-unicast', 'maximum-paths']
+ config.set(new_base)
+ config.copy(maximum_paths + [bgp_type], new_base + [bgp_type])
+ config.delete(maximum_paths)
diff --git a/src/migration-scripts/quagga/8-to-9 b/src/migration-scripts/quagga/8-to-9
new file mode 100644
index 0000000..eece6c1
--- /dev/null
+++ b/src/migration-scripts/quagga/8-to-9
@@ -0,0 +1,117 @@
+# 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/>.
+
+# - T2450: drop interface-route and interface-route6 from "protocols static"
+
+from vyos.configtree import ConfigTree
+
+def migrate_interface_route(config, base, path, route_route6):
+ """ Generic migration function which can be called on every instance of
+ interface-route, beeing it ipv4, ipv6 or nested under the "static table" nodes.
+
+ What we do?
+ - Drop 'interface-route' or 'interface-route6' and migrate the route unter the
+ 'route' or 'route6' tag node.
+ """
+ if config.exists(base + path):
+ for route in config.list_nodes(base + path):
+ interface = config.list_nodes(base + path + [route, 'next-hop-interface'])
+
+ tmp = base + path + [route, 'next-hop-interface']
+ for interface in config.list_nodes(tmp):
+ new_base = base + [route_route6, route, 'interface']
+ config.set(new_base)
+ config.set_tag(base + [route_route6])
+ config.set_tag(new_base)
+ config.copy(tmp + [interface], new_base + [interface])
+
+ config.delete(base + path)
+
+def migrate_route(config, base, path, route_route6):
+ """ Generic migration function which can be called on every instance of
+ route, beeing it ipv4, ipv6 or even nested under the static table nodes.
+
+ What we do?
+ - for consistency reasons rename next-hop-interface to interface
+ - for consistency reasons rename next-hop-vrf to vrf
+ """
+ if config.exists(base + path):
+ for route in config.list_nodes(base + path):
+ next_hop = base + path + [route, 'next-hop']
+ if config.exists(next_hop):
+ for gateway in config.list_nodes(next_hop):
+ # IPv4 routes calls it next-hop-interface, rename this to
+ # interface instead so it's consitent with IPv6
+ interface_path = next_hop + [gateway, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+
+ # When VRFs got introduced, I (c-po) named it next-hop-vrf,
+ # we can also call it vrf which is simply shorter.
+ vrf_path = next_hop + [gateway, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
+
+ next_hop = base + path + [route, 'interface']
+ if config.exists(next_hop):
+ for interface in config.list_nodes(next_hop):
+ # IPv4 routes calls it next-hop-interface, rename this to
+ # interface instead so it's consitent with IPv6
+ interface_path = next_hop + [interface, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+
+ # When VRFs got introduced, I (c-po) named it next-hop-vrf,
+ # we can also call it vrf which is simply shorter.
+ vrf_path = next_hop + [interface, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
+
+
+base = ['protocols', 'static']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Migrate interface-route into route
+ migrate_interface_route(config, base, ['interface-route'], 'route')
+
+ # Migrate interface-route6 into route6
+ migrate_interface_route(config, base, ['interface-route6'], 'route6')
+
+ # Cleanup nodes inside route
+ migrate_route(config, base, ['route'], 'route')
+
+ # Cleanup nodes inside route6
+ migrate_route(config, base, ['route6'], 'route6')
+
+ #
+ # PBR table cleanup
+ table_path = base + ['table']
+ if config.exists(table_path):
+ for table in config.list_nodes(table_path):
+ # Migrate interface-route into route
+ migrate_interface_route(config, table_path + [table], ['interface-route'], 'route')
+
+ # Migrate interface-route6 into route6
+ migrate_interface_route(config, table_path + [table], ['interface-route6'], 'route6')
+
+ # Cleanup nodes inside route
+ migrate_route(config, table_path + [table], ['route'], 'route')
+
+ # Cleanup nodes inside route6
+ migrate_route(config, table_path + [table], ['route6'], 'route6')
diff --git a/src/migration-scripts/quagga/9-to-10 b/src/migration-scripts/quagga/9-to-10
new file mode 100644
index 0000000..4ac1f0b
--- /dev/null
+++ b/src/migration-scripts/quagga/9-to-10
@@ -0,0 +1,42 @@
+# Copyright 2022-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/>.
+
+# re-organize route-map as-path
+
+from vyos.configtree import ConfigTree
+
+base = ['policy', 'route-map']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for route_map in config.list_nodes(base):
+ # Bail out Early
+ if not config.exists(base + [route_map, 'rule']):
+ continue
+
+ for rule in config.list_nodes(base + [route_map, 'rule']):
+ rule_base = base + [route_map, 'rule', rule]
+ if config.exists(rule_base + ['set', 'as-path-exclude']):
+ tmp = config.return_value(rule_base + ['set', 'as-path-exclude'])
+ config.delete(rule_base + ['set', 'as-path-exclude'])
+ config.set(rule_base + ['set', 'as-path', 'exclude'], value=tmp)
+
+ if config.exists(rule_base + ['set', 'as-path-prepend']):
+ tmp = config.return_value(rule_base + ['set', 'as-path-prepend'])
+ config.delete(rule_base + ['set', 'as-path-prepend'])
+ config.set(rule_base + ['set', 'as-path', 'prepend'], value=tmp)
diff --git a/src/migration-scripts/reverse-proxy/0-to-1 b/src/migration-scripts/reverse-proxy/0-to-1
new file mode 100644
index 0000000..b495474
--- /dev/null
+++ b/src/migration-scripts/reverse-proxy/0-to-1
@@ -0,0 +1,31 @@
+# Copyright 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/>.
+
+# T6409: Remove unused 'backend bk-example parameters' node
+
+from vyos.configtree import ConfigTree
+
+base = ['load-balancing', 'reverse-proxy', 'backend']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # we need to run this for every configured network
+ for backend in config.list_nodes(base):
+ param_node = base + [backend, 'parameters']
+ if config.exists(param_node):
+ config.delete(param_node)
diff --git a/src/migration-scripts/rip/0-to-1 b/src/migration-scripts/rip/0-to-1
new file mode 100644
index 0000000..6d41bcf
--- /dev/null
+++ b/src/migration-scripts/rip/0-to-1
@@ -0,0 +1,31 @@
+# Copyright 2023-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/>.
+
+# T5150: Rework CLI definitions to apply route-maps between routing daemons
+# and zebra/kernel
+
+from vyos.configtree import ConfigTree
+
+ripng_base = ['protocols', 'ripng']
+
+def migrate(config: ConfigTree) -> None:
+ # Check if RIPng is configured - if so, migrate the CLI node
+ if config.exists(ripng_base):
+ if config.exists(ripng_base + ['route-map']):
+ tmp = config.return_value(ripng_base + ['route-map'])
+
+ config.set(['system', 'ipv6', 'protocol', 'ripng', 'route-map'], value=tmp)
+ config.set_tag(['system', 'ipv6', 'protocol'])
+ config.delete(ripng_base + ['route-map'])
diff --git a/src/migration-scripts/rpki/0-to-1 b/src/migration-scripts/rpki/0-to-1
new file mode 100644
index 0000000..b6e781f
--- /dev/null
+++ b/src/migration-scripts/rpki/0-to-1
@@ -0,0 +1,44 @@
+# 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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'rpki']
+
+def migrate(config: ConfigTree) -> None:
+ # Nothing to do
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['cache']):
+ preference = 1
+ for cache in config.list_nodes(base + ['cache']):
+ address_node = base + ['cache', cache, 'address']
+ if config.exists(address_node):
+ address = config.return_value(address_node)
+ # We do not longer support the address leafNode, RPKI cache server
+ # IP address is now used from the tagNode
+ config.delete(address_node)
+ # VyOS 1.2 had no per instance preference, setting new defaults
+ config.set(base + ['cache', cache, 'preference'], value=preference)
+ # Increase preference for the next caching peer - actually VyOS 1.2
+ # supported only one but better save then sorry (T3253)
+ preference += 1
+
+ # T3293: If the RPKI cache name equals the configured address,
+ # renaming is not possible, as rename expects the new path to not
+ # exist.
+ if not config.exists(base + ['cache', address]):
+ config.rename(base + ['cache', cache], address)
diff --git a/src/migration-scripts/rpki/1-to-2 b/src/migration-scripts/rpki/1-to-2
new file mode 100644
index 0000000..855236d
--- /dev/null
+++ b/src/migration-scripts/rpki/1-to-2
@@ -0,0 +1,53 @@
+# Copyright 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/>.
+
+# T6011: rpki: known-hosts-file is no longer supported bxy FRR CLI,
+# remove VyOS CLI node
+
+from vyos.configtree import ConfigTree
+from vyos.pki import OPENSSH_KEY_BEGIN
+from vyos.pki import OPENSSH_KEY_END
+from vyos.utils.file import read_file
+
+base = ['protocols', 'rpki']
+
+def migrate(config: ConfigTree) -> None:
+ # Nothing to do
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['cache']):
+ for cache in config.list_nodes(base + ['cache']):
+ ssh_node = base + ['cache', cache, 'ssh']
+ if config.exists(ssh_node + ['known-hosts-file']):
+ config.delete(ssh_node + ['known-hosts-file'])
+
+ if config.exists(base + ['cache', cache, 'ssh']):
+ private_key_node = base + ['cache', cache, 'ssh', 'private-key-file']
+ private_key_file = config.return_value(private_key_node)
+ private_key = read_file(private_key_file).replace(OPENSSH_KEY_BEGIN, '').replace(OPENSSH_KEY_END, '').replace('\n','')
+
+ public_key_node = base + ['cache', cache, 'ssh', 'public-key-file']
+ public_key_file = config.return_value(public_key_node)
+ public_key = read_file(public_key_file).split()
+
+ config.set(['pki', 'openssh', f'rpki-{cache}', 'private', 'key'], value=private_key)
+ config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'key'], value=public_key[1])
+ config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'type'], value=public_key[0])
+ config.set_tag(['pki', 'openssh'])
+ config.set(ssh_node + ['key'], value=f'rpki-{cache}')
+
+ config.delete(private_key_node)
+ config.delete(public_key_node)
diff --git a/src/migration-scripts/salt/0-to-1 b/src/migration-scripts/salt/0-to-1
new file mode 100644
index 0000000..3990a88
--- /dev/null
+++ b/src/migration-scripts/salt/0-to-1
@@ -0,0 +1,38 @@
+# Copyright 2020-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/>.
+
+# Delete log_file, log_level and user nodes
+# rename hash_type to hash
+# rename mine_interval to interval
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'salt-minion']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # delete nodes which are now populated with sane defaults
+ for node in ['log_file', 'log_level', 'user']:
+ if config.exists(base + [node]):
+ config.delete(base + [node])
+
+ if config.exists(base + ['hash_type']):
+ config.rename(base + ['hash_type'], 'hash')
+
+ if config.exists(base + ['mine_interval']):
+ config.rename(base + ['mine_interval'], 'interval')
diff --git a/src/migration-scripts/snmp/0-to-1 b/src/migration-scripts/snmp/0-to-1
new file mode 100644
index 0000000..03b190c
--- /dev/null
+++ b/src/migration-scripts/snmp/0-to-1
@@ -0,0 +1,38 @@
+# Copyright 2019-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/>.
+
+from vyos.configtree import ConfigTree
+
+config_base = ['service', 'snmp', 'v3']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(config_base):
+ # Nothing to do
+ return
+
+ # we no longer support a per trap target engine ID (https://vyos.dev/T818)
+ if config.exists(config_base + ['v3', 'trap-target']):
+ for target in config.list_nodes(config_base + ['v3', 'trap-target']):
+ config.delete(config_base + ['v3', 'trap-target', target, 'engineid'])
+
+ # we no longer support a per user engine ID (https://vyos.dev/T818)
+ if config.exists(config_base + ['v3', 'user']):
+ for user in config.list_nodes(config_base + ['v3', 'user']):
+ config.delete(config_base + ['v3', 'user', user, 'engineid'])
+
+ # we drop TSM support as there seem to be no users and this code is untested
+ # https://vyos.dev/T1769
+ if config.exists(config_base + ['v3', 'tsm']):
+ config.delete(config_base + ['v3', 'tsm'])
diff --git a/src/migration-scripts/snmp/1-to-2 b/src/migration-scripts/snmp/1-to-2
new file mode 100644
index 0000000..0120f8a
--- /dev/null
+++ b/src/migration-scripts/snmp/1-to-2
@@ -0,0 +1,70 @@
+# Copyright 2020-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/>.
+
+from vyos.configtree import ConfigTree
+
+# We no longer support hashed values prefixed with '0x' to unclutter
+# CLI and also calculate the hases in advance instead of retrieving
+# them after service startup - which was always a bad idea
+prefix = '0x'
+
+def migrate_keys(config, path):
+ # authentication: rename node 'encrypted-key' -> 'encrypted-password'
+ config_path_auth = path + ['auth', 'encrypted-key']
+ if config.exists(config_path_auth):
+ config.rename(config_path_auth, 'encrypted-password')
+ config_path_auth = path + ['auth', 'encrypted-password']
+
+ # remove leading '0x' from string if present
+ tmp = config.return_value(config_path_auth)
+ if tmp.startswith(prefix):
+ tmp = tmp.replace(prefix, '')
+ config.set(config_path_auth, value=tmp)
+
+ # privacy: rename node 'encrypted-key' -> 'encrypted-password'
+ config_path_priv = path + ['privacy', 'encrypted-key']
+ if config.exists(config_path_priv):
+ config.rename(config_path_priv, 'encrypted-password')
+ config_path_priv = path + ['privacy', 'encrypted-password']
+
+ # remove leading '0x' from string if present
+ tmp = config.return_value(config_path_priv)
+ if tmp.startswith(prefix):
+ tmp = tmp.replace(prefix, '')
+ config.set(config_path_priv, value=tmp)
+
+def migrate(config: ConfigTree) -> None:
+ config_base = ['service', 'snmp', 'v3']
+
+ if not config.exists(config_base):
+ # Nothing to do
+ return
+
+ config_engineid = config_base + ['engineid']
+ if config.exists(config_engineid):
+ tmp = config.return_value(config_engineid)
+ if tmp.startswith(prefix):
+ tmp = tmp.replace(prefix, '')
+ config.set(config_engineid, value=tmp)
+
+ config_user = config_base + ['user']
+ if config.exists(config_user):
+ for user in config.list_nodes(config_user):
+ migrate_keys(config, config_user + [user])
+
+ config_trap = config_base + ['trap-target']
+ if config.exists(config_trap):
+ for trap in config.list_nodes(config_trap):
+ migrate_keys(config, config_trap + [trap])
diff --git a/src/migration-scripts/snmp/2-to-3 b/src/migration-scripts/snmp/2-to-3
new file mode 100644
index 0000000..6d828b6
--- /dev/null
+++ b/src/migration-scripts/snmp/2-to-3
@@ -0,0 +1,33 @@
+# Copyright 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/>.
+
+# T4857: Implement FRR SNMP recomendations
+# cli changes from:
+# set service snmp oid-enable route-table
+# To
+# set service snmp oid-enable ip-forward
+
+from vyos.configtree import ConfigTree
+
+base = ['service snmp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['oid-enable']):
+ config.delete(base + ['oid-enable'])
+ config.set(base + ['oid-enable'], 'ip-forward')
diff --git a/src/migration-scripts/ssh/0-to-1 b/src/migration-scripts/ssh/0-to-1
new file mode 100644
index 0000000..65b68f5
--- /dev/null
+++ b/src/migration-scripts/ssh/0-to-1
@@ -0,0 +1,26 @@
+# Copyright 2020-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/>.
+
+# Delete "service ssh allow-root" option
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['service', 'ssh', 'allow-root']):
+ # Nothing to do
+ return
+
+ # Delete node with abandoned command
+ config.delete(['service', 'ssh', 'allow-root'])
diff --git a/src/migration-scripts/ssh/1-to-2 b/src/migration-scripts/ssh/1-to-2
new file mode 100644
index 0000000..b601db3
--- /dev/null
+++ b/src/migration-scripts/ssh/1-to-2
@@ -0,0 +1,63 @@
+# Copyright 2020-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/>.
+
+# VyOS 1.2 crux allowed configuring a lower or upper case loglevel. This
+# is no longer supported as the input data is validated and will lead to
+# an error. If user specifies an upper case logleve, make it lowercase
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'ssh']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ path_loglevel = base + ['loglevel']
+ if config.exists(path_loglevel):
+ # red in configured loglevel and convert it to lower case
+ tmp = config.return_value(path_loglevel).lower()
+ # VyOS 1.2 had no proper value validation on the CLI thus the
+ # user could use any arbitrary values - sanitize them
+ if tmp not in ['quiet', 'fatal', 'error', 'info', 'verbose']:
+ tmp = 'info'
+ config.set(path_loglevel, value=tmp)
+
+ # T4273: migrate ssh cipher list to multi node
+ path_ciphers = base + ['ciphers']
+ if config.exists(path_ciphers):
+ tmp = []
+ # get curtrent cipher list - comma delimited
+ for cipher in config.return_values(path_ciphers):
+ tmp.extend(cipher.split(','))
+ # delete old cipher suite representation
+ config.delete(path_ciphers)
+
+ for cipher in tmp:
+ config.set(path_ciphers, value=cipher, replace=False)
+
+ # T4273: migrate ssh key-exchange list to multi node
+ path_kex = base + ['key-exchange']
+ if config.exists(path_kex):
+ tmp = []
+ # get curtrent cipher list - comma delimited
+ for kex in config.return_values(path_kex):
+ tmp.extend(kex.split(','))
+ # delete old cipher suite representation
+ config.delete(path_kex)
+
+ for kex in tmp:
+ config.set(path_kex, value=kex, replace=False)
diff --git a/src/migration-scripts/sstp/0-to-1 b/src/migration-scripts/sstp/0-to-1
new file mode 100644
index 0000000..1bd7d6c
--- /dev/null
+++ b/src/migration-scripts/sstp/0-to-1
@@ -0,0 +1,109 @@
+# Copyright 2020-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 from "service sstp-server" to "vpn sstp"
+# - remove primary/secondary identifier from nameserver
+# - migrate RADIUS configuration to a more uniform syntax accross the system
+# - authentication radius-server x.x.x.x to authentication radius server x.x.x.x
+# - authentication radius-settings to authentication radius
+# - do not migrate radius server req-limit, use default of unlimited
+# - migrate SSL certificate path
+
+from vyos.configtree import ConfigTree
+
+old_base = ['service', 'sstp-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base):
+ # Nothing to do
+ return
+
+ # ensure new base path exists
+ if not config.exists(['vpn']):
+ config.set(['vpn'])
+
+ new_base = ['vpn', 'sstp']
+ # copy entire tree
+ config.copy(old_base, new_base)
+ config.delete(old_base)
+
+ # migrate DNS servers
+ dns_base = new_base + ['network-settings', 'dns-server']
+ if config.exists(dns_base):
+ if config.exists(dns_base + ['primary-dns']):
+ dns = config.return_value(dns_base + ['primary-dns'])
+ config.set(new_base + ['network-settings', 'name-server'], value=dns, replace=False)
+
+ if config.exists(dns_base + ['secondary-dns']):
+ dns = config.return_value(dns_base + ['secondary-dns'])
+ config.set(new_base + ['network-settings', 'name-server'], value=dns, replace=False)
+
+ config.delete(dns_base)
+
+
+ # migrate radius options - copy subtree
+ # thus must happen before migration of the individual RADIUS servers
+ old_options = new_base + ['authentication', 'radius-settings']
+ if config.exists(old_options):
+ new_options = new_base + ['authentication', 'radius']
+ config.copy(old_options, new_options)
+ config.delete(old_options)
+
+ # migrate radius dynamic author / change of authorisation server
+ dae_old = new_base + ['authentication', 'radius', 'dae-server']
+ if config.exists(dae_old):
+ config.rename(dae_old, 'dynamic-author')
+ dae_new = new_base + ['authentication', 'radius', 'dynamic-author']
+
+ if config.exists(dae_new + ['ip-address']):
+ config.rename(dae_new + ['ip-address'], 'server')
+
+ if config.exists(dae_new + ['secret']):
+ config.rename(dae_new + ['secret'], 'key')
+
+
+ # migrate radius server
+ radius_server = new_base + ['authentication', 'radius-server']
+ if config.exists(radius_server):
+ for server in config.list_nodes(radius_server):
+ base = radius_server + [server]
+ new = new_base + ['authentication', 'radius', 'server', server]
+
+ # convert secret to key
+ if config.exists(base + ['secret']):
+ tmp = config.return_value(base + ['secret'])
+ config.set(new + ['key'], value=tmp)
+
+ if config.exists(base + ['fail-time']):
+ tmp = config.return_value(base + ['fail-time'])
+ config.set(new + ['fail-time'], value=tmp)
+
+ config.set_tag(new_base + ['authentication', 'radius', 'server'])
+ config.delete(radius_server)
+
+ # migrate SSL certificates
+ old_ssl = new_base + ['sstp-settings']
+ new_ssl = new_base + ['ssl']
+ config.copy(old_ssl + ['ssl-certs'], new_ssl)
+ config.delete(old_ssl)
+
+ if config.exists(new_ssl + ['ca']):
+ config.rename(new_ssl + ['ca'], 'ca-cert-file')
+
+ if config.exists(new_ssl + ['server-cert']):
+ config.rename(new_ssl + ['server-cert'], 'cert-file')
+
+ if config.exists(new_ssl + ['server-key']):
+ config.rename(new_ssl + ['server-key'], 'key-file')
diff --git a/src/migration-scripts/sstp/1-to-2 b/src/migration-scripts/sstp/1-to-2
new file mode 100644
index 0000000..2349e3c
--- /dev/null
+++ b/src/migration-scripts/sstp/1-to-2
@@ -0,0 +1,93 @@
+# Copyright 2020-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 relative path SSL certificate to absolute path, as certs are only
+# allowed to stored in /config/user-data/sstp/ this is pretty straight
+# forward move. Delete certificates from source directory
+
+import os
+
+from shutil import copy2
+from stat import S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH
+from vyos.configtree import ConfigTree
+
+base_path = ['vpn', 'sstp', 'ssl']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ cert_path_old ='/config/user-data/sstp/'
+ cert_path_new ='/config/auth/sstp/'
+
+ if not os.path.isdir(cert_path_new):
+ os.mkdir(cert_path_new)
+
+ #
+ # migrate ca-cert-file to new path
+ if config.exists(base_path + ['ca-cert-file']):
+ tmp = config.return_value(base_path + ['ca-cert-file'])
+ cert_old = cert_path_old + tmp
+ cert_new = cert_path_new + tmp
+
+ if os.path.isfile(cert_old):
+ # adjust file permissions on source file,
+ # permissions will be copied by copy2()
+ os.chmod(cert_old, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
+ copy2(cert_old, cert_path_new)
+ # delete old certificate file
+ os.unlink(cert_old)
+
+ config.set(base_path + ['ca-cert-file'], value=cert_new, replace=True)
+
+ #
+ # migrate cert-file to new path
+ if config.exists(base_path + ['cert-file']):
+ tmp = config.return_value(base_path + ['cert-file'])
+ cert_old = cert_path_old + tmp
+ cert_new = cert_path_new + tmp
+
+ if os.path.isfile(cert_old):
+ # adjust file permissions on source file,
+ # permissions will be copied by copy2()
+ os.chmod(cert_old, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
+ copy2(cert_old, cert_path_new)
+ # delete old certificate file
+ os.unlink(cert_old)
+
+ config.set(base_path + ['cert-file'], value=cert_new, replace=True)
+
+ #
+ # migrate key-file to new path
+ if config.exists(base_path + ['key-file']):
+ tmp = config.return_value(base_path + ['key-file'])
+ cert_old = cert_path_old + tmp
+ cert_new = cert_path_new + tmp
+
+ if os.path.isfile(cert_old):
+ # adjust file permissions on source file,
+ # permissions will be copied by copy2()
+ os.chmod(cert_old, S_IRUSR | S_IWUSR)
+ copy2(cert_old, cert_path_new)
+ # delete old certificate file
+ os.unlink(cert_old)
+
+ config.set(base_path + ['key-file'], value=cert_new, replace=True)
+
+ #
+ # check if old certificate directory exists but is empty
+ if os.path.isdir(cert_path_old) and not os.listdir(cert_path_old):
+ os.rmdir(cert_path_old)
diff --git a/src/migration-scripts/sstp/2-to-3 b/src/migration-scripts/sstp/2-to-3
new file mode 100644
index 0000000..4255a89
--- /dev/null
+++ b/src/migration-scripts/sstp/2-to-3
@@ -0,0 +1,59 @@
+# Copyright 2020-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/>.
+
+# - Rename SSTP ppp-settings node to ppp-options to make use of a common
+# Jinja Template to render Accel-PPP services
+
+from vyos.configtree import ConfigTree
+
+base_path = ['vpn', 'sstp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_path):
+ # Nothing to do
+ return
+
+ if config.exists(base_path + ['ppp-settings']):
+ config.rename(base_path + ['ppp-settings'], 'ppp-options')
+
+ config_ns = base_path + ['network-settings', 'name-server']
+ if config.exists(config_ns):
+ config.copy(config_ns, base_path + ['name-server'])
+ config.delete(config_ns)
+
+ config_mtu = base_path + ['network-settings', 'mtu']
+ if config.exists(config_mtu):
+ config.copy(config_mtu, base_path + ['mtu'])
+ config.delete(config_mtu)
+
+ config_gw = base_path + ['network-settings', 'client-ip-settings', 'gateway-address']
+ if config.exists(config_gw):
+ config.copy(config_gw, base_path + ['gateway-address'])
+ config.delete(config_gw)
+
+ config_client_ip = base_path + ['network-settings', 'client-ip-settings']
+ if config.exists(config_client_ip):
+ config.copy(config_client_ip, base_path + ['client-ip-pool'])
+ config.delete(config_client_ip)
+
+ config_client_ipv6 = base_path + ['network-settings', 'client-ipv6-pool']
+ if config.exists(config_client_ipv6):
+ config.copy(config_client_ipv6, base_path + ['client-ipv6-pool'])
+ config.delete(config_client_ipv6)
+
+ # all nodes now have been migrated out of network-settings - delete node
+ config_nw_settings = base_path + ['network-settings']
+ if config.exists(config_nw_settings):
+ config.delete(config_nw_settings)
diff --git a/src/migration-scripts/sstp/3-to-4 b/src/migration-scripts/sstp/3-to-4
new file mode 100644
index 0000000..fd10985
--- /dev/null
+++ b/src/migration-scripts/sstp/3-to-4
@@ -0,0 +1,116 @@
+# 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/>.
+
+# - Update SSL to use PKI configuration
+
+import os
+
+from vyos.configtree import ConfigTree
+from vyos.pki import load_certificate
+from vyos.pki import load_private_key
+from vyos.pki import encode_certificate
+from vyos.pki import encode_private_key
+from vyos.utils.process import run
+
+base = ['vpn', 'sstp']
+pki_base = ['pki']
+
+AUTH_DIR = '/config/auth'
+
+def wrapped_pem_to_config_value(pem):
+ return "".join(pem.strip().split("\n")[1:-1])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(base + ['ssl']):
+ return
+
+ x509_base = base + ['ssl']
+ pki_name = 'sstp'
+
+ if not config.exists(pki_base + ['ca']):
+ config.set(pki_base + ['ca'])
+ config.set_tag(pki_base + ['ca'])
+
+ if not config.exists(pki_base + ['certificate']):
+ config.set(pki_base + ['certificate'])
+ config.set_tag(pki_base + ['certificate'])
+
+ if config.exists(x509_base + ['ca-cert-file']):
+ cert_file = config.return_value(x509_base + ['ca-cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['ca-certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate CA certificate on sstp config')
+
+ config.delete(x509_base + ['ca-cert-file'])
+
+ if config.exists(x509_base + ['cert-file']):
+ cert_file = config.return_value(x509_base + ['cert-file'])
+ cert_path = os.path.join(AUTH_DIR, cert_file)
+ cert = None
+
+ if os.path.isfile(cert_path):
+ if not os.access(cert_path, os.R_OK):
+ run(f'sudo chmod 644 {cert_path}')
+
+ with open(cert_path, 'r') as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if cert:
+ cert_pem = encode_certificate(cert)
+ config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+ config.set(x509_base + ['certificate'], value=pki_name)
+ else:
+ print(f'Failed to migrate certificate on sstp config')
+
+ config.delete(x509_base + ['cert-file'])
+
+ if config.exists(x509_base + ['key-file']):
+ key_file = config.return_value(x509_base + ['key-file'])
+ key_path = os.path.join(AUTH_DIR, key_file)
+ key = None
+
+ if os.path.isfile(key_path):
+ if not os.access(key_path, os.R_OK):
+ run(f'sudo chmod 644 {key_path}')
+
+ with open(key_path, 'r') as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+ if key:
+ key_pem = encode_private_key(key, passphrase=None)
+ config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+ else:
+ print(f'Failed to migrate private key on sstp config')
+
+ config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/sstp/4-to-5 b/src/migration-scripts/sstp/4-to-5
new file mode 100644
index 0000000..254e828
--- /dev/null
+++ b/src/migration-scripts/sstp/4-to-5
@@ -0,0 +1,41 @@
+# Copyright 2023-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/>.
+
+# - move all pool to named pools
+# 'subnet' migrate to namedpool 'default-subnet-pool'
+# 'default-subnet-pool' is the next pool for 'default-range-pool'
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'sstp']
+pool_base = base + ['client-ip-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ range_pool_name = 'default-range-pool'
+
+ if config.exists(pool_base + ['subnet']):
+ default_pool = range_pool_name
+ for subnet in config.return_values(pool_base + ['subnet']):
+ config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+ config.delete(pool_base + ['subnet'])
+ config.set(base + ['default-pool'], value=default_pool)
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/sstp/5-to-6 b/src/migration-scripts/sstp/5-to-6
new file mode 100644
index 0000000..fc3cc29
--- /dev/null
+++ b/src/migration-scripts/sstp/5-to-6
@@ -0,0 +1,40 @@
+# Copyright 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/>.
+
+# Migrating to named ipv6 pools
+
+from vyos.configtree import ConfigTree
+
+base = ['vpn', 'sstp']
+pool_base = base + ['client-ipv6-pool']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if not config.exists(pool_base):
+ return
+
+ ipv6_pool_name = 'ipv6-pool'
+ config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+ if config.exists(pool_base + ['prefix']):
+ config.delete(pool_base + ['prefix'])
+ config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+ if config.exists(pool_base + ['delegate']):
+ config.delete(pool_base + ['delegate'])
+
+ # format as tag node
+ config.set_tag(pool_base)
diff --git a/src/migration-scripts/system/10-to-11 b/src/migration-scripts/system/10-to-11
new file mode 100644
index 0000000..76d7f23
--- /dev/null
+++ b/src/migration-scripts/system/10-to-11
@@ -0,0 +1,32 @@
+# Copyright 2019-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/>.
+
+# Operator accounts have been deprecated due to a security issue. Those accounts
+# will be converted to regular admin accounts.
+
+from vyos.configtree import ConfigTree
+
+base_level = ['system', 'login', 'user']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_level):
+ # Nothing to do, which shouldn't happen anyway
+ # only if you wipe the config and reboot.
+ return
+
+ for user in config.list_nodes(base_level):
+ if config.exists(base_level + [user, 'level']):
+ if config.return_value(base_level + [user, 'level']) == 'operator':
+ config.set(base_level + [user, 'level'], value="admin", replace=True)
diff --git a/src/migration-scripts/system/11-to-12 b/src/migration-scripts/system/11-to-12
new file mode 100644
index 0000000..71c359b
--- /dev/null
+++ b/src/migration-scripts/system/11-to-12
@@ -0,0 +1,69 @@
+# Copyright 2019-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/>.
+
+# Unclutter RADIUS configuration
+#
+# Move radius-server top level tag nodes to a regular node which allows us
+# to specify additional general features for the RADIUS client.
+
+from vyos.configtree import ConfigTree
+
+cfg_base = ['system', 'login']
+
+def migrate(config: ConfigTree) -> None:
+ if not (config.exists(cfg_base + ['radius-server']) or config.exists(cfg_base + ['radius-source-address'])):
+ # Nothing to do
+ return
+
+ #
+ # Migrate "system login radius-source-address" to "system login radius"
+ #
+ if config.exists(cfg_base + ['radius-source-address']):
+ address = config.return_value(cfg_base + ['radius-source-address'])
+ # delete old configuration node
+ config.delete(cfg_base + ['radius-source-address'])
+ # write new configuration node
+ config.set(cfg_base + ['radius', 'source-address'], value=address)
+
+ #
+ # Migrate "system login radius-server" tag node to new
+ # "system login radius server" tag node and also rename the "secret" node to "key"
+ #
+ if config.exists(cfg_base + ['radius-server']):
+ for server in config.list_nodes(cfg_base + ['radius-server']):
+ base_server = cfg_base + ['radius-server', server]
+ # "key" node is mandatory
+ key = config.return_value(base_server + ['secret'])
+ config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
+
+ # "port" is optional
+ if config.exists(base_server + ['port']):
+ port = config.return_value(base_server + ['port'])
+ config.set(cfg_base + ['radius', 'server', server, 'port'], value=port)
+
+ # "timeout is optional"
+ if config.exists(base_server + ['timeout']):
+ timeout = config.return_value(base_server + ['timeout'])
+ config.set(cfg_base + ['radius', 'server', server, 'timeout'], value=timeout)
+
+ # format as tag node
+ config.set_tag(cfg_base + ['radius', 'server'])
+
+ # delete old configuration node
+ config.delete(base_server)
+
+ # delete top level tag node
+ if config.exists(cfg_base + ['radius-server']):
+ config.delete(cfg_base + ['radius-server'])
diff --git a/src/migration-scripts/system/12-to-13 b/src/migration-scripts/system/12-to-13
new file mode 100644
index 0000000..014edba
--- /dev/null
+++ b/src/migration-scripts/system/12-to-13
@@ -0,0 +1,44 @@
+# Copyright 2019-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/>.
+
+# converts 'set system syslog host <address>:<port>'
+# to 'set system syslog host <address> port <port>'
+
+import re
+
+from vyos.configtree import ConfigTree
+
+cbase = ['system', 'syslog', 'host']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cbase):
+ return
+
+ for host in config.list_nodes(cbase):
+ if re.search(':[0-9]{1,5}$',host):
+ h = re.search('^[a-zA-Z\-0-9\.]+', host).group(0)
+ p = re.sub(':', '', re.search(':[0-9]+$', host).group(0))
+ config.set(cbase + [h])
+ config.set(cbase + [h, 'port'], value=p)
+ for fac in config.list_nodes(cbase + [host, 'facility']):
+ config.set(cbase + [h, 'facility', fac])
+ config.set_tag(cbase + [h, 'facility'])
+ if config.exists(cbase + [host, 'facility', fac, 'protocol']):
+ proto = config.return_value(cbase + [host, 'facility', fac, 'protocol'])
+ config.set(cbase + [h, 'facility', fac, 'protocol'], value=proto)
+ if config.exists(cbase + [host, 'facility', fac, 'level']):
+ lvl = config.return_value(cbase + [host, 'facility', fac, 'level'])
+ config.set(cbase + [h, 'facility', fac, 'level'], value=lvl)
+ config.delete(cbase + [host])
diff --git a/src/migration-scripts/system/13-to-14 b/src/migration-scripts/system/13-to-14
new file mode 100644
index 0000000..fbbecbc
--- /dev/null
+++ b/src/migration-scripts/system/13-to-14
@@ -0,0 +1,67 @@
+# Copyright 2019-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/>.
+
+# Fixup non existent time-zones. Some systems have time-zone set to: Los*
+# (Los_Angeles), Den* (Denver), New* (New_York) ... but those are no real IANA
+# assigned time zones. In the past they have been silently remapped.
+#
+# Time to clean it up!
+#
+# Migrate all configured timezones to real IANA assigned timezones!
+
+import re
+
+from vyos.configtree import ConfigTree
+from vyos.utils.process import cmd
+
+
+tz_base = ['system', 'time-zone']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(tz_base):
+ # Nothing to do
+ return
+
+ tz = config.return_value(tz_base)
+
+ # retrieve all valid timezones
+ try:
+ tz_datas = cmd('timedatectl list-timezones')
+ except OSError:
+ tz_datas = ''
+ tz_data = tz_datas.split('\n')
+
+ if re.match(r'[Ll][Oo][Ss].+', tz):
+ tz = 'America/Los_Angeles'
+ elif re.match(r'[Dd][Ee][Nn].+', tz):
+ tz = 'America/Denver'
+ elif re.match(r'[Hh][Oo][Nn][Oo].+', tz):
+ tz = 'Pacific/Honolulu'
+ elif re.match(r'[Nn][Ee][Ww].+', tz):
+ tz = 'America/New_York'
+ elif re.match(r'[Cc][Hh][Ii][Cc]*.+', tz):
+ tz = 'America/Chicago'
+ elif re.match(r'[Aa][Nn][Cc].+', tz):
+ tz = 'America/Anchorage'
+ elif re.match(r'[Pp][Hh][Oo].+', tz):
+ tz = 'America/Phoenix'
+ elif re.match(r'GMT(.+)?', tz):
+ tz = 'Etc/' + tz
+ elif tz not in tz_data:
+ # assign default UTC timezone
+ tz = 'UTC'
+
+ # replace timezone data is required
+ config.set(tz_base, value=tz)
diff --git a/src/migration-scripts/system/14-to-15 b/src/migration-scripts/system/14-to-15
new file mode 100644
index 0000000..2818094
--- /dev/null
+++ b/src/migration-scripts/system/14-to-15
@@ -0,0 +1,37 @@
+# Copyright 2019-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/>.
+
+# Delete 'system ipv6 blacklist' option as the IPv6 module can no longer be
+# blacklisted as it is required by e.g. WireGuard and thus will always be
+# loaded.
+
+import os
+
+ipv6_blacklist_file = '/etc/modprobe.d/vyatta_blacklist_ipv6.conf'
+
+from vyos.configtree import ConfigTree
+
+ip_base = ['system', 'ipv6']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(ip_base):
+ # Nothing to do
+ return
+
+ # delete 'system ipv6 blacklist' node
+ if config.exists(ip_base + ['blacklist']):
+ config.delete(ip_base + ['blacklist'])
+ if os.path.isfile(ipv6_blacklist_file):
+ os.unlink(ipv6_blacklist_file)
diff --git a/src/migration-scripts/system/15-to-16 b/src/migration-scripts/system/15-to-16
new file mode 100644
index 0000000..7db0429
--- /dev/null
+++ b/src/migration-scripts/system/15-to-16
@@ -0,0 +1,32 @@
+# Copyright 2019-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/>.
+
+# Make 'system options reboot-on-panic' valueless
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'options']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['reboot-on-panic']):
+ reboot = config.return_value(base + ['reboot-on-panic'])
+ config.delete(base + ['reboot-on-panic'])
+ # create new valueless node if action was true
+ if reboot == "true":
+ config.set(base + ['reboot-on-panic'])
diff --git a/src/migration-scripts/system/16-to-17 b/src/migration-scripts/system/16-to-17
new file mode 100644
index 0000000..9fb86af
--- /dev/null
+++ b/src/migration-scripts/system/16-to-17
@@ -0,0 +1,36 @@
+# Copyright 2020-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/>.
+
+# * remove "system login user <user> group" node, Why should be add a user to a
+# 3rd party group when the system is fully managed by CLI?
+# * remove "system login user <user> level" node
+# This is the only privilege level left and also the default, what is the
+# sense in keeping this orphaned node?
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'login', 'user']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for user in config.list_nodes(base):
+ if config.exists(base + [user, 'group']):
+ config.delete(base + [user, 'group'])
+
+ if config.exists(base + [user, 'level']):
+ config.delete(base + [user, 'level'])
diff --git a/src/migration-scripts/system/17-to-18 b/src/migration-scripts/system/17-to-18
new file mode 100644
index 0000000..323ef4e
--- /dev/null
+++ b/src/migration-scripts/system/17-to-18
@@ -0,0 +1,59 @@
+# Copyright 2020-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/>.
+
+# remove "system console netconsole"
+# remove "system console device <device> modem"
+
+import os
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'console']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # remove "system console netconsole" (T2561)
+ if config.exists(base + ['netconsole']):
+ config.delete(base + ['netconsole'])
+
+ if config.exists(base + ['device']):
+ for device in config.list_nodes(base + ['device']):
+ dev_path = base + ['device', device]
+ # remove "system console device <device> modem" (T2570)
+ if config.exists(dev_path + ['modem']):
+ config.delete(dev_path + ['modem'])
+
+ # Only continue on USB based serial consoles
+ if not 'ttyUSB' in device:
+ continue
+
+ # A serial console has been configured but it does no longer
+ # exist on the system - cleanup
+ if not os.path.exists(f'/dev/{device}'):
+ config.delete(dev_path)
+ continue
+
+ # migrate from ttyUSB device to new device in /dev/serial/by-bus
+ for root, dirs, files in os.walk('/dev/serial/by-bus'):
+ for usb_device in files:
+ device_file = os.path.realpath(os.path.join(root, usb_device))
+ # migrate to new USB device names (T2529)
+ if os.path.basename(device_file) == device:
+ config.copy(dev_path, base + ['device', usb_device])
+ # Delete old USB node from config
+ config.delete(dev_path)
diff --git a/src/migration-scripts/system/18-to-19 b/src/migration-scripts/system/18-to-19
new file mode 100644
index 0000000..5d9788d
--- /dev/null
+++ b/src/migration-scripts/system/18-to-19
@@ -0,0 +1,81 @@
+# Copyright 2020-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 disable-dhcp-nameservers (boolean) to name-servers-dhcp <interface>
+# if disable-dhcp-nameservers is set, just remove it
+# else retrieve all interface names that have configured dhcp(v6) address and
+# add them to the new name-servers-dhcp node
+
+from vyos.ifconfig import Interface
+from vyos.configtree import ConfigTree
+
+base = ['system']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['disable-dhcp-nameservers']):
+ config.delete(base + ['disable-dhcp-nameservers'])
+ else:
+ dhcp_interfaces = []
+
+ # go through all interfaces searching for 'address dhcp(v6)?'
+ for sect in Interface.sections():
+ sect_base = ['interfaces', sect]
+
+ if not config.exists(sect_base):
+ continue
+
+ for intf in config.list_nodes(sect_base):
+ intf_base = sect_base + [intf]
+
+ # try without vlans
+ if config.exists(intf_base + ['address']):
+ for addr in config.return_values(intf_base + ['address']):
+ if addr in ['dhcp', 'dhcpv6']:
+ dhcp_interfaces.append(intf)
+
+ # try vif
+ if config.exists(intf_base + ['vif']):
+ for vif in config.list_nodes(intf_base + ['vif']):
+ vif_base = intf_base + ['vif', vif]
+ if config.exists(vif_base + ['address']):
+ for addr in config.return_values(vif_base + ['address']):
+ if addr in ['dhcp', 'dhcpv6']:
+ dhcp_interfaces.append(f'{intf}.{vif}')
+
+ # try vif-s
+ if config.exists(intf_base + ['vif-s']):
+ for vif_s in config.list_nodes(intf_base + ['vif-s']):
+ vif_s_base = intf_base + ['vif-s', vif_s]
+ if config.exists(vif_s_base + ['address']):
+ for addr in config.return_values(vif_s_base + ['address']):
+ if addr in ['dhcp', 'dhcpv6']:
+ dhcp_interfaces.append(f'{intf}.{vif_s}')
+
+ # try vif-c
+ if config.exists(intf_base + ['vif-c']):
+ for vif_c in config.list_nodes(vif_s_base + ['vif-c']):
+ vif_c_base = vif_s_base + ['vif-c', vif_c]
+ if config.exists(vif_c_base + ['address']):
+ for addr in config.return_values(vif_c_base + ['address']):
+ if addr in ['dhcp', 'dhcpv6']:
+ dhcp_interfaces.append(f'{intf}.{vif_s}.{vif_c}')
+
+ # set new config nodes
+ for intf in dhcp_interfaces:
+ config.set(base + ['name-servers-dhcp'], value=intf, replace=False)
diff --git a/src/migration-scripts/system/19-to-20 b/src/migration-scripts/system/19-to-20
new file mode 100644
index 0000000..cb84e11
--- /dev/null
+++ b/src/migration-scripts/system/19-to-20
@@ -0,0 +1,44 @@
+# Copyright 2020-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/>.
+
+# T3048: remove smp-affinity node from ethernet and use tuned instead
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'options']
+base_new = ['system', 'option']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base_new):
+ for node in config.list_nodes(base):
+ config.copy(base + [node], base_new + [node])
+ else:
+ config.copy(base, base_new)
+
+ config.delete(base)
+
+ # Rename "system option beep-if-fully-booted" -> "system option startup-beep"
+ base_beep = base_new + ['beep-if-fully-booted']
+ if config.exists(base_beep):
+ config.rename(base_beep, 'startup-beep')
+
+ # Rename "system option ctrl-alt-del-action" -> "system option ctrl-alt-delete"
+ base_ctrl_alt_del = base_new + ['ctrl-alt-del-action']
+ if config.exists(base_ctrl_alt_del):
+ config.rename(base_ctrl_alt_del, 'ctrl-alt-delete')
diff --git a/src/migration-scripts/system/20-to-21 b/src/migration-scripts/system/20-to-21
new file mode 100644
index 0000000..71c283d
--- /dev/null
+++ b/src/migration-scripts/system/20-to-21
@@ -0,0 +1,30 @@
+# 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/>.
+
+# T3795: merge "system name-servers-dhcp" into "system name-server"
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'name-servers-dhcp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for interface in config.return_values(base):
+ config.set(['system', 'name-server'], value=interface, replace=False)
+
+ config.delete(base)
diff --git a/src/migration-scripts/system/21-to-22 b/src/migration-scripts/system/21-to-22
new file mode 100644
index 0000000..0e68a68
--- /dev/null
+++ b/src/migration-scripts/system/21-to-22
@@ -0,0 +1,38 @@
+# 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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'sysctl']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for all_custom in ['all', 'custom']:
+ if config.exists(base + [all_custom]):
+ for key in config.list_nodes(base + [all_custom]):
+ tmp = config.return_value(base + [all_custom, key, 'value'])
+ config.set(base + ['parameter', key, 'value'], value=tmp)
+ config.set_tag(base + ['parameter'])
+ config.delete(base + [all_custom])
+
+ for ipv4_param in ['net.ipv4.igmp_max_memberships', 'net.ipv4.ipfrag_time']:
+ if config.exists(base + [ipv4_param]):
+ tmp = config.return_value(base + [ipv4_param])
+ config.set(base + ['parameter', ipv4_param, 'value'], value=tmp)
+ config.set_tag(base + ['parameter'])
+ config.delete(base + [ipv4_param])
diff --git a/src/migration-scripts/system/22-to-23 b/src/migration-scripts/system/22-to-23
new file mode 100644
index 0000000..e49094e
--- /dev/null
+++ b/src/migration-scripts/system/22-to-23
@@ -0,0 +1,31 @@
+# Copyright 2022-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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'ipv6']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # T4346: drop support to disbale IPv6 address family within the OS Kernel
+ if config.exists(base + ['disable']):
+ config.delete(base + ['disable'])
+ # IPv6 address family disable was the only CLI option set - we can cleanup
+ # the entire tree
+ if len(config.list_nodes(base)) == 0:
+ config.delete(base)
diff --git a/src/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24
new file mode 100644
index 0000000..feb62bc
--- /dev/null
+++ b/src/migration-scripts/system/23-to-24
@@ -0,0 +1,71 @@
+# Copyright 2022-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/>.
+
+from ipaddress import ip_interface
+from ipaddress import ip_address
+
+from vyos.configtree import ConfigTree
+from vyos.template import is_ipv4
+
+base = ['protocols', 'static', 'arp']
+tmp_base = ['protocols', 'static', 'arp-tmp']
+
+def fixup_cli(config, path, interface, host):
+ if config.exists(path + ['address']):
+ for address in config.return_values(path + ['address']):
+ tmp = ip_interface(address)
+ # ARP is only available for IPv4 ;-)
+ if not is_ipv4(tmp):
+ continue
+ if ip_address(host) in tmp.network.hosts():
+ mac = config.return_value(tmp_base + [host, 'hwaddr'])
+ iface_path = ['protocols', 'static', 'arp', 'interface']
+ config.set(iface_path + [interface, 'address', host, 'mac'], value=mac)
+ config.set_tag(iface_path)
+ config.set_tag(iface_path + [interface, 'address'])
+ continue
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # We need a temporary copy of the config tree as the original one needs to be
+ # deleted first due to a change iun thge tagNode structure.
+ config.copy(base, tmp_base)
+ config.delete(base)
+
+ for host in config.list_nodes(tmp_base):
+ for type in config.list_nodes(['interfaces']):
+ for interface in config.list_nodes(['interfaces', type]):
+ if_base = ['interfaces', type, interface]
+ fixup_cli(config, if_base, interface, host)
+
+ if config.exists(if_base + ['vif']):
+ for vif in config.list_nodes(if_base + ['vif']):
+ vif_base = ['interfaces', type, interface, 'vif', vif]
+ fixup_cli(config, vif_base, f'{interface}.{vif}', host)
+
+ if config.exists(if_base + ['vif-s']):
+ for vif_s in config.list_nodes(if_base + ['vif-s']):
+ vif_s_base = ['interfaces', type, interface, 'vif-s', vif_s]
+ fixup_cli(config, vif_s_base, f'{interface}.{vif_s}', host)
+
+ if config.exists(if_base + ['vif-s', vif_s, 'vif-c']):
+ for vif_c in config.list_nodes(if_base + ['vif-s', vif_s, 'vif-c']):
+ vif_c_base = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c', vif_c]
+ fixup_cli(config, vif_c_base, f'{interface}.{vif_s}.{vif_c}', host)
+
+ config.delete(tmp_base)
diff --git a/src/migration-scripts/system/24-to-25 b/src/migration-scripts/system/24-to-25
new file mode 100644
index 0000000..bdb8990
--- /dev/null
+++ b/src/migration-scripts/system/24-to-25
@@ -0,0 +1,35 @@
+# Copyright 2022-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 syslog global archive to system logs logrotate messages
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'syslog', 'global', 'archive']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['file']):
+ tmp = config.return_value(base + ['file'])
+ config.set(['system', 'logs', 'logrotate', 'messages', 'rotate'], value=tmp)
+
+ if config.exists(base + ['size']):
+ tmp = config.return_value(base + ['size'])
+ tmp = max(round(int(tmp) / 1024), 1) # kb -> mb
+ config.set(['system', 'logs', 'logrotate', 'messages', 'max-size'], value=tmp)
+
+ config.delete(base)
diff --git a/src/migration-scripts/system/25-to-26 b/src/migration-scripts/system/25-to-26
new file mode 100644
index 0000000..8832f48
--- /dev/null
+++ b/src/migration-scripts/system/25-to-26
@@ -0,0 +1,65 @@
+# Copyright 2023-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/>.
+
+# syslog: migrate deprecated CLI options
+# - protocols -> local7
+# - security -> auth
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'syslog']
+
+def rename_facilities(config, base_tree, facility, facility_new) -> None:
+ if config.exists(base + [base_tree, 'facility', facility]):
+ # do not overwrite already existing replacement facility
+ if not config.exists(base + [base_tree, 'facility', facility_new]):
+ config.rename(base + [base_tree, 'facility', facility], facility_new)
+ else:
+ # delete old duplicate facility config
+ config.delete(base + [base_tree, 'facility', facility])
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ #
+ # Rename protocols and securityy facility to common ones
+ #
+ replace = {
+ 'protocols' : 'local7',
+ 'security' : 'auth'
+ }
+ for facility, facility_new in replace.items():
+ rename_facilities(config, 'console', facility, facility_new)
+ rename_facilities(config, 'global', facility, facility_new)
+
+ if config.exists(base + ['host']):
+ for host in config.list_nodes(base + ['host']):
+ rename_facilities(config, f'host {host}', facility, facility_new)
+
+ #
+ # It makes no sense to configure udp/tcp transport per individual facility
+ #
+ if config.exists(base + ['host']):
+ for host in config.list_nodes(base + ['host']):
+ protocol = None
+ for facility in config.list_nodes(base + ['host', host, 'facility']):
+ tmp_path = base + ['host', host, 'facility', facility, 'protocol']
+ if config.exists(tmp_path):
+ # We can only change the first one
+ if protocol == None:
+ protocol = config.return_value(tmp_path)
+ config.set(base + ['host', host, 'protocol'], value=protocol)
+ config.delete(tmp_path)
diff --git a/src/migration-scripts/system/26-to-27 b/src/migration-scripts/system/26-to-27
new file mode 100644
index 0000000..499e16e
--- /dev/null
+++ b/src/migration-scripts/system/26-to-27
@@ -0,0 +1,30 @@
+# Copyright 2023-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/>.
+
+# T5877: migrate 'system domain-search domain' to 'system domain-search'
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'domain-search']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+
+ if config.exists(base + ['domain']):
+ entries = config.return_values(base + ['domain'])
+ config.delete(base + ['domain'])
+ for entry in entries:
+ config.set(base, value=entry, replace=False)
diff --git a/src/migration-scripts/system/6-to-7 b/src/migration-scripts/system/6-to-7
new file mode 100644
index 0000000..e91ccc4
--- /dev/null
+++ b/src/migration-scripts/system/6-to-7
@@ -0,0 +1,36 @@
+# Copyright 2019-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/>.
+
+# Change smp_affinity to smp-affinity
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ intf_types = config.list_nodes(["interfaces"])
+
+ for intf_type in intf_types:
+ intf_type_path = ["interfaces", intf_type]
+ intfs = config.list_nodes(intf_type_path)
+
+ for intf in intfs:
+ intf_path = intf_type_path + [intf]
+ if not config.exists(intf_path + ["smp_affinity"]):
+ # Nothing to do.
+ continue
+ else:
+ # Rename the node.
+ old_smp_affinity_path = intf_path + ["smp_affinity"]
+ config.rename(old_smp_affinity_path, "smp-affinity")
+ update_required = True
diff --git a/src/migration-scripts/system/7-to-8 b/src/migration-scripts/system/7-to-8
new file mode 100644
index 0000000..64dd4dc
--- /dev/null
+++ b/src/migration-scripts/system/7-to-8
@@ -0,0 +1,39 @@
+# Copyright 2018-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/>.
+
+# Converts "system gateway-address" option to "protocols static route 0.0.0.0/0 next-hop $gw"
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['system', 'gateway-address']):
+ # Nothing to do
+ return
+
+ # Save the address
+ gw = config.return_value(['system', 'gateway-address'])
+
+ # Create the node for the new syntax
+ # Note: next-hop is a tag node, gateway address is its child, not a value
+ config.set(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop', gw])
+
+ # Delete the node with the old syntax
+ config.delete(['system', 'gateway-address'])
+
+ # Now, the interesting part. Both route and next-hop are supposed to be tag nodes,
+ # which you can verify with "cli-shell-api isTag $configPath".
+ # They must be formatted as such to load correctly.
+ config.set_tag(['protocols', 'static', 'route'])
+ config.set_tag(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop'])
diff --git a/src/migration-scripts/system/8-to-9 b/src/migration-scripts/system/8-to-9
new file mode 100644
index 0000000..ea5f7af
--- /dev/null
+++ b/src/migration-scripts/system/8-to-9
@@ -0,0 +1,26 @@
+# Copyright 2018-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/>.
+
+# Deletes "system package" option as it is deprecated
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(['system', 'package']):
+ # Nothing to do
+ return
+
+ # Delete the node with the old syntax
+ config.delete(['system', 'package'])
diff --git a/src/migration-scripts/vrf/0-to-1 b/src/migration-scripts/vrf/0-to-1
new file mode 100644
index 0000000..70abae2
--- /dev/null
+++ b/src/migration-scripts/vrf/0-to-1
@@ -0,0 +1,113 @@
+# 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/>.
+
+# - T2450: drop interface-route and interface-route6 from "protocols vrf"
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'vrf']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for vrf in config.list_nodes(base):
+ static_base = base + [vrf, 'static']
+ if not config.exists(static_base):
+ continue
+
+ #
+ # Migrate interface-route into route
+ #
+ interface_route_path = static_base + ['interface-route']
+ if config.exists(interface_route_path):
+ for route in config.list_nodes(interface_route_path):
+ interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
+
+ tmp = interface_route_path + [route, 'next-hop-interface']
+ for interface in config.list_nodes(tmp):
+ new_base = static_base + ['route', route, 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(tmp + [interface], new_base + [interface])
+
+ config.delete(interface_route_path)
+
+ #
+ # Migrate interface-route6 into route6
+ #
+ interface_route_path = static_base + ['interface-route6']
+ if config.exists(interface_route_path):
+ for route in config.list_nodes(interface_route_path):
+ interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
+
+ tmp = interface_route_path + [route, 'next-hop-interface']
+ for interface in config.list_nodes(tmp):
+ new_base = static_base + ['route6', route, 'interface']
+ config.set(new_base)
+ config.set_tag(new_base)
+ config.copy(tmp + [interface], new_base + [interface])
+
+ config.delete(interface_route_path)
+
+ #
+ # Cleanup nodes inside route
+ #
+ route_path = static_base + ['route']
+ if config.exists(route_path):
+ for route in config.list_nodes(route_path):
+ next_hop = route_path + [route, 'next-hop']
+ if config.exists(next_hop):
+ for gateway in config.list_nodes(next_hop):
+ interface_path = next_hop + [gateway, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+ vrf_path = next_hop + [gateway, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
+
+ next_hop = route_path + [route, 'interface']
+ if config.exists(next_hop):
+ for interface in config.list_nodes(next_hop):
+ interface_path = next_hop + [interface, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+ vrf_path = next_hop + [interface, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
+
+ #
+ # Cleanup nodes inside route6
+ #
+ route_path = static_base + ['route6']
+ if config.exists(route_path):
+ for route in config.list_nodes(route_path):
+ next_hop = route_path + [route, 'next-hop']
+ if config.exists(next_hop):
+ for gateway in config.list_nodes(next_hop):
+ vrf_path = next_hop + [gateway, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
+
+ next_hop = route_path + [route, 'interface']
+ if config.exists(next_hop):
+ for interface in config.list_nodes(next_hop):
+ interface_path = next_hop + [interface, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+ vrf_path = next_hop + [interface, 'next-hop-vrf']
+ if config.exists(vrf_path):
+ config.rename(vrf_path, 'vrf')
diff --git a/src/migration-scripts/vrf/1-to-2 b/src/migration-scripts/vrf/1-to-2
new file mode 100644
index 0000000..557a9ec
--- /dev/null
+++ b/src/migration-scripts/vrf/1-to-2
@@ -0,0 +1,43 @@
+# 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/>.
+
+# - T3344: migrate routing options from "protocols vrf" to "vrf <name> protocols"
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'vrf']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ vrf_base = ['vrf', 'name']
+ config.set(vrf_base)
+ config.set_tag(vrf_base)
+
+ # Copy all existing static routes to the new base node under "vrf name <name> protocols static"
+ for vrf in config.list_nodes(base):
+ static_base = base + [vrf, 'static']
+ if not config.exists(static_base):
+ continue
+
+ new_static_base = vrf_base + [vrf, 'protocols']
+ config.set(new_static_base)
+ config.copy(static_base, new_static_base + ['static'])
+ config.set_tag(new_static_base + ['static', 'route'])
+
+ # Now delete the old configuration
+ config.delete(base)
diff --git a/src/migration-scripts/vrf/2-to-3 b/src/migration-scripts/vrf/2-to-3
new file mode 100644
index 0000000..acacffb
--- /dev/null
+++ b/src/migration-scripts/vrf/2-to-3
@@ -0,0 +1,125 @@
+# 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/>.
+
+# Since connection tracking zones are int16, VRFs tables maximum value must
+# be limited to 65535
+# Also, interface names in nftables cannot start from numbers,
+# so VRF name should not start from a number
+
+from random import randrange
+from random import choice
+from string import ascii_lowercase
+from vyos.configtree import ConfigTree
+import re
+
+
+# Helper function to find all config items with a VRF name
+def _search_vrfs(config_commands, vrf_name):
+ vrf_values = []
+ # Regex to find path of config command with old VRF
+ regex_filter = re.compile(rf'^set (?P<cmd_path>[^\']+vrf) \'{vrf_name}\'$')
+ # Check each command for VRF value
+ for config_command in config_commands:
+ search_result = regex_filter.search(config_command)
+ if search_result:
+ # Append VRF command to a list
+ vrf_values.append(search_result.group('cmd_path').split())
+ if vrf_values:
+ return vrf_values
+ else:
+ return None
+
+
+# Helper function to find all config items with a table number
+def _search_tables(config_commands, table_num):
+ table_items = {'table_tags': [], 'table_values': []}
+ # Regex to find values and nodes with a table number
+ regex_tags = re.compile(rf'^set (?P<cmd_path>[^\']+table {table_num}) ?.*$')
+ regex_values = re.compile(
+ rf'^set (?P<cmd_path>[^\']+table) \'{table_num}\'$')
+ for config_command in config_commands:
+ # Search for tag nodes
+ search_result = regex_tags.search(config_command)
+ if search_result:
+ # Append table node path to a tag nodes list
+ cmd_path = search_result.group('cmd_path').split()
+ if cmd_path not in table_items['table_tags']:
+ table_items['table_tags'].append(cmd_path)
+ # Search for value nodes
+ search_result = regex_values.search(config_command)
+ if search_result:
+ # Append table node path to a value nodes list
+ table_items['table_values'].append(
+ search_result.group('cmd_path').split())
+ return table_items
+
+
+base = ['vrf', 'name']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ # Get a list of all currently used VRFs and tables
+ vrfs_current = {}
+ for vrf in config.list_nodes(base):
+ vrfs_current[vrf] = int(config.return_value(base + [vrf, 'table']))
+
+ # Check VRF names and table numbers
+ name_regex = re.compile(r'^\d.*$')
+ for vrf_name, vrf_table in vrfs_current.items():
+ # Check table number
+ if vrf_table > 65535:
+ # Find new unused table number
+ vrfs_current[vrf_name] = None
+ while not vrfs_current[vrf_name]:
+ table_random = randrange(100, 65535)
+ if table_random not in vrfs_current.values():
+ vrfs_current[vrf_name] = table_random
+ # Update number to a new one
+ config.set(['vrf', 'name', vrf_name, 'table'],
+ vrfs_current[vrf_name],
+ replace=True)
+ # Check config items with old table number and replace to new one
+ config_commands = config.to_commands().split('\n')
+ table_config_lines = _search_tables(config_commands, vrf_table)
+ # Rename table nodes
+ if table_config_lines.get('table_tags'):
+ for table_config_path in table_config_lines.get('table_tags'):
+ config.rename(table_config_path, f'{vrfs_current[vrf_name]}')
+ # Replace table values
+ if table_config_lines.get('table_values'):
+ for table_config_path in table_config_lines.get('table_values'):
+ config.set(table_config_path,
+ f'{vrfs_current[vrf_name]}',
+ replace=True)
+
+ # Check VRF name
+ if name_regex.match(vrf_name):
+ vrf_name_new = None
+ while not vrf_name_new:
+ vrf_name_rand = f'{choice(ascii_lowercase)}{vrf_name}'[:15]
+ if vrf_name_rand not in vrfs_current:
+ vrf_name_new = vrf_name_rand
+ # Update VRF name to a new one
+ config.rename(['vrf', 'name', vrf_name], vrf_name_new)
+ # Check config items with old VRF name and replace to new one
+ config_commands = config.to_commands().split('\n')
+ vrf_config_lines = _search_vrfs(config_commands, vrf_name)
+ # Rename VRF to a new name
+ if vrf_config_lines:
+ for vrf_value_path in vrf_config_lines:
+ config.set(vrf_value_path, vrf_name_new, replace=True)
diff --git a/src/migration-scripts/vrrp/1-to-2 b/src/migration-scripts/vrrp/1-to-2
new file mode 100644
index 0000000..8639a75
--- /dev/null
+++ b/src/migration-scripts/vrrp/1-to-2
@@ -0,0 +1,250 @@
+# Copyright 2018-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/>.
+
+import re
+
+from vyos.configtree import ConfigTree
+
+
+# Convert the old VRRP syntax to the new syntax
+
+# The old approach was to put VRRP groups inside interfaces,
+# as in "interfaces ethernet eth0 vrrp vrrp-group 10 ...".
+# It was supported only under ethernet and bonding and their
+# respective vif, vif-s, and vif-c subinterfaces
+
+def get_vrrp_group(path):
+ group = {"preempt": True, "rfc_compatibility": False, "disable": False}
+
+ if config.exists(path + ["advertise-interval"]):
+ group["advertise_interval"] = config.return_value(path + ["advertise-interval"])
+
+ if config.exists(path + ["description"]):
+ group["description"] = config.return_value(path + ["description"])
+
+ if config.exists(path + ["disable"]):
+ group["disable"] = True
+
+ if config.exists(path + ["hello-source-address"]):
+ group["hello_source"] = config.return_value(path + ["hello-source-address"])
+
+ # 1.1.8 didn't have it, but earlier 1.2.0 did, we don't want to break
+ # configs of early adopters!
+ if config.exists(path + ["peer-address"]):
+ group["peer_address"] = config.return_value(path + ["peer-address"])
+
+ if config.exists(path + ["preempt"]):
+ preempt = config.return_value(path + ["preempt"])
+ if preempt == "false":
+ group["preempt"] = False
+
+ if config.exists(path + ["rfc3768-compatibility"]):
+ group["rfc_compatibility"] = True
+
+ if config.exists(path + ["preempt-delay"]):
+ group["preempt_delay"] = config.return_value(path + ["preempt-delay"])
+
+ if config.exists(path + ["priority"]):
+ group["priority"] = config.return_value(path + ["priority"])
+
+ if config.exists(path + ["sync-group"]):
+ group["sync_group"] = config.return_value(path + ["sync-group"])
+
+ if config.exists(path + ["authentication", "type"]):
+ group["auth_type"] = config.return_value(path + ["authentication", "type"])
+
+ if config.exists(path + ["authentication", "password"]):
+ group["auth_password"] = config.return_value(path + ["authentication", "password"])
+
+ if config.exists(path + ["virtual-address"]):
+ group["virtual_addresses"] = config.return_values(path + ["virtual-address"])
+
+ if config.exists(path + ["run-transition-scripts"]):
+ if config.exists(path + ["run-transition-scripts", "master"]):
+ group["master_script"] = config.return_value(path + ["run-transition-scripts", "master"])
+ if config.exists(path + ["run-transition-scripts", "backup"]):
+ group["backup_script"] = config.return_value(path + ["run-transition-scripts", "backup"])
+ if config.exists(path + ["run-transition-scripts", "fault"]):
+ group["fault_script"] = config.return_value(path + ["run-transition-scripts", "fault"])
+
+ # Also not present in 1.1.8, but supported by earlier 1.2.0
+ if config.exists(path + ["health-check"]):
+ if config.exists(path + ["health-check", "interval"]):
+ group["health_check_interval"] = config.return_value(path + ["health-check", "interval"])
+ if config.exists(path + ["health-check", "failure-count"]):
+ group["health_check_count"] = config.return_value(path + ["health-check", "failure-count"])
+ if config.exists(path + ["health-check", "script"]):
+ group["health_check_script"] = config.return_value(path + ["health-check", "script"])
+
+ return group
+
+# Since VRRP is all over the place, there's no way to just check a path and exit early
+# if it doesn't exist, we have to walk all interfaces and collect VRRP settings from them.
+# Only if no data is collected from any interface we can conclude that VRRP is not configured
+# and exit.
+
+def migrate(config: ConfigTree) -> None:
+ groups = []
+ base_paths = []
+
+ if config.exists(["interfaces", "ethernet"]):
+ base_paths.append("ethernet")
+ if config.exists(["interfaces", "bonding"]):
+ base_paths.append("bonding")
+
+ for bp in base_paths:
+ parent_path = ["interfaces", bp]
+
+ parent_intfs = config.list_nodes(parent_path)
+
+ for pi in parent_intfs:
+ # Extract VRRP groups from the parent interface
+ vg_path =[pi, "vrrp", "vrrp-group"]
+ if config.exists(parent_path + vg_path):
+ pgroups = config.list_nodes(parent_path + vg_path)
+ for pg in pgroups:
+ g = get_vrrp_group(parent_path + vg_path + [pg])
+ g["interface"] = pi
+ g["vrid"] = pg
+ groups.append(g)
+
+ # Delete the VRRP subtree
+ # If left in place, configs will not load correctly
+ config.delete(parent_path + [pi, "vrrp"])
+
+ # Extract VRRP groups from 802.1q VLAN interfaces
+ if config.exists(parent_path + [pi, "vif"]):
+ vifs = config.list_nodes(parent_path + [pi, "vif"])
+ for vif in vifs:
+ vif_vg_path = [pi, "vif", vif, "vrrp", "vrrp-group"]
+ if config.exists(parent_path + vif_vg_path):
+ vifgroups = config.list_nodes(parent_path + vif_vg_path)
+ for vif_group in vifgroups:
+ g = get_vrrp_group(parent_path + vif_vg_path + [vif_group])
+ g["interface"] = "{0}.{1}".format(pi, vif)
+ g["vrid"] = vif_group
+ groups.append(g)
+
+ config.delete(parent_path + [pi, "vif", vif, "vrrp"])
+
+ # Extract VRRP groups from 802.3ad QinQ service VLAN interfaces
+ if config.exists(parent_path + [pi, "vif-s"]):
+ vif_ss = config.list_nodes(parent_path + [pi, "vif-s"])
+ for vif_s in vif_ss:
+ vifs_vg_path = [pi, "vif-s", vif_s, "vrrp", "vrrp-group"]
+ if config.exists(parent_path + vifs_vg_path):
+ vifsgroups = config.list_nodes(parent_path + vifs_vg_path)
+ for vifs_group in vifsgroups:
+ g = get_vrrp_group(parent_path + vifs_vg_path + [vifs_group])
+ g["interface"] = "{0}.{1}".format(pi, vif_s)
+ g["vrid"] = vifs_group
+ groups.append(g)
+
+ config.delete(parent_path + [pi, "vif-s", vif_s, "vrrp"])
+
+ # Extract VRRP groups from QinQ client VLAN interfaces nested in the vif-s
+ if config.exists(parent_path + [pi, "vif-s", vif_s, "vif-c"]):
+ vif_cs = config.list_nodes(parent_path + [pi, "vif-s", vif_s, "vif-c"])
+ for vif_c in vif_cs:
+ vifc_vg_path = [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp", "vrrp-group"]
+ vifcgroups = config.list_nodes(parent_path + vifc_vg_path)
+ for vifc_group in vifcgroups:
+ g = get_vrrp_group(parent_path + vifc_vg_path + [vifc_group])
+ g["interface"] = "{0}.{1}.{2}".format(pi, vif_s, vif_c)
+ g["vrid"] = vifc_group
+ groups.append(g)
+
+ config.delete(parent_path + [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp"])
+
+ # If nothing was collected before this point, it means the config has no VRRP setup
+ if not groups:
+ return
+
+ # Otherwise, there is VRRP to convert
+
+ # Now convert the collected groups to the new syntax
+ base_group_path = ["high-availability", "vrrp", "group"]
+ sync_path = ["high-availability", "vrrp", "sync-group"]
+
+ for g in groups:
+ group_name = "{0}-{1}".format(g["interface"], g["vrid"])
+ group_path = base_group_path + [group_name]
+
+ config.set(group_path + ["interface"], value=g["interface"])
+ config.set(group_path + ["vrid"], value=g["vrid"])
+
+ if "advertise_interval" in g:
+ config.set(group_path + ["advertise-interval"], value=g["advertise_interval"])
+
+ if "priority" in g:
+ config.set(group_path + ["priority"], value=g["priority"])
+
+ if not g["preempt"]:
+ config.set(group_path + ["no-preempt"], value=None)
+
+ if "preempt_delay" in g:
+ config.set(group_path + ["preempt-delay"], value=g["preempt_delay"])
+
+ if g["rfc_compatibility"]:
+ config.set(group_path + ["rfc3768-compatibility"], value=None)
+
+ if g["disable"]:
+ config.set(group_path + ["disable"], value=None)
+
+ if "hello_source" in g:
+ config.set(group_path + ["hello-source-address"], value=g["hello_source"])
+
+ if "peer_address" in g:
+ config.set(group_path + ["peer-address"], value=g["peer_address"])
+
+ if "auth_password" in g:
+ config.set(group_path + ["authentication", "password"], value=g["auth_password"])
+ if "auth_type" in g:
+ config.set(group_path + ["authentication", "type"], value=g["auth_type"])
+
+ if "master_script" in g:
+ config.set(group_path + ["transition-script", "master"], value=g["master_script"])
+ if "backup_script" in g:
+ config.set(group_path + ["transition-script", "backup"], value=g["backup_script"])
+ if "fault_script" in g:
+ config.set(group_path + ["transition-script", "fault"], value=g["fault_script"])
+
+ if "health_check_interval" in g:
+ config.set(group_path + ["health-check", "interval"], value=g["health_check_interval"])
+ if "health_check_count" in g:
+ config.set(group_path + ["health-check", "failure-count"], value=g["health_check_count"])
+ if "health_check_script" in g:
+ config.set(group_path + ["health-check", "script"], value=g["health_check_script"])
+
+ # Not that it should ever be absent...
+ if "virtual_addresses" in g:
+ # The new CLI disallows addresses without prefix length
+ # Pre-rewrite configs didn't support IPv6 VRRP, but handle it anyway
+ for va in g["virtual_addresses"]:
+ if not re.search(r'/', va):
+ if re.search(r':', va):
+ va = "{0}/128".format(va)
+ else:
+ va = "{0}/32".format(va)
+ config.set(group_path + ["virtual-address"], value=va, replace=False)
+
+ # Sync group
+ if "sync_group" in g:
+ config.set(sync_path + [g["sync_group"], "member"], value=group_name, replace=False)
+
+ # Set the tag flag
+ config.set_tag(base_group_path)
+ if config.exists(sync_path):
+ config.set_tag(sync_path)
diff --git a/src/migration-scripts/vrrp/2-to-3 b/src/migration-scripts/vrrp/2-to-3
new file mode 100644
index 0000000..468918f
--- /dev/null
+++ b/src/migration-scripts/vrrp/2-to-3
@@ -0,0 +1,44 @@
+# 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/>.
+
+# T3847: vrrp config cleanup
+
+from vyos.configtree import ConfigTree
+
+base = ['high-availability', 'vrrp']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['group']):
+ for group in config.list_nodes(base + ['group']):
+ group_base = base + ['group', group]
+
+ # Deprecated option
+ tmp = group_base + ['transition-script', 'mode-force']
+ if config.exists(tmp):
+ config.delete(tmp)
+
+ # Rename virtual-address -> address
+ tmp = group_base + ['virtual-address']
+ if config.exists(tmp):
+ config.rename(tmp, 'address')
+
+ # Rename virtual-address-excluded -> excluded-address
+ tmp = group_base + ['virtual-address-excluded']
+ if config.exists(tmp):
+ config.rename(tmp, 'excluded-address')
diff --git a/src/migration-scripts/vrrp/3-to-4 b/src/migration-scripts/vrrp/3-to-4
new file mode 100644
index 0000000..9f05cf7
--- /dev/null
+++ b/src/migration-scripts/vrrp/3-to-4
@@ -0,0 +1,32 @@
+# Copyright 2023-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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['high-availability', 'virtual-server']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base):
+ for vs in config.list_nodes(base):
+ vs_base = base + [vs]
+
+ # If the fwmark is used, the address is not required
+ if not config.exists(vs_base + ['fwmark']):
+ # add option: 'virtual-server <tag> address x.x.x.x'
+ config.set(vs_base + ['address'], value=vs)
diff --git a/src/migration-scripts/webproxy/1-to-2 b/src/migration-scripts/webproxy/1-to-2
new file mode 100644
index 0000000..5a48474
--- /dev/null
+++ b/src/migration-scripts/webproxy/1-to-2
@@ -0,0 +1,33 @@
+# Copyright 2018-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 old style `webproxy proxy-bypass 1.2.3.4/24`
+# to new style `webproxy whitelist destination-address 1.2.3.4/24`
+
+from vyos.configtree import ConfigTree
+
+cfg_webproxy_base = ['service', 'webproxy']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(cfg_webproxy_base + ['proxy-bypass']):
+ # Nothing to do
+ return
+
+ bypass_addresses = config.return_values(cfg_webproxy_base + ['proxy-bypass'])
+ # delete old configuration node
+ config.delete(cfg_webproxy_base + ['proxy-bypass'])
+ for bypass_address in bypass_addresses:
+ # add data to new configuration node
+ config.set(cfg_webproxy_base + ['whitelist', 'destination-address'], value=bypass_address, replace=False)
diff --git a/src/op_mode/accelppp.py b/src/op_mode/accelppp.py
new file mode 100644
index 0000000..67ce786
--- /dev/null
+++ b/src/op_mode/accelppp.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 vyos.accel_ppp
+import vyos.opmode
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import rc_cmd
+
+
+accel_dict = {
+ 'ipoe': {
+ 'port': 2002,
+ 'path': 'service ipoe-server',
+ 'base_path': 'service ipoe-server'
+ },
+ 'pppoe': {
+ 'port': 2001,
+ 'path': 'service pppoe-server',
+ 'base_path': 'service pppoe-server'
+ },
+ 'pptp': {
+ 'port': 2003,
+ 'path': 'vpn pptp',
+ 'base_path': 'vpn pptp'
+ },
+ 'l2tp': {
+ 'port': 2004,
+ 'path': 'vpn l2tp',
+ 'base_path': 'vpn l2tp remote-access'
+ },
+ 'sstp': {
+ 'port': 2005,
+ 'path': 'vpn sstp',
+ 'base_path': 'vpn sstp'
+ }
+}
+
+
+def _get_config_settings(protocol):
+ '''Get config dict from VyOS configuration'''
+ conf = ConfigTreeQuery()
+ base_path = accel_dict[protocol]['base_path']
+ data = conf.get_config_dict(base_path,
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ if conf.exists(f'{base_path} authentication local-users'):
+ # Delete sensitive data
+ del data['authentication']['local_users']
+ return {'config_option': data}
+
+
+def _get_raw_statistics(accel_output, pattern, protocol):
+ return {
+ **vyos.accel_ppp.get_server_statistics(accel_output, pattern, sep=':'),
+ **_get_config_settings(protocol)
+ }
+
+
+def _get_raw_sessions(port):
+ cmd_options = 'show sessions ifname,username,ip,ip6,ip6-dp,type,rate-limit,' \
+ 'state,uptime-raw,calling-sid,called-sid,sid,comp,rx-bytes-raw,' \
+ 'tx-bytes-raw,rx-pkts,tx-pkts'
+ output = vyos.accel_ppp.accel_cmd(port, cmd_options)
+ parsed_data: list[dict[str, str]] = vyos.accel_ppp.accel_out_parse(
+ output.splitlines())
+ return parsed_data
+
+
+def _verify(func):
+ """Decorator checks if accel-ppp protocol
+ ipoe/pppoe/pptp/l2tp/sstp is configured
+
+ for example:
+ service ipoe-server
+ vpn sstp
+ """
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ protocol_list = accel_dict.keys()
+ protocol = kwargs.get('protocol')
+ # unknown or incorrect protocol query
+ if protocol not in protocol_list:
+ unconf_message = f'unknown protocol "{protocol}"'
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ # Check if config does not exist
+ config_protocol_path = accel_dict[protocol]['path']
+ if not config.exists(config_protocol_path):
+ unconf_message = f'"{config_protocol_path}" is not configured'
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ return func(*args, **kwargs)
+
+ return _wrapper
+
+
+@_verify
+def show_statistics(raw: bool, protocol: str):
+ """show accel-cmd statistics
+ CPU utilization and amount of sessions
+
+ protocol: ipoe/pppoe/ppptp/l2tp/sstp
+ """
+ pattern = f'{protocol}:'
+ port = accel_dict[protocol]['port']
+ rc, output = rc_cmd(f'/usr/bin/accel-cmd -p {port} show stat')
+
+ if raw:
+ return _get_raw_statistics(output, pattern, protocol)
+
+ return output
+
+
+@_verify
+def show_sessions(raw: bool, protocol: str):
+ """show accel-cmd sessions
+
+ protocol: ipoe/pppoe/ppptp/l2tp/sstp
+ """
+ port = accel_dict[protocol]['port']
+ if raw:
+ return _get_raw_sessions(port)
+
+ return vyos.accel_ppp.accel_cmd(port,
+ 'show sessions ifname,username,ip,ip6,ip6-dp,'
+ 'calling-sid,rate-limit,state,uptime,rx-bytes,tx-bytes')
+
+
+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/bgp.py b/src/op_mode/bgp.py
new file mode 100644
index 0000000..096113c
--- /dev/null
+++ b/src/op_mode/bgp.py
@@ -0,0 +1,153 @@
+#!/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/>.
+#
+# Purpose:
+# Displays BGP neighbors and tables information.
+
+import re
+import sys
+import typing
+
+from jinja2 import Template
+
+import vyos.opmode
+
+frr_command_template = Template("""
+show bgp
+
+{## VRF and family modifiers that may precede any options ##}
+
+{% if vrf %}
+ vrf {{vrf}}
+{% endif %}
+
+{% if family == "inet" %}
+ ipv4
+{% elif family == "inet6" %}
+ ipv6
+{% elif family == "l2vpn" %}
+ l2vpn evpn
+{% endif %}
+
+{% if family_modifier == "unicast" %}
+ unicast
+{% elif family_modifier == "multicast" %}
+ multicast
+{% elif family_modifier == "flowspec" %}
+ flowspec
+{% elif family_modifier == "vpn" %}
+ vpn
+{% endif %}
+
+{## Mutually exclusive query parameters ##}
+
+{# Network prefix #}
+{% if prefix %}
+ {{prefix}}
+
+ {% if longer_prefixes %}
+ longer-prefixes
+ {% elif best_path %}
+ bestpath
+ {% endif %}
+{% endif %}
+
+{# Regex #}
+{% if regex %}
+ regex {{regex}}
+{% endif %}
+
+{## Raw modifier ##}
+
+{% if raw %}
+ json
+{% endif %}
+""")
+
+ArgFamily = typing.Literal['inet', 'inet6', 'l2vpn']
+ArgFamilyModifier = typing.Literal['unicast', 'labeled_unicast', 'multicast', 'vpn', 'flowspec']
+
+def show_summary(raw: bool):
+ from vyos.utils.process import cmd
+
+ if raw:
+ from json import loads
+
+ output = cmd(f"vtysh -c 'show bgp summary json'").strip()
+
+ # FRR 8.5 correctly returns an empty object when BGP is not running,
+ # we don't need to do anything special here
+ return loads(output)
+ else:
+ output = cmd(f"vtysh -c 'show bgp summary'")
+ return output
+
+def show_neighbors(raw: bool):
+ from vyos.utils.process import cmd
+ from vyos.utils.dict import dict_to_list
+
+ if raw:
+ from json import loads
+
+ output = cmd(f"vtysh -c 'show bgp neighbors json'").strip()
+ d = loads(output)
+ return dict_to_list(d, save_key_to="neighbor")
+ else:
+ output = cmd(f"vtysh -c 'show bgp neighbors'")
+ return output
+
+def show(raw: bool,
+ family: ArgFamily,
+ family_modifier: ArgFamilyModifier,
+ prefix: typing.Optional[str],
+ longer_prefixes: typing.Optional[bool],
+ best_path: typing.Optional[bool],
+ regex: typing.Optional[str],
+ vrf: typing.Optional[str]):
+ from vyos.utils.dict import dict_to_list
+
+ if (longer_prefixes or best_path) and (prefix is None):
+ raise ValueError("longer_prefixes and best_path can only be used when prefix is given")
+ elif (family == "l2vpn") and (family_modifier is not None):
+ raise ValueError("l2vpn family does not accept any modifiers")
+ else:
+ kwargs = dict(locals())
+
+ frr_command = frr_command_template.render(kwargs)
+ frr_command = re.sub(r'\s+', ' ', frr_command)
+
+ from vyos.utils.process import cmd
+ output = cmd(f"vtysh -c '{frr_command}'")
+
+ if raw:
+ from json import loads
+ d = loads(output)
+ if not ("routes" in d):
+ raise vyos.opmode.InternalError("FRR returned a BGP table with no routes field")
+ d = d["routes"]
+ routes = dict_to_list(d, save_key_to="route_key")
+ return routes
+ else:
+ return output
+
+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/bonding.py b/src/op_mode/bonding.py
new file mode 100644
index 0000000..07bccbd
--- /dev/null
+++ b/src/op_mode/bonding.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2016-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/>.
+#
+# This script will parse 'sudo cat /proc/net/bonding/<interface name>' and return table output for lacp related info
+
+import subprocess
+import re
+import sys
+import typing
+from tabulate import tabulate
+
+import vyos.opmode
+from vyos.configquery import ConfigTreeQuery
+
+def list_to_dict(data, headers, basekey):
+ data_list = {basekey: []}
+
+ for row in data:
+ row_dict = {headers[i]: row[i] for i in range(len(headers))}
+ data_list[basekey].append(row_dict)
+
+ return data_list
+
+def show_lacp_neighbors(raw: bool, interface: typing.Optional[str]):
+ headers = ["Interface", "Member", "Local ID", "Remote ID"]
+ data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8')
+ if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data:
+ raise vyos.opmode.DataUnavailable(f"{interface} is not present or not configured with mode 802.3ad")
+
+ pattern = re.compile(
+ r"Slave Interface: (?P<member>\w+\d+).*?"
+ r"system mac address: (?P<local_id>[0-9a-f:]+).*?"
+ r"details partner lacp pdu:.*?"
+ r"system mac address: (?P<remote_id>[0-9a-f:]+)",
+ re.DOTALL
+ )
+
+ interfaces = []
+
+ for match in re.finditer(pattern, data):
+ member = match.group("member")
+ local_id = match.group("local_id")
+ remote_id = match.group("remote_id")
+ interfaces.append([interface, member, local_id, remote_id])
+
+ if raw:
+ return list_to_dict(interfaces, headers, 'lacp')
+ else:
+ return tabulate(interfaces, headers)
+
+def show_lacp_detail(raw: bool, interface: typing.Optional[str]):
+ headers = ["Interface", "Members", "Mode", "Rate", "System-MAC", "Hash"]
+ query = ConfigTreeQuery()
+
+ if interface:
+ intList = [interface]
+ else:
+ intList = query.list_nodes(['interfaces', 'bonding'])
+
+ bondList = []
+
+ for interface in intList:
+ data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8')
+ if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data:
+ continue
+
+ mode_active = "active" if "LACP active: on" in data else "passive"
+ lacp_rate = re.search(r"LACP rate: (\w+)", data).group(1) if re.search(r"LACP rate: (\w+)", data) else "N/A"
+ hash_policy = re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data).group(1) if re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data) else "N/A"
+ system_mac = re.search(r"System MAC address: ([0-9a-f:]+)", data).group(1) if re.search(r"System MAC address: ([0-9a-f:]+)", data) else "N/A"
+ if raw:
+ members = re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data)
+ else:
+ members = ",".join(set(re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data)))
+
+ bondList.append([interface, members, mode_active, lacp_rate, system_mac, hash_policy])
+
+ if raw:
+ return list_to_dict(bondList, headers, 'lacp')
+ else:
+ return tabulate(bondList, headers)
+
+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/bridge.py b/src/op_mode/bridge.py
new file mode 100644
index 0000000..e80b1c2
--- /dev/null
+++ b/src/op_mode/bridge.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 jmespath
+import json
+import sys
+import typing
+
+from tabulate import tabulate
+
+from vyos.utils.process import cmd
+from vyos.utils.process import rc_cmd
+from vyos.utils.process import call
+
+import vyos.opmode
+
+def _get_json_data():
+ """
+ Get bridge data format JSON
+ """
+ return cmd(f'bridge --json link show')
+
+
+def _get_raw_data_summary():
+ """Get interested rules
+ :returns dict
+ """
+ data = _get_json_data()
+ data_dict = json.loads(data)
+ return data_dict
+
+
+def _get_raw_data_vlan(tunnel:bool=False):
+ """
+ :returns dict
+ """
+ show = 'show'
+ if tunnel:
+ show = 'tunnel'
+ json_data = cmd(f'bridge --json --compressvlans vlan {show}')
+ data_dict = json.loads(json_data)
+ return data_dict
+
+def _get_raw_data_vni() -> dict:
+ """
+ :returns dict
+ """
+ json_data = cmd(f'bridge --json vni show')
+ data_dict = json.loads(json_data)
+ return data_dict
+
+def _get_raw_data_fdb(bridge):
+ """Get MAC-address for the bridge brX
+ :returns list
+ """
+ code, json_data = rc_cmd(f'bridge --json fdb show br {bridge}')
+ # From iproute2 fdb.c, fdb_show() will only exit(-1) in case of
+ # non-existent bridge device; raise error.
+ if code == 255:
+ raise vyos.opmode.UnconfiguredObject(f"bridge {bridge} does not exist in the system")
+ data_dict = json.loads(json_data)
+ return data_dict
+
+
+def _get_raw_data_mdb(bridge):
+ """Get MAC-address multicast gorup for the bridge brX
+ :return list
+ """
+ json_data = cmd(f'bridge --json mdb show br {bridge}')
+ data_dict = json.loads(json_data)
+ return data_dict
+
+
+def _get_bridge_members(bridge: str) -> list:
+ """
+ Get list of interface bridge members
+ :param bridge: str
+ :default: ['n/a']
+ :return: list
+ """
+ data = _get_raw_data_summary()
+ members = jmespath.search(f'[?master == `{bridge}`].ifname', data)
+ return [member for member in members] if members else ['n/a']
+
+
+def _get_member_options(bridge: str):
+ data = _get_raw_data_summary()
+ options = jmespath.search(f'[?master == `{bridge}`]', data)
+ return options
+
+
+def _get_formatted_output_summary(data):
+ data_entries = ''
+ bridges = set(jmespath.search('[*].master', data))
+ for bridge in bridges:
+ member_options = _get_member_options(bridge)
+ member_entries = []
+ for option in member_options:
+ interface = option.get('ifname')
+ ifindex = option.get('ifindex')
+ state = option.get('state')
+ mtu = option.get('mtu')
+ flags = ','.join(option.get('flags')).lower()
+ prio = option.get('priority')
+ member_entries.append([interface, state, mtu, flags, prio])
+ member_headers = ["Member", "State", "MTU", "Flags", "Prio"]
+ output_members = tabulate(member_entries, member_headers, numalign="left")
+ output_bridge = f"""Bridge interface {bridge}:
+{output_members}
+
+"""
+ data_entries += output_bridge
+ output = data_entries
+ return output
+
+
+def _get_formatted_output_vlan(data):
+ data_entries = []
+ for entry in data:
+ interface = entry.get('ifname')
+ vlans = entry.get('vlans')
+ for vlan_entry in vlans:
+ vlan = vlan_entry.get('vlan')
+ if vlan_entry.get('vlanEnd'):
+ vlan_end = vlan_entry.get('vlanEnd')
+ vlan = f'{vlan}-{vlan_end}'
+ flags_raw = vlan_entry.get('flags')
+ flags = ', '.join(flags_raw if isinstance(flags_raw,list) else "").lower()
+ data_entries.append([interface, vlan, flags])
+
+ headers = ["Interface", "VLAN", "Flags"]
+ output = tabulate(data_entries, headers)
+ return output
+
+def _get_formatted_output_vlan_tunnel(data):
+ data_entries = []
+ for entry in data:
+ interface = entry.get('ifname')
+ first = True
+ for tunnel_entry in entry.get('tunnels'):
+ vlan = tunnel_entry.get('vlan')
+ vni = tunnel_entry.get('tunid')
+ if first:
+ data_entries.append([interface, vlan, vni])
+ first = False
+ else:
+ # Group by VXLAN interface only - no need to repeat
+ # VXLAN interface name for every VLAN <-> VNI mapping
+ #
+ # Interface VLAN VNI
+ # ----------- ------ -----
+ # vxlan0 100 100
+ # 200 200
+ data_entries.append(['', vlan, vni])
+
+ headers = ["Interface", "VLAN", "VNI"]
+ output = tabulate(data_entries, headers)
+ return output
+
+def _get_formatted_output_vni(data):
+ data_entries = []
+ for entry in data:
+ interface = entry.get('ifname')
+ vlans = entry.get('vnis')
+ for vlan_entry in vlans:
+ vlan = vlan_entry.get('vni')
+ if vlan_entry.get('vniEnd'):
+ vlan_end = vlan_entry.get('vniEnd')
+ vlan = f'{vlan}-{vlan_end}'
+ data_entries.append([interface, vlan])
+
+ headers = ["Interface", "VNI"]
+ output = tabulate(data_entries, headers)
+ return output
+
+def _get_formatted_output_fdb(data):
+ data_entries = []
+ for entry in data:
+ interface = entry.get('ifname')
+ mac = entry.get('mac')
+ state = entry.get('state')
+ flags = ','.join(entry['flags'])
+ data_entries.append([interface, mac, state, flags])
+
+ headers = ["Interface", "Mac address", "State", "Flags"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def _get_formatted_output_mdb(data):
+ data_entries = []
+ for entry in data:
+ for mdb_entry in entry['mdb']:
+ interface = mdb_entry.get('port')
+ group = mdb_entry.get('grp')
+ state = mdb_entry.get('state')
+ flags = ','.join(mdb_entry.get('flags'))
+ data_entries.append([interface, group, state, flags])
+ headers = ["Interface", "Group", "State", "Flags"]
+ output = tabulate(data_entries, headers)
+ return output
+
+def _get_bridge_detail(iface):
+ """Get interface detail statistics"""
+ return call(f'vtysh -c "show interface {iface}"')
+
+def _get_bridge_detail_nexthop_group(iface):
+ """Get interface detail nexthop_group statistics"""
+ return call(f'vtysh -c "show interface {iface} nexthop-group"')
+
+def _get_bridge_detail_nexthop_group_raw(iface):
+ out = cmd(f'vtysh -c "show interface {iface} nexthop-group"')
+ return out
+
+def _get_bridge_detail_raw(iface):
+ """Get interface detail json statistics"""
+ data = cmd(f'vtysh -c "show interface {iface} json"')
+ data_dict = json.loads(data)
+ return data_dict
+
+def show(raw: bool):
+ bridge_data = _get_raw_data_summary()
+ if raw:
+ return bridge_data
+ else:
+ return _get_formatted_output_summary(bridge_data)
+
+
+def show_vlan(raw: bool, tunnel: typing.Optional[bool]):
+ bridge_vlan = _get_raw_data_vlan(tunnel)
+ if raw:
+ return bridge_vlan
+ else:
+ if tunnel:
+ return _get_formatted_output_vlan_tunnel(bridge_vlan)
+ else:
+ return _get_formatted_output_vlan(bridge_vlan)
+
+def show_vni(raw: bool):
+ bridge_vni = _get_raw_data_vni()
+ if raw:
+ return bridge_vni
+ else:
+ return _get_formatted_output_vni(bridge_vni)
+
+def show_fdb(raw: bool, interface: str):
+ fdb_data = _get_raw_data_fdb(interface)
+ if raw:
+ return fdb_data
+ else:
+ return _get_formatted_output_fdb(fdb_data)
+
+
+def show_mdb(raw: bool, interface: str):
+ mdb_data = _get_raw_data_mdb(interface)
+ if raw:
+ return mdb_data
+ else:
+ return _get_formatted_output_mdb(mdb_data)
+
+def show_detail(raw: bool, nexthop_group: typing.Optional[bool], interface: str):
+ if raw:
+ if nexthop_group:
+ return _get_bridge_detail_nexthop_group_raw(interface)
+ else:
+ return _get_bridge_detail_raw(interface)
+ else:
+ if nexthop_group:
+ return _get_bridge_detail_nexthop_group(interface)
+ else:
+ return _get_bridge_detail(interface)
+
+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/cgnat.py b/src/op_mode/cgnat.py
new file mode 100644
index 0000000..9ad8f92
--- /dev/null
+++ b/src/op_mode/cgnat.py
@@ -0,0 +1,96 @@
+#!/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 json
+import sys
+import typing
+
+from tabulate import tabulate
+
+import vyos.opmode
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+
+CGNAT_TABLE = 'cgnat'
+
+
+def _get_raw_data(external_address: str = '', internal_address: str = '') -> list[dict]:
+ """Get CGNAT dictionary and filter by external or internal address if provided."""
+ cmd_output = cmd(f'nft --json list table ip {CGNAT_TABLE}')
+ data = json.loads(cmd_output)
+
+ elements = data['nftables'][2]['map']['elem']
+ allocations = []
+ for elem in elements:
+ internal = elem[0] # internal
+ external = elem[1]['concat'][0] # external
+ start_port = elem[1]['concat'][1]['range'][0]
+ end_port = elem[1]['concat'][1]['range'][1]
+ port_range = f'{start_port}-{end_port}'
+
+ if (internal_address and internal != internal_address) or (
+ external_address and external != external_address
+ ):
+ continue
+
+ allocations.append(
+ {
+ 'internal_address': internal,
+ 'external_address': external,
+ 'port_range': port_range,
+ }
+ )
+
+ return allocations
+
+
+def _get_formatted_output(allocations: list[dict]) -> str:
+ # Convert the list of dictionaries to a list of tuples for tabulate
+ headers = ['Internal IP', 'External IP', 'Port range']
+ data = [
+ (alloc['internal_address'], alloc['external_address'], alloc['port_range'])
+ for alloc in allocations
+ ]
+ output = tabulate(data, headers, numalign="left")
+ return output
+
+
+def show_allocation(
+ raw: bool,
+ external_address: typing.Optional[str],
+ internal_address: typing.Optional[str],
+) -> str:
+ config = ConfigTreeQuery()
+ if not config.exists('nat cgnat'):
+ raise vyos.opmode.UnconfiguredSubsystem('CGNAT is not configured')
+
+ if raw:
+ return _get_raw_data(external_address, internal_address)
+
+ else:
+ raw_data = _get_raw_data(external_address, internal_address)
+ return _get_formatted_output(raw_data)
+
+
+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/clear_conntrack.py b/src/op_mode/clear_conntrack.py
new file mode 100644
index 0000000..fec7cf1
--- /dev/null
+++ b/src/op_mode/clear_conntrack.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 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
+
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import cmd
+from vyos.utils.process import DEVNULL
+
+if not ask_yes_no("This will clear all currently tracked and expected connections. Continue?"):
+ sys.exit(1)
+else:
+ cmd('/usr/sbin/conntrack -F', stderr=DEVNULL)
+ cmd('/usr/sbin/conntrack -F expect', stderr=DEVNULL)
diff --git a/src/op_mode/config_mgmt.py b/src/op_mode/config_mgmt.py
new file mode 100644
index 0000000..66de26d
--- /dev/null
+++ b/src/op_mode/config_mgmt.py
@@ -0,0 +1,85 @@
+#!/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 typing
+
+import vyos.opmode
+from vyos.config_mgmt import ConfigMgmt
+
+def show_commit_diff(raw: bool, rev: int, rev2: typing.Optional[int],
+ commands: bool):
+ config_mgmt = ConfigMgmt()
+ config_diff = config_mgmt.show_commit_diff(rev, rev2, commands)
+
+ if raw:
+ rev2 = (rev+1) if rev2 is None else rev2
+ if commands:
+ d = {f'config_command_diff_{rev2}_{rev}': config_diff}
+ else:
+ d = {f'config_file_diff_{rev2}_{rev}': config_diff}
+ return d
+
+ return config_diff
+
+def show_commit_file(raw: bool, rev: int):
+ config_mgmt = ConfigMgmt()
+ config_file = config_mgmt.show_commit_file(rev)
+
+ if raw:
+ d = {f'config_revision_{rev}': config_file}
+ return d
+
+ return config_file
+
+def show_commit_log(raw: bool):
+ config_mgmt = ConfigMgmt()
+
+ msg = ''
+ if config_mgmt.max_revisions == 0:
+ msg = ('commit-revisions is not configured;\n'
+ 'commit log is empty or stale:\n\n')
+
+ data = config_mgmt.get_raw_log_data()
+ if raw:
+ return data
+
+ out = config_mgmt.format_log_data(data)
+ out = msg + out
+
+ return out
+
+def show_commit_log_brief(raw: bool):
+ # used internally for completion help for 'rollback'
+ # option 'raw' will return same as 'show_commit_log'
+ config_mgmt = ConfigMgmt()
+
+ data = config_mgmt.get_raw_log_data()
+ if raw:
+ return data
+
+ out = config_mgmt.format_log_data_brief(data)
+
+ return out
+
+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/connect_disconnect.py b/src/op_mode/connect_disconnect.py
new file mode 100644
index 0000000..8903f91
--- /dev/null
+++ b/src/op_mode/connect_disconnect.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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 argparse
+
+from psutil import process_iter
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import call
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.network import is_wwan_connected
+from vyos.utils.process import DEVNULL
+
+def check_ppp_interface(interface):
+ if not os.path.isfile(f'/etc/ppp/peers/{interface}'):
+ print(f'Interface {interface} does not exist!')
+ exit(1)
+
+def check_ppp_running(interface):
+ """ Check if PPP process is running in the interface in question """
+ for p in process_iter():
+ if "pppd" in p.name():
+ if interface in p.cmdline():
+ return True
+
+ return False
+
+def connect(interface):
+ """ Connect dialer interface """
+
+ if interface.startswith('pppoe') or interface.startswith('sstpc'):
+ check_ppp_interface(interface)
+ # Check if interface is already dialed
+ if os.path.isdir(f'/sys/class/net/{interface}'):
+ print(f'Interface {interface}: already connected!')
+ elif check_ppp_running(interface):
+ print(f'Interface {interface}: connection is being established!')
+ else:
+ print(f'Interface {interface}: connecting...')
+ call(f'systemctl restart ppp@{interface}.service')
+ elif interface.startswith('wwan'):
+ if is_wwan_connected(interface):
+ print(f'Interface {interface}: already connected!')
+ else:
+ call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces_wwan.py')
+ else:
+ print(f'Unknown interface {interface}, cannot connect. Aborting!')
+
+ # Reaply QoS configuration
+ config = ConfigTreeQuery()
+ if config.exists(f'qos interface {interface}'):
+ count = 1
+ while commit_in_progress():
+ if ( count % 60 == 0 ):
+ print(f'Commit still in progress after {count}s - waiting')
+ count += 1
+ time.sleep(1)
+ call('/usr/libexec/vyos/conf_mode/qos.py')
+
+def disconnect(interface):
+ """ Disconnect dialer interface """
+
+ if interface.startswith('pppoe') or interface.startswith('sstpc'):
+ check_ppp_interface(interface)
+
+ # Check if interface is already down
+ if not check_ppp_running(interface):
+ print(f'Interface {interface}: connection is already down')
+ else:
+ print(f'Interface {interface}: disconnecting...')
+ call(f'systemctl stop ppp@{interface}.service')
+ elif interface.startswith('wwan'):
+ if not is_wwan_connected(interface):
+ print(f'Interface {interface}: connection is already down')
+ else:
+ modem = interface.lstrip('wwan')
+ call(f'mmcli --modem {modem} --simple-disconnect', stdout=DEVNULL)
+ else:
+ print(f'Unknown interface {interface}, cannot disconnect. Aborting!')
+
+def main():
+ parser = argparse.ArgumentParser()
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument("--connect", help="Bring up a connection-oriented network interface", action="store_true")
+ group.add_argument("--disconnect", help="Take down connection-oriented network interface", action="store_true")
+ parser.add_argument("--interface", help="Interface name", action="store", required=True)
+ args = parser.parse_args()
+
+ if args.connect or args.disconnect:
+ if args.disconnect:
+ disconnect(args.interface)
+
+ if args.connect:
+ if commit_in_progress():
+ print('Cannot connect while a commit is in progress')
+ exit(1)
+ connect(args.interface)
+
+ else:
+ parser.print_help()
+
+ exit(0)
+
+if __name__ == '__main__':
+ main()
diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py
new file mode 100644
index 0000000..c379c3e
--- /dev/null
+++ b/src/op_mode/conntrack.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 sys
+import typing
+import xmltodict
+
+from tabulate import tabulate
+from vyos.utils.process import cmd
+
+import vyos.opmode
+
+ArgFamily = typing.Literal['inet', 'inet6']
+
+def _get_xml_data(family):
+ """
+ Get conntrack XML output
+ """
+ return cmd(f'sudo conntrack --dump --family {family} --output xml')
+
+
+def _xml_to_dict(xml):
+ """
+ Convert XML to dictionary
+ Return: dictionary
+ """
+ parse = xmltodict.parse(xml, attr_prefix='')
+ # If only one conntrack entry we must change dict
+ if 'meta' in parse['conntrack']['flow']:
+ return dict(conntrack={'flow': [parse['conntrack']['flow']]})
+ return parse
+
+
+def _get_raw_data(family):
+ """
+ Return: dictionary
+ """
+ xml = _get_xml_data(family)
+ if len(xml) == 0:
+ output = {'conntrack':
+ {
+ 'error': True,
+ 'reason': 'entries not found'
+ }
+ }
+ return output
+ return _xml_to_dict(xml)
+
+
+def _get_raw_statistics():
+ entries = []
+ data = cmd('sudo conntrack --stats')
+ data = data.replace(' \t', '').split('\n')
+ for entry in data:
+ entries.append(entry.split())
+ return entries
+
+
+def get_formatted_statistics(entries):
+ headers = [
+ "CPU",
+ "Found",
+ "Invalid",
+ "Insert",
+ "Insert fail",
+ "Drop",
+ "Early drop",
+ "Errors",
+ "Search restart",
+ "",
+ "",
+ ]
+ # Process each entry to extract and format the values after '='
+ processed_entries = [
+ [value.split('=')[-1] for value in entry]
+ for entry in entries
+ ]
+ output = tabulate(processed_entries, headers, numalign="left")
+ return output
+
+
+def get_formatted_output(dict_data):
+ """
+ :param xml:
+ :return: formatted output
+ """
+ data_entries = []
+ if 'error' in dict_data['conntrack']:
+ return 'Entries not found'
+ for entry in dict_data['conntrack']['flow']:
+ orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {}
+ reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {}
+ proto = {}
+ for meta in entry['meta']:
+ direction = meta['direction']
+ if direction in ['original']:
+ if 'layer3' in meta:
+ orig_src = meta['layer3']['src']
+ orig_dst = meta['layer3']['dst']
+ if 'layer4' in meta:
+ if meta.get('layer4').get('sport'):
+ orig_sport = meta['layer4']['sport']
+ if meta.get('layer4').get('dport'):
+ orig_dport = meta['layer4']['dport']
+ proto = meta['layer4']['protoname']
+ if direction in ['reply']:
+ if 'layer3' in meta:
+ reply_src = meta['layer3']['src']
+ reply_dst = meta['layer3']['dst']
+ if 'layer4' in meta:
+ if meta.get('layer4').get('sport'):
+ reply_sport = meta['layer4']['sport']
+ if meta.get('layer4').get('dport'):
+ reply_dport = meta['layer4']['dport']
+ proto = meta['layer4']['protoname']
+ if direction == 'independent':
+ conn_id = meta['id']
+ # T6138 flowtable offload conntrack entries without 'timeout'
+ timeout = meta.get('timeout', 'n/a')
+ orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src
+ orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst
+ reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src
+ reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst
+ state = meta['state'] if 'state' in meta else ''
+ mark = meta['mark'] if 'mark' in meta else ''
+ zone = meta['zone'] if 'zone' in meta else ''
+ data_entries.append(
+ [conn_id, orig_src, orig_dst, reply_src, reply_dst, proto, state, timeout, mark, zone])
+ headers = ["Id", "Original src", "Original dst", "Reply src", "Reply dst", "Protocol", "State", "Timeout", "Mark",
+ "Zone"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def show(raw: bool, family: ArgFamily):
+ family = 'ipv6' if family == 'inet6' else 'ipv4'
+ conntrack_data = _get_raw_data(family)
+ if raw:
+ return conntrack_data
+ else:
+ return get_formatted_output(conntrack_data)
+
+
+def show_statistics(raw: bool):
+ conntrack_statistics = _get_raw_statistics()
+ if raw:
+ return conntrack_statistics
+ else:
+ return get_formatted_statistics(conntrack_statistics)
+
+
+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/conntrack_sync.py b/src/op_mode/conntrack_sync.py
new file mode 100644
index 0000000..f3b09b4
--- /dev/null
+++ b/src/op_mode/conntrack_sync.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 sys
+import syslog
+import xmltodict
+
+from tabulate import tabulate
+
+import vyos.opmode
+
+from vyos.configquery import CliShellApiConfigQuery
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+
+conntrackd_bin = '/usr/sbin/conntrackd'
+conntrackd_config = '/run/conntrackd/conntrackd.conf'
+failover_state_file = '/var/run/vyatta-conntrackd-failover-state'
+
+def is_configured():
+ """ Check if conntrack-sync service is configured """
+ config = CliShellApiConfigQuery()
+ if not config.exists(['service', 'conntrack-sync']):
+ raise vyos.opmode.UnconfiguredSubsystem("conntrack-sync is not configured!")
+
+def send_bulk_update():
+ """ send bulk update of internal-cache to other systems """
+ tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -B')
+ if tmp > 0:
+ raise vyos.opmode.Error('Failed to send bulk update to other conntrack-sync systems')
+
+def request_sync():
+ """ request resynchronization with other systems """
+ tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -n')
+ if tmp > 0:
+ raise vyos.opmode.Error('Failed to request resynchronization of external cache')
+
+def flush_cache(direction):
+ """ flush conntrackd cache (internal or external) """
+ if direction not in ['internal', 'external']:
+ raise ValueError()
+ tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -f {direction}')
+ if tmp > 0:
+ raise vyos.opmode.Error('Failed to clear {direction} cache')
+
+def get_formatted_output(data):
+ data_entries = []
+ for parsed in data:
+ for meta in parsed.get('flow', {}).get('meta', []):
+ direction = meta['@direction']
+ if direction == 'original':
+ src = meta['layer3']['src']
+ dst = meta['layer3']['dst']
+ sport = meta['layer4'].get('sport')
+ dport = meta['layer4'].get('dport')
+ protocol = meta['layer4'].get('@protoname')
+ orig_src = f'{src}:{sport}' if sport else src
+ orig_dst = f'{dst}:{dport}' if dport else dst
+
+ data_entries.append([orig_src, orig_dst, protocol])
+
+ headers = ["Source", "Destination", "Protocol"]
+ output = tabulate(data_entries, headers, tablefmt="simple")
+ return output
+
+def from_xml(raw, xml):
+ out = []
+ for line in xml.splitlines():
+ if line == '\n':
+ continue
+ parsed = xmltodict.parse(line)
+ out.append(parsed)
+
+ if raw:
+ return out
+ else:
+ return get_formatted_output(out)
+
+def restart():
+ is_configured()
+ if commit_in_progress():
+ raise vyos.opmode.CommitInProgress('Cannot restart conntrackd while a commit is in progress')
+
+ syslog.syslog('Restarting conntrack sync service...')
+ cmd('systemctl restart conntrackd.service')
+ # request resynchronization with other systems
+ request_sync()
+ # send bulk update of internal-cache to other systems
+ send_bulk_update()
+
+def reset_external_cache():
+ is_configured()
+ syslog.syslog('Resetting external cache of conntrack sync service...')
+
+ # flush the external cache
+ flush_cache('external')
+ # request resynchronization with other systems
+ request_sync()
+
+def reset_internal_cache():
+ is_configured()
+ syslog.syslog('Resetting internal cache of conntrack sync service...')
+ # flush the internal cache
+ flush_cache('internal')
+
+ # request resynchronization of internal cache with kernel conntrack table
+ tmp = run(f'{conntrackd_bin} -C {conntrackd_config} -R')
+ if tmp > 0:
+ print('ERROR: failed to resynchronize internal cache with kernel conntrack table')
+
+ # send bulk update of internal-cache to other systems
+ send_bulk_update()
+
+def _show_cache(raw, opts):
+ is_configured()
+ out = cmd(f'{conntrackd_bin} -C {conntrackd_config} {opts} -x')
+ return from_xml(raw, out)
+
+def show_external_cache(raw: bool):
+ opts = '-e ct'
+ return _show_cache(raw, opts)
+
+def show_external_expect(raw: bool):
+ opts = '-e expect'
+ return _show_cache(raw, opts)
+
+def show_internal_cache(raw: bool):
+ opts = '-i ct'
+ return _show_cache(raw, opts)
+
+def show_internal_expect(raw: bool):
+ opts = '-i expect'
+ return _show_cache(raw, opts)
+
+def show_statistics(raw: bool):
+ if raw:
+ raise vyos.opmode.UnsupportedOperation("Machine-readable conntrack-sync statistics are not available yet")
+ else:
+ is_configured()
+ config = ConfigTreeQuery()
+ print('\nMain Table Statistics:\n')
+ call(f'{conntrackd_bin} -C {conntrackd_config} -s')
+ print()
+ if config.exists(['service', 'conntrack-sync', 'expect-sync']):
+ print('\nExpect Table Statistics:\n')
+ call(f'{conntrackd_bin} -C {conntrackd_config} -s exp')
+ print()
+
+def show_status(raw: bool):
+ is_configured()
+ config = ConfigTreeQuery()
+ ct_sync_intf = config.list_nodes(['service', 'conntrack-sync', 'interface'])
+ ct_sync_intf = ', '.join(ct_sync_intf)
+ failover_state = "no transition yet!"
+ expect_sync_protocols = []
+
+ if config.exists(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp']):
+ failover_mechanism = "vrrp"
+ vrrp_sync_grp = config.value(['service', 'conntrack-sync', 'failover-mechanism', 'vrrp', 'sync-group'])
+
+ if os.path.isfile(failover_state_file):
+ with open(failover_state_file, "r") as f:
+ failover_state = f.readline()
+
+ if config.exists(['service', 'conntrack-sync', 'expect-sync']):
+ expect_sync_protocols = config.values(['service', 'conntrack-sync', 'expect-sync'])
+ if 'all' in expect_sync_protocols:
+ expect_sync_protocols = ["ftp", "sip", "h323", "nfs", "sqlnet"]
+
+ if raw:
+ status_data = {
+ "sync_interface": ct_sync_intf,
+ "failover_mechanism": failover_mechanism,
+ "sync_group": vrrp_sync_grp,
+ "last_transition": failover_state,
+ "sync_protocols": expect_sync_protocols
+ }
+
+ return status_data
+ else:
+ if expect_sync_protocols:
+ expect_sync_protocols = ', '.join(expect_sync_protocols)
+ else:
+ expect_sync_protocols = "disabled"
+ show_status = (f'\nsync-interface : {ct_sync_intf}\n'
+ f'failover-mechanism : {failover_mechanism} [sync-group {vrrp_sync_grp}]\n'
+ f'last state transition : {failover_state}\n'
+ f'ExpectationSync : {expect_sync_protocols}')
+
+ return show_status
+
+if __name__ == '__main__':
+ syslog.openlog(ident='conntrack-tools', logoption=syslog.LOG_PID, facility=syslog.LOG_INFO)
+
+ 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/container.py b/src/op_mode/container.py
new file mode 100644
index 0000000..05f65df
--- /dev/null
+++ b/src/op_mode/container.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 json
+import sys
+
+from vyos.utils.process import cmd
+from vyos.utils.process import rc_cmd
+import vyos.opmode
+
+def _get_json_data(command: str) -> list:
+ """
+ Get container command format JSON
+ """
+ return cmd(f'{command} --format json')
+
+def _get_raw_data(command: str) -> list:
+ json_data = _get_json_data(command)
+ data = json.loads(json_data)
+ return data
+
+def add_image(name: str):
+ """ Pull image from container registry. If registry authentication
+ is defined within VyOS CLI, credentials are used to login befroe pull """
+ from vyos.configquery import ConfigTreeQuery
+
+ conf = ConfigTreeQuery()
+ container = conf.get_config_dict(['container', 'registry'])
+
+ do_logout = False
+ if 'registry' in container:
+ for registry, registry_config in container['registry'].items():
+ if 'disable' in registry_config:
+ continue
+ if 'authentication' in registry_config:
+ do_logout = True
+ if {'username', 'password'} <= set(registry_config['authentication']):
+ username = registry_config['authentication']['username']
+ password = registry_config['authentication']['password']
+ cmd = f'podman login --username {username} --password {password} {registry}'
+ rc, out = rc_cmd(cmd)
+ if rc != 0: raise vyos.opmode.InternalError(out)
+
+ rc, output = rc_cmd(f'podman image pull {name}')
+ if rc != 0:
+ raise vyos.opmode.InternalError(output)
+
+ if do_logout:
+ rc_cmd('podman logout --all')
+
+def delete_image(name: str):
+ from vyos.utils.process import rc_cmd
+
+ if name == 'all':
+ # gather list of all images and pass them to the removal list
+ name = cmd('sudo podman image ls --quiet')
+ # If there are no container images left, we can not delete them all
+ if not name: return
+ # replace newline with whitespace
+ name = name.replace('\n', ' ')
+ rc, output = rc_cmd(f'podman image rm {name}')
+ if rc != 0:
+ raise vyos.opmode.InternalError(output)
+
+def show_container(raw: bool):
+ command = 'podman ps --all'
+ container_data = _get_raw_data(command)
+ if raw:
+ return container_data
+ else:
+ return cmd(command)
+
+def show_image(raw: bool):
+ command = 'podman image ls'
+ container_data = _get_raw_data('podman image ls')
+ if raw:
+ return container_data
+ else:
+ return cmd(command)
+
+def show_network(raw: bool):
+ command = 'podman network ls'
+ container_data = _get_raw_data(command)
+ if raw:
+ return container_data
+ else:
+ return cmd(command)
+
+def restart(name: str):
+ from vyos.utils.process import rc_cmd
+
+ rc, output = rc_cmd(f'systemctl restart vyos-container-{name}.service')
+ if rc != 0:
+ print(output)
+ return None
+ print(f'Container "{name}" restarted!')
+ return output
+
+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/cpu.py b/src/op_mode/cpu.py
new file mode 100644
index 0000000..1a0f739
--- /dev/null
+++ b/src/op_mode/cpu.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2016-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 sys
+
+import vyos.opmode
+from vyos.utils.cpu import get_cpus
+from vyos.utils.cpu import get_core_count
+
+from jinja2 import Template
+
+cpu_template = Template("""
+{% for cpu in cpus %}
+{% if 'physical id' in cpu %}CPU socket: {{cpu['physical id']}}{% endif %}
+{% if 'vendor_id' in cpu %}CPU Vendor: {{cpu['vendor_id']}}{% endif %}
+{% if 'model name' in cpu %}Model: {{cpu['model name']}}{% endif %}
+{% if 'cpu cores' in cpu %}Cores: {{cpu['cpu cores']}}{% endif %}
+{% if 'cpu MHz' in cpu %}Current MHz: {{cpu['cpu MHz']}}{% endif %}
+{% endfor %}
+""")
+
+cpu_summary_template = Template("""
+Physical CPU cores: {{count}}
+CPU model(s): {{models | join(", ")}}
+""")
+
+def _get_raw_data():
+ return get_cpus()
+
+def _format_cpus(cpu_data):
+ env = {'cpus': cpu_data}
+ return cpu_template.render(env).strip()
+
+def _get_summary_data():
+ count = get_core_count()
+ cpu_data = get_cpus()
+ models = [c['model name'] for c in cpu_data]
+ env = {'count': count, "models": models}
+
+ return env
+
+def _format_cpu_summary(summary_data):
+ return cpu_summary_template.render(summary_data).strip()
+
+def show(raw: bool):
+ cpu_data = _get_raw_data()
+
+ if raw:
+ return cpu_data
+ else:
+ return _format_cpus(cpu_data)
+
+def show_summary(raw: bool):
+ cpu_summary_data = _get_summary_data()
+
+ if raw:
+ return cpu_summary_data
+ else:
+ return _format_cpu_summary(cpu_summary_data)
+
+
+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/dhcp.py b/src/op_mode/dhcp.py
new file mode 100644
index 0000000..e5455c8
--- /dev/null
+++ b/src/op_mode/dhcp.py
@@ -0,0 +1,530 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 sys
+import typing
+
+from datetime import datetime
+from glob import glob
+from ipaddress import ip_address
+from tabulate import tabulate
+
+import vyos.opmode
+
+from vyos.base import Warning
+from vyos.configquery import ConfigTreeQuery
+
+from vyos.kea import kea_get_active_config
+from vyos.kea import kea_get_leases
+from vyos.kea import kea_get_pool_from_subnet_id
+from vyos.kea import kea_delete_lease
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.process import call
+
+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']
+mapping_sort_valid = ['mac', 'ip', 'pool', 'duid']
+
+ArgFamily = typing.Literal['inet', 'inet6']
+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='') -> int:
+ """
+ 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 _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list:
+ """
+ Get DHCP server leases
+ :return list
+ """
+ inet_suffix = '6' if family == 'inet6' else '4'
+ try:
+ leases = kea_get_leases(inet_suffix)
+ except:
+ raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server lease information')
+
+ if pool is None:
+ pool = _get_dhcp_pools(family=family)
+ else:
+ pool = [pool]
+
+ try:
+ active_config = kea_get_active_config(inet_suffix)
+ except:
+ 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.utcfromtimestamp(expiry - lifetime)
+ lease['expire_timestamp'] = datetime.utcfromtimestamp(expiry) 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
+
+ if family == 'inet':
+ data_lease['mac'] = lease['hw-address']
+ data_lease['start'] = lease['start_timestamp'].timestamp()
+ data_lease['hostname'] = lease['hostname']
+
+ 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.utcnow()
+
+ 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)
+ data.pop(idx)
+
+ if sorted:
+ if sorted == 'ip':
+ data.sort(key = lambda x:ip_address(x['ip']))
+ else:
+ data.sort(key = lambda x:x[sorted])
+ return data
+
+
+def _get_formatted_server_leases(raw_data, family='inet'):
+ data_entries = []
+ if family == 'inet':
+ for lease in raw_data:
+ ipaddr = lease.get('ip')
+ 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')
+ end = lease.get('end')
+ 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']
+
+ 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')
+ end = lease.get('end')
+ 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']
+
+ 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}'
+ size = 0
+ subnets = config.list_nodes(f'{base} subnet')
+ for subnet in subnets:
+ ranges = config.list_nodes(f'{base} subnet {subnet} range')
+ for range in ranges:
+ if family == 'inet6':
+ start = config.value(f'{base} subnet {subnet} range {range} start')
+ stop = config.value(f'{base} subnet {subnet} range {range} stop')
+ else:
+ start = config.value(f'{base} subnet {subnet} range {range} start')
+ stop = config.value(f'{base} subnet {subnet} range {range} stop')
+ # Add +1 because both range boundaries are inclusive
+ size += int(ip_address(stop)) - int(ip_address(start)) + 1
+ return size
+
+
+def _get_raw_pool_statistics(family='inet', pool=None):
+ if pool is None:
+ pool = _get_dhcp_pools(family=family)
+ else:
+ pool = [pool]
+
+ 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')
+ size = _get_pool_size(family=family, pool=p)
+ leases = len(_get_raw_server_leases(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}
+ stats.append(pool_stats)
+ return stats
+
+
+def _get_formatted_pool_statistics(pool_data, family='inet'):
+ data_entries = []
+ for entry in pool_data:
+ pool = entry.get('pool')
+ size = entry.get('size')
+ leases = entry.get('leases')
+ available = entry.get('available')
+ use_percentage = entry.get('use_percentage')
+ use_percentage = f'{use_percentage}%'
+ data_entries.append([pool, size, leases, available, use_percentage])
+
+ 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)
+
+ if sorted:
+ if sorted == 'ip':
+ data.sort(key = lambda x:ip_address(x['ip-address']))
+ else:
+ data.sort(key = lambda x:x[sorted])
+ return mappings
+
+def _get_formatted_server_static_mappings(raw_data, family='inet'):
+ data_entries = []
+ 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])
+
+ headers = ['Pool', 'Subnet', 'Name', 'IP Address', 'MAC Address', 'DUID', 'Description']
+ output = tabulate(data_entries, headers, numalign='left')
+ return output
+
+def _verify(func):
+ """Decorator checks if DHCP(v6) config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ family = kwargs.get('family')
+ v = 'v6' if family == 'inet6' else ''
+ unconf_message = f'DHCP{v} server is not configured'
+ # Check if config does not exist
+ 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
+ from vyos.ifconfig import Section
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ family = kwargs.get('family')
+ v = 'v6' if family == 'inet6' else ''
+ interface = kwargs.get('interface')
+ interface_path = Section.get_config_path(interface)
+ unconf_message = f'DHCP{v} client not configured on interface {interface}!'
+
+ # Check if config does not exist
+ 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)
+ if raw:
+ return pool_data
+ else:
+ return _get_formatted_pool_statistics(pool_data, family=family)
+
+
+@_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.')
+
+ 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!')
+
+ if state and state not in lease_valid_states:
+ raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!')
+
+ 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 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]):
+ 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!')
+
+ 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)
+ 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
+
+@_verify
+def clear_dhcp_server_lease(family: ArgFamily, address: str):
+ v = 'v6' if family == 'inet6' else ''
+ inet = '6' if family == 'inet6' else '4'
+
+ if not _lease_valid(inet, address):
+ print(f'Lease not found on DHCP{v} server')
+ return None
+
+ if not kea_delete_lease(inet, address):
+ print(f'Failed to clear lease for "{address}"')
+ return None
+
+ print(f'Lease "{address}" has been cleared')
+
+def _get_raw_client_leases(family='inet', interface=None):
+ from time import mktime
+ from datetime import datetime
+ from vyos.defaults import directories
+ from vyos.utils.network import get_interface_vrf
+
+ lease_dir = directories['isc_dhclient_dir']
+ lease_files = []
+ lease_data = []
+
+ if interface:
+ tmp = f'{lease_dir}/dhclient_{interface}.lease'
+ if os.path.exists(tmp):
+ lease_files.append(tmp)
+ else:
+ # All DHCP leases
+ lease_files = glob(f'{lease_dir}/dhclient_*.lease')
+
+ for lease in lease_files:
+ tmp = {}
+ with open(lease, 'r') as f:
+ for line in f.readlines():
+ line = line.rstrip()
+ if 'last_update' not in tmp:
+ # ISC dhcp client contains least_update timestamp in human readable
+ # 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()))})
+ continue
+
+ k, v = line.split('=')
+ tmp.update({k : v.replace("'", "")})
+
+ if 'interface' in tmp:
+ vrf = get_interface_vrf(tmp['interface'])
+ if 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
+
+ from vyos.utils.network import is_intf_addr_assigned
+
+ data_entries = []
+ for lease in lease_data:
+ if not lease.get('new_ip_address'):
+ continue
+ 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])
+ if 'new_subnet_mask' in lease:
+ data_entries.append(["Subnet Mask", lease['new_subnet_mask']])
+ if 'new_domain_name' in lease:
+ data_entries.append(["Domain Name", lease['new_domain_name']])
+ if 'new_routers' in lease:
+ data_entries.append(["Router", lease['new_routers']])
+ if 'new_domain_name_servers' in lease:
+ 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']])
+ if 'new_dhcp_lease_time' in lease:
+ data_entries.append(["DHCP Server", lease['new_dhcp_lease_time']])
+ if 'vrf' in lease:
+ 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])
+ if 'new_expiry' in lease:
+ tmp = strftime(time_string, localtime(int(lease['new_expiry'])))
+ data_entries.append(["Expiry", tmp])
+
+ # Add empty marker
+ data_entries.append([''])
+
+ output = tabulate(data_entries, tablefmt='plain')
+
+ 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:
+ return lease_data
+ 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:
+ v = 'v6' if family == 'inet6' else ''
+ print(f'Restarting DHCP{v} client on interface {interface}...')
+ if family == 'inet6':
+ call(f'systemctl restart dhcp6c@{interface}.service')
+ else:
+ call(f'systemctl restart dhclient@{interface}.service')
+
+@_verify_client
+def release_client_lease(raw: bool, family: ArgFamily, interface: str):
+ if not raw:
+ v = 'v6' if family == 'inet6' else ''
+ print(f'Release DHCP{v} client on interface {interface}...')
+ if family == 'inet6':
+ call(f'systemctl stop dhcp6c@{interface}.service')
+ else:
+ call(f'systemctl stop dhclient@{interface}.service')
+
+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/dns.py b/src/op_mode/dns.py
new file mode 100644
index 0000000..16c462f
--- /dev/null
+++ b/src/op_mode/dns.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 sys
+import time
+import typing
+import vyos.opmode
+
+from tabulate import tabulate
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd, rc_cmd
+from vyos.template import is_ipv4, is_ipv6
+
+_dynamic_cache_file = r'/run/ddclient/ddclient.cache'
+
+_dynamic_status_columns = {
+ 'host': 'Hostname',
+ 'ipv4': 'IPv4 address',
+ 'status-ipv4': 'IPv4 status',
+ 'ipv6': 'IPv6 address',
+ 'status-ipv6': 'IPv6 status',
+ 'mtime': 'Last update',
+}
+
+_forwarding_statistics_columns = {
+ 'cache-entries': 'Cache entries',
+ 'max-cache-entries': 'Max cache entries',
+ 'cache-size': 'Cache size',
+}
+
+def _forwarding_data_to_dict(data, sep="\t") -> dict:
+ """
+ Return dictionary from plain text
+ separated by tab
+
+ cache-entries 73
+ cache-hits 0
+ uptime 2148
+ user-msec 172
+
+ {
+ 'cache-entries': '73',
+ 'cache-hits': '0',
+ 'uptime': '2148',
+ 'user-msec': '172'
+ }
+ """
+ dictionary = {}
+ mylist = [line for line in data.split('\n')]
+
+ for line in mylist:
+ if sep in line:
+ key, value = line.split(sep)
+ dictionary[key] = value
+ return dictionary
+
+def _get_dynamic_host_records_raw() -> dict:
+
+ data = []
+
+ if os.path.isfile(_dynamic_cache_file): # A ddclient status file might not always exist
+ with open(_dynamic_cache_file, 'r') as f:
+ for line in f:
+ if line.startswith('#'):
+ continue
+
+ props = {}
+ # ddclient cache rows have properties in 'key=value' format separated by comma
+ # we pick up the ones we are interested in
+ for kvraw in line.split(' ')[0].split(','):
+ k, v = kvraw.split('=')
+ if k in list(_dynamic_status_columns.keys()) + ['ip', 'status']: # ip and status are legacy keys
+ props[k] = v
+
+ # Extract IPv4 and IPv6 address and status from legacy keys
+ # Dual-stack isn't supported in legacy format, 'ip' and 'status' are for one of IPv4 or IPv6
+ if 'ip' in props:
+ if is_ipv4(props['ip']):
+ props['ipv4'] = props['ip']
+ props['status-ipv4'] = props['status']
+ elif is_ipv6(props['ip']):
+ props['ipv6'] = props['ip']
+ props['status-ipv6'] = props['status']
+ del props['ip']
+
+ # Convert mtime to human readable format
+ if 'mtime' in props:
+ props['mtime'] = time.strftime(
+ "%Y-%m-%d %H:%M:%S", time.localtime(int(props['mtime'], base=10)))
+
+ data.append(props)
+
+ return data
+
+def _get_dynamic_host_records_formatted(data):
+ data_entries = []
+ for entry in data:
+ data_entries.append([entry.get(key) for key in _dynamic_status_columns.keys()])
+ header = _dynamic_status_columns.values()
+ output = tabulate(data_entries, header, numalign='left')
+ return output
+
+def _get_forwarding_statistics_raw() -> dict:
+ command = cmd('rec_control get-all')
+ data = _forwarding_data_to_dict(command)
+ data['cache-size'] = "{0:.2f} kbytes".format( int(
+ cmd('rec_control get cache-bytes')) / 1024 )
+ return data
+
+def _get_forwarding_statistics_formatted(data):
+ data_entries = []
+ data_entries.append([data.get(key) for key in _forwarding_statistics_columns.keys()])
+ header = _forwarding_statistics_columns.values()
+ output = tabulate(data_entries, header, numalign='left')
+ return output
+
+def _verify(target):
+ """Decorator checks if config for DNS related service exists"""
+ from functools import wraps
+
+ if target not in ['dynamic', 'forwarding']:
+ raise ValueError('Invalid target')
+
+ def _verify_target(func):
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ if not config.exists(f'service dns {target}'):
+ _prefix = f'Dynamic DNS' if target == 'dynamic' else 'DNS Forwarding'
+ raise vyos.opmode.UnconfiguredSubsystem(f'{_prefix} is not configured')
+ return func(*args, **kwargs)
+ return _wrapper
+ return _verify_target
+
+@_verify('dynamic')
+def show_dynamic_status(raw: bool):
+ host_data = _get_dynamic_host_records_raw()
+ if raw:
+ return host_data
+ else:
+ return _get_dynamic_host_records_formatted(host_data)
+
+@_verify('dynamic')
+def reset_dynamic():
+ """
+ Reset Dynamic DNS cache
+ """
+ if os.path.exists(_dynamic_cache_file):
+ os.remove(_dynamic_cache_file)
+ rc, output = rc_cmd('systemctl restart ddclient.service')
+ if rc != 0:
+ print(output)
+ return None
+ print(f'Dynamic DNS state reset!')
+
+@_verify('forwarding')
+def show_forwarding_statistics(raw: bool):
+ dns_data = _get_forwarding_statistics_raw()
+ if raw:
+ return dns_data
+ else:
+ return _get_forwarding_statistics_formatted(dns_data)
+
+@_verify('forwarding')
+def reset_forwarding(all: bool, domain: typing.Optional[str]):
+ """
+ Reset DNS Forwarding cache
+
+ :param all (bool): reset cache all domains
+ :param domain (str): reset cache for specified domain
+ """
+ if all:
+ rc, output = rc_cmd('rec_control wipe-cache ".$"')
+ if rc != 0:
+ print(output)
+ return None
+ print('DNS Forwarding cache reset for all domains!')
+ return output
+ elif domain:
+ rc, output = rc_cmd(f'rec_control wipe-cache "{domain}$"')
+ if rc != 0:
+ print(output)
+ return None
+ print(f'DNS Forwarding cache reset for domain "{domain}"!')
+ return output
+
+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/evpn.py b/src/op_mode/evpn.py
new file mode 100644
index 0000000..cae4ab9
--- /dev/null
+++ b/src/op_mode/evpn.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2016-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/>.
+#
+# This script is a helper to run VTYSH commands for "show evpn", allowing for the --raw flag to output JSON
+
+import sys
+import typing
+import json
+
+import vyos.opmode
+from vyos.utils.process import cmd
+
+def show_evpn(raw: bool, command: typing.Optional[str]):
+ if raw:
+ command = f"{command} json"
+ evpnDict = {}
+ try:
+ evpnDict['evpn'] = json.loads(cmd(f"vtysh -c '{command}'"))
+ except:
+ raise vyos.opmode.DataUnavailable(f"\"{command.replace(' json', '')}\" is invalid or has no JSON option")
+
+ return evpnDict
+ else:
+ return cmd(f"vtysh -c '{command}'")
+
+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/execute_bandwidth_test.sh b/src/op_mode/execute_bandwidth_test.sh
new file mode 100644
index 0000000..a6ad0b4
--- /dev/null
+++ b/src/op_mode/execute_bandwidth_test.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+#
+# Copyright (C) 2020 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/>.
+
+if ipaddrcheck --is-ipv6 $1; then
+ # Set address family to IPv6 when an IPv6 address was specified
+ OPT="-V"
+elif [[ $(dig $1 AAAA +short | grep -v '\.$' | wc -l) -gt 0 ]]; then
+ # CNAME is also part of the dig answer thus we must remove any
+ # CNAME response and only shot the AAAA response(s), this is done
+ # by grep -v '\.$'
+
+ # Set address family to IPv6 when FQDN has at least one AAAA record
+ OPT="-V"
+else
+ # It's not IPv6, no option needed
+ OPT=""
+fi
+
+/usr/bin/iperf $OPT -c $1 $2
+
diff --git a/src/op_mode/execute_port-scan.py b/src/op_mode/execute_port-scan.py
new file mode 100644
index 0000000..bf17d03
--- /dev/null
+++ b/src/op_mode/execute_port-scan.py
@@ -0,0 +1,155 @@
+#! /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 sys
+
+from vyos.utils.process import call
+
+
+options = {
+ 'port': {
+ 'cmd': '{command} -p {value}',
+ 'type': '<1-65535> <list>',
+ 'help': 'Scan specified ports.'
+ },
+ 'tcp': {
+ 'cmd': '{command} -sT',
+ 'type': 'noarg',
+ 'help': 'Use TCP scan.'
+ },
+ 'udp': {
+ 'cmd': '{command} -sU',
+ 'type': 'noarg',
+ 'help': 'Use UDP scan.'
+ },
+ 'skip-ping': {
+ 'cmd': '{command} -Pn',
+ 'type': 'noarg',
+ 'help': 'Skip the Nmap discovery stage altogether.'
+ },
+ 'ipv6': {
+ 'cmd': '{command} -6',
+ 'type': 'noarg',
+ 'help': 'Enable IPv6 scanning.'
+ },
+}
+
+nmap = 'sudo /usr/bin/nmap'
+
+
+class List(list):
+ def first(self):
+ return self.pop(0) if self else ''
+
+ def last(self):
+ return self.pop() if self else ''
+
+ def prepend(self, value):
+ self.insert(0, value)
+
+
+def completion_failure(option: str) -> None:
+ """
+ Shows failure message after TAB when option is wrong
+ :param option: failure option
+ :type str:
+ """
+ sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option))
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def expansion_failure(option, completions):
+ reason = 'Ambiguous' if completions else 'Invalid'
+ sys.stderr.write(
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
+ option))
+ if completions:
+ sys.stderr.write(' Possible completions:\n ')
+ sys.stderr.write('\n '.join(completions))
+ sys.stderr.write('\n')
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def complete(prefix):
+ return [o for o in options if o.startswith(prefix)]
+
+
+def convert(command, args):
+ while args:
+ shortname = args.first()
+ longnames = complete(shortname)
+ if len(longnames) != 1:
+ expansion_failure(shortname, longnames)
+ longname = longnames[0]
+ if options[longname]['type'] == 'noarg':
+ command = options[longname]['cmd'].format(
+ command=command, value='')
+ elif not args:
+ sys.exit(f'port-scan: missing argument for {longname} option')
+ else:
+ command = options[longname]['cmd'].format(
+ command=command, value=args.first())
+ return command
+
+
+if __name__ == '__main__':
+ args = List(sys.argv[1:])
+ host = args.first()
+
+ if host == '--get-options-nested':
+ args.first() # pop execute
+ args.first() # pop port-scan
+ args.first() # pop host
+ args.first() # pop <host>
+ usedoptionslist = []
+ while args:
+ option = args.first() # pop option
+ matched = complete(option) # get option parameters
+ usedoptionslist.append(option) # list of used options
+ # Select options
+ if not args:
+ # remove from Possible completions used options
+ for o in usedoptionslist:
+ if o in matched:
+ matched.remove(o)
+ if not matched:
+ sys.stdout.write('<nocomps>')
+ else:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+
+ if len(matched) > 1:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+ # If option doesn't have value
+ if matched:
+ if options[matched[0]]['type'] == 'noarg':
+ continue
+ else:
+ # Unexpected option
+ completion_failure(option)
+
+ value = args.first() # pop option's value
+ if not args:
+ matched = complete(option)
+ helplines = options[matched[0]]['type']
+ sys.stdout.write(helplines)
+ sys.exit(0)
+
+ command = convert(nmap, args)
+ call(f'{command} -T4 {host}')
diff --git a/src/op_mode/file.py b/src/op_mode/file.py
new file mode 100644
index 0000000..bf13bed
--- /dev/null
+++ b/src/op_mode/file.py
@@ -0,0 +1,383 @@
+#!/usr/bin/python3
+
+# Copyright 2023 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/>.
+
+import argparse
+import contextlib
+import datetime
+import grp
+import os
+import pwd
+import shutil
+import sys
+import tempfile
+
+from vyos.remote import download
+from vyos.remote import upload
+from vyos.utils.io import ask_yes_no
+from vyos.utils.io import print_error
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+
+
+parser = argparse.ArgumentParser(description='view, copy or remove files and directories',
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+parser.epilog = """
+TYPE is one of 'remote', 'image' and 'local'.
+A local path is <path> or ~/<path>.
+A remote path is <scheme>://<urn>.
+An image path is <image>:<path>.
+
+Clone operation is between images only.
+Copy operation does not support directories from remote locations.
+Delete operation does not support remote paths.
+"""
+operations = parser.add_mutually_exclusive_group(required=True)
+operations.add_argument('--show', nargs=1, help='show the contents of file PATH of type TYPE',
+ metavar=('PATH'))
+operations.add_argument('--copy', nargs=2, help='copy SRC to DEST',
+ metavar=('SRC', 'DEST'))
+operations.add_argument('--delete', nargs=1, help='delete file PATH',
+ metavar=('PATH'))
+operations.add_argument('--clone', help='clone config from running image to IMG',
+ metavar='IMG')
+operations.add_argument('--clone-from', nargs=2, help='clone config from image SRC to image DEST',
+ metavar=('SRC', 'DEST'))
+
+## Helper procedures
+def fix_terminal() -> None:
+ """
+ Reset terminal after potential breakage caused by abrupt exits.
+ """
+ run('stty sane')
+
+def get_types(arg: str) -> tuple[str, str]:
+ """
+ Determine whether the argument shows a local, image or remote path.
+ """
+ schemes = ['http', 'https', 'ftp', 'ftps', 'sftp', 'ssh', 'scp', 'tftp']
+ s = arg.split("://", 1)
+ if len(s) != 2:
+ return 'local', arg
+ elif s[0] in schemes:
+ return 'remote', arg
+ else:
+ return 'image', arg
+
+def zealous_copy(source: str, destination: str) -> None:
+ # Even shutil.copy2() doesn't preserve ownership across copies.
+ # So we need to resort to this.
+ stats = os.stat(source)
+ shutil.copy2(source, destination)
+ os.chown(destination, stats.st_uid, stats.st_gid)
+
+def get_file_type(path: str) -> str:
+ return cmd(['file', '-sb', path])
+
+def print_header(string: str) -> None:
+ print('#' * 10, string, '#' * 10)
+
+def octal_to_symbolic(octal: str) -> str:
+ perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
+ result = ""
+ # We discard all but the last three digits because we're only
+ # interested in the permission bits.
+ for i in octal[-3:]:
+ result += perms[int(i)]
+ return result
+
+def get_user_and_group(stats: os.stat_result) -> tuple[str, str]:
+ try:
+ user = pwd.getpwuid(stats.st_uid).pw_name
+ except (KeyError, PermissionError):
+ user = str(stats.st_uid)
+ try:
+ group = grp.getgrgid(stats.st_gid).gr_name
+ except (KeyError, PermissionError):
+ group = str(stats.st_gid)
+ return user, group
+
+def print_file_info(path: str) -> None:
+ stats = os.stat(path)
+ username, groupname = get_user_and_group(stats)
+ mtime = datetime.datetime.fromtimestamp(stats.st_mtime).strftime("%F %X")
+ print_header('FILE INFO')
+ print(f'Path:\t\t{path}')
+ # File type is determined through `file(1)`.
+ print(f'Type:\t\t{get_file_type(path)}')
+ # Owner user and group
+ print(f'Owner:\t\t{username}:{groupname}')
+ # Permissions are converted from raw int to octal string to symbolic string.
+ print(f'Permissions:\t{octal_to_symbolic(oct(stats.st_mode))}')
+ # Last date of modification
+ print(f'Modified:\t{mtime}')
+
+def print_file_data(path: str) -> None:
+ print_header('FILE DATA')
+ file_type = get_file_type(path)
+ # Human-readable files are streamed line-by-line.
+ if 'text' in file_type:
+ with open(path, 'r') as f:
+ for line in f:
+ print(line, end='')
+ # tcpdump files go to TShark.
+ elif 'pcap' in file_type or os.path.splitext(path)[1] == '.pcap':
+ print(cmd(['sudo', 'tshark', '-r', path]))
+ # All other binaries get hexdumped.
+ else:
+ print(cmd(['hexdump', '-C', path]))
+
+def parse_image_path(image_path: str) -> str:
+ """
+ my-image:/foo/bar -> /lib/live/mount/persistence/boot/my-image/rw/foo/bar
+ """
+ image_name, path = image_path.split('://', 1)
+ if image_name == 'running':
+ image_root = '/'
+ elif image_name == 'disk-install':
+ image_root = '/lib/live/mount/persistence/'
+ else:
+ image_root = os.path.join('/lib/live/mount/persistence/boot', image_name, 'rw')
+ if not os.path.isdir(image_root):
+ print_error(f'Image {image_name} not found.')
+ sys.exit(1)
+ return os.path.join(image_root, path)
+
+
+## Show procedures
+def show_locally(path: str) -> None:
+ """
+ Display the contents of a local file or directory.
+ """
+ location = os.path.realpath(os.path.expanduser(path))
+ # Temporarily redirect stdout to a throwaway file for `less(1)` to read.
+ # The output could be potentially too hefty for an in-memory StringIO.
+ temp = tempfile.NamedTemporaryFile('w', delete=False)
+ try:
+ with contextlib.redirect_stdout(temp):
+ # Just a directory. Call `ls(1)` and bail.
+ if os.path.isdir(location):
+ print_header('DIRECTORY LISTING')
+ print('Path:\t', location)
+ print(cmd(['ls', '-hlFGL', '--group-directories-first', location]))
+ elif os.path.isfile(location):
+ print_file_info(location)
+ print()
+ print_file_data(location)
+ else:
+ print_error(f'File or directory {path} not found.')
+ sys.exit(1)
+ sys.stdout.flush()
+ # Call `less(1)` and wait for it to terminate before going forward.
+ cmd(['/usr/bin/less', '-X', temp.name], stdout=sys.stdout)
+ # The stream to the temporary file could break for any reason.
+ # It's much less fragile than if we streamed directly to the process stdin.
+ # But anything could still happen and we don't want to scare the user.
+ except (BrokenPipeError, EOFError, KeyboardInterrupt, OSError):
+ fix_terminal()
+ sys.exit(1)
+ finally:
+ os.remove(temp.name)
+
+def show(type: str, path: str) -> None:
+ if type == 'remote':
+ temp = tempfile.NamedTemporaryFile(delete=False)
+ download(temp.name, path)
+ show_locally(temp.name)
+ os.remove(temp.name)
+ elif type == 'image':
+ show_locally(parse_image_path(path))
+ elif type == 'local':
+ show_locally(path)
+ else:
+ print_error(f'Unknown target for showing: {type}')
+ print_error('Valid types are "remote", "image" and "local".')
+ sys.exit(1)
+
+
+## Copying procedures
+def copy(source_type: str, source_path: str,
+ destination_type: str, destination_path: str) -> None:
+ """
+ Copy a file or directory locally, remotely or to and from an image.
+ Directory uploads and downloads not supported.
+ """
+ source = ''
+ try:
+ # Download to a temporary file and use that as the source.
+ if source_type == 'remote':
+ source = tempfile.NamedTemporaryFile(delete=False).name
+ download(source, source_path)
+ # Prepend the image root to the path.
+ elif source_type == 'image':
+ source = parse_image_path(source_path)
+ elif source_type == 'local':
+ source = source_path
+ else:
+ print_error(f'Unknown source type: {source_type}')
+ print_error(f'Valid source types are "remote", "image" and "local".')
+ sys.exit(1)
+
+ # Directly upload the file.
+ if destination_type == 'remote':
+ if os.path.isdir(source):
+ print_error(f'Cannot upload {source}. Directory uploads not supported.')
+ sys.exit(1)
+ upload(source, destination_path)
+ # No need to duplicate local copy operations for image copying.
+ elif destination_type == 'image':
+ copy('local', source, 'local', parse_image_path(destination_path))
+ # Try to preserve metadata when copying.
+ elif destination_type == 'local':
+ if os.path.isdir(destination_path):
+ destination_path = os.path.join(destination_path, os.path.basename(source))
+ if os.path.isdir(source):
+ shutil.copytree(source, destination_path, copy_function=zealous_copy)
+ else:
+ zealous_copy(source, destination_path)
+ else:
+ print_error(f'Unknown destination type: {source_type}')
+ print_error(f'Valid destination types are "remote", "image" and "local".')
+ sys.exit(1)
+ except OSError:
+ import traceback
+ # We can't check for every single user error (eg copying a directory to a file)
+ # so we just let a curtailed stack trace provide a descriptive error.
+ print_error(f'Failed to copy {source_path} to {destination_path}.')
+ traceback.print_exception(*sys.exc_info()[:2], None)
+ sys.exit(1)
+ else:
+ # To prevent a duplicate message.
+ if destination_type != 'image':
+ print('Copy successful.')
+ finally:
+ # Clean up temporary file.
+ if source_type == 'remote':
+ os.remove(source)
+
+
+## Deletion procedures
+def delete_locally(path: str) -> None:
+ """
+ Remove a local file or directory.
+ """
+ try:
+ if os.path.isdir(path):
+ if (ask_yes_no(f'Do you want to remove {path} with all its contents?')):
+ shutil.rmtree(path)
+ print(f'Directory {path} removed.')
+ else:
+ print('Operation aborted.')
+ elif os.path.isfile(path):
+ if (ask_yes_no(f'Do you want to remove {path}?')):
+ os.remove(path)
+ print(f'File {path} removed.')
+ else:
+ print('Operation aborted.')
+ else:
+ raise OSError(f'File or directory {path} not found.')
+ except OSError:
+ import traceback
+ print_error(f'Failed to delete {path}.')
+ traceback.print_exception(*sys.exc_info()[:2], None)
+ sys.exit(1)
+
+def delete(type: str, path: str) -> None:
+ if type == 'local':
+ delete_locally(path)
+ elif type == 'image':
+ delete_locally(parse_image_path(path))
+ else:
+ print_error(f'Unknown target for deletion: {type}')
+ print_error('Valid types are "image" and "local".')
+ sys.exit(1)
+
+
+## Cloning procedures
+def clone(source: str, destination: str) -> None:
+ if os.geteuid():
+ print_error('Only the superuser can run this command.')
+ sys.exit(1)
+ if destination == 'running' or destination == 'disk-install':
+ print_error(f'Cannot clone config to {destination}.')
+ sys.exit(1)
+ # If `source` is None, then we're going to copy from the running image.
+ if source is None or source == 'running':
+ source_path = '/config'
+ # For the warning message only.
+ source = 'the current'
+ else:
+ source_path = parse_image_path(source + ':/config')
+ destination_path = parse_image_path(destination + ':/config')
+ backup_path = destination_path + '.preclone'
+
+ if not os.path.isdir(source_path):
+ print_error(f'Source image {source} does not exist.')
+ sys.exit(1)
+ if not os.path.isdir(destination_path):
+ print_error(f'Destination image {destination} does not exist.')
+ sys.exit(1)
+ print(f'WARNING: This operation will erase /config data in image {destination}.')
+ print(f'/config data in {source} image will be copied over in its place.')
+ print(f'The existing /config data in {destination} image will be backed up to /config.preclone.')
+
+ if ask_yes_no('Are you sure you want to continue?'):
+ try:
+ if os.path.isdir(backup_path):
+ print('Removing previous backup...')
+ shutil.rmtree(backup_path)
+ print('Making new backup...')
+ shutil.move(destination_path, backup_path)
+ except:
+ print('Something went wrong during the backup process!')
+ print('Cowardly refusing to proceed with cloning.')
+ raise
+ # Copy new config from image.
+ try:
+ shutil.copytree(source_path, destination_path, copy_function=zealous_copy)
+ except:
+ print('Cloning failed! Reverting to backup!')
+ # Delete leftover files from the botched cloning.
+ shutil.rmtree(destination_path, ignore_errors=True)
+ # Restore backup before bailing out.
+ shutil.copytree(backup_path, destination_path, copy_function=zealous_copy)
+ raise
+ else:
+ print(f'Successfully cloned config from {source} to {destination}.')
+ finally:
+ shutil.rmtree(backup_path)
+ else:
+ print('Operation aborted.')
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+ try:
+ if args.show:
+ show(*get_types(args.show[0]))
+ elif args.copy:
+ copy(*get_types(args.copy[0]),
+ *get_types(args.copy[1]))
+ elif args.delete:
+ delete(*get_types(args.delete[0]))
+ elif args.clone_from:
+ clone(*args.clone_from)
+ elif args.clone:
+ # Pass None as source image to copy from local image.
+ clone(None, args.clone)
+ except KeyboardInterrupt:
+ print_error('Operation cancelled by user.')
+ sys.exit(1)
+ sys.exit(0)
diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py
new file mode 100644
index 0000000..c197ca4
--- /dev/null
+++ b/src/op_mode/firewall.py
@@ -0,0 +1,728 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 argparse
+import ipaddress
+import json
+import re
+import tabulate
+import textwrap
+
+from vyos.config import Config
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search_args
+
+def get_config_node(conf, node=None, family=None, hook=None, priority=None):
+ if node == 'nat':
+ if family == 'ipv6':
+ config_path = ['nat66']
+ else:
+ config_path = ['nat']
+
+ elif node == 'policy':
+ config_path = ['policy']
+ else:
+ config_path = ['firewall']
+ if family:
+ config_path += [family]
+ if hook:
+ config_path += [hook]
+ if priority:
+ config_path += [priority]
+
+ node_config = conf.get_config_dict(config_path, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ return node_config
+
+def get_nftables_details(family, hook, priority):
+ if family == 'ipv6':
+ suffix = 'ip6'
+ name_prefix = 'NAME6_'
+ aux='IPV6_'
+ elif family == 'ipv4':
+ suffix = 'ip'
+ name_prefix = 'NAME_'
+ aux=''
+ else:
+ suffix = 'bridge'
+ name_prefix = 'NAME_'
+ aux=''
+
+ if hook == 'name' or hook == 'ipv6-name':
+ command = f'nft list chain {suffix} vyos_filter {name_prefix}{priority}'
+ else:
+ up_hook = hook.upper()
+ command = f'nft list chain {suffix} vyos_filter VYOS_{aux}{up_hook}_{priority}'
+
+ try:
+ results = cmd(command)
+ except:
+ return {}
+
+ out = {}
+ for line in results.split('\n'):
+ comment_search = re.search(rf'{priority}[\- ](\d+|default-action)', line)
+ if not comment_search:
+ continue
+
+ rule = {}
+ rule_id = comment_search[1]
+ counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line)
+ if counter_search:
+ rule['packets'] = counter_search[1]
+ rule['bytes'] = counter_search[2]
+
+ rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip()
+ out[rule_id] = rule
+ return out
+
+def get_nftables_state_details(family):
+ if family == 'ipv6':
+ suffix = 'ip6'
+ name_suffix = 'POLICY6'
+ elif family == 'ipv4':
+ suffix = 'ip'
+ name_suffix = 'POLICY'
+ else:
+ # no state policy for bridge
+ return {}
+
+ command = f'nft list chain {suffix} vyos_filter VYOS_STATE_{name_suffix}'
+ try:
+ results = cmd(command)
+ except:
+ return {}
+
+ out = {}
+ for line in results.split('\n'):
+ rule = {}
+ for state in ['established', 'related', 'invalid']:
+ if state in line:
+ counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line)
+ if counter_search:
+ rule['packets'] = counter_search[1]
+ rule['bytes'] = counter_search[2]
+ rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip()
+ out[state] = rule
+ return out
+
+def get_nftables_group_members(family, table, name):
+ prefix = 'ip6' if family == 'ipv6' else 'ip'
+ out = []
+
+ try:
+ results_str = cmd(f'nft -j list set {prefix} {table} {name}')
+ results = json.loads(results_str)
+ except:
+ return out
+
+ if 'nftables' not in results:
+ return out
+
+ for obj in results['nftables']:
+ if 'set' not in obj:
+ continue
+
+ set_obj = obj['set']
+
+ if 'elem' in set_obj:
+ for elem in set_obj['elem']:
+ if isinstance(elem, str):
+ out.append(elem)
+ elif isinstance(elem, dict) and 'elem' in elem:
+ out.append(elem['elem'])
+
+ return out
+
+def output_firewall_vertical(rules, headers, adjust=True):
+ for rule in rules:
+ adjusted_rule = rule + [""] * (len(headers) - len(rule)) if adjust else rule # account for different header length, like default-action
+ transformed_rule = [[header, textwrap.fill(adjusted_rule[i].replace('\n', ' '), 65)] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char
+
+ print(tabulate.tabulate(transformed_rule, tablefmt="presto"))
+ print()
+
+def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=None):
+ print(f'\n---------------------------------\n{family} Firewall "{hook} {priority}"\n')
+
+ details = get_nftables_details(family, hook, priority)
+ rows = []
+
+ if 'rule' in firewall_conf:
+ for rule_id, rule_conf in firewall_conf['rule'].items():
+ if single_rule_id and rule_id != single_rule_id:
+ continue
+
+ if 'disable' in rule_conf:
+ continue
+
+ row = [rule_id, textwrap.fill(rule_conf.get('description') or '', 50), rule_conf['action'], rule_conf['protocol'] if 'protocol' in rule_conf else 'all']
+ if rule_id in details:
+ rule_details = details[rule_id]
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ row.append(rule_details['conditions'])
+ rows.append(row)
+
+ if hook in ['input', 'forward', 'output']:
+ def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'accept'
+ else:
+ def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'drop'
+ row = ['default', '', def_action, 'all']
+ rule_details = details['default-action']
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+
+ rows.append(row)
+
+ if rows:
+ if args.rule:
+ rows.pop()
+
+ if args.detail:
+ header = ['Rule', 'Description', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions']
+ output_firewall_vertical(rows, header)
+ else:
+ header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions']
+ for i in rows:
+ rows[rows.index(i)].pop(1)
+ print(tabulate.tabulate(rows, header) + '\n')
+
+def output_firewall_state_policy(family):
+ if family == 'bridge':
+ return {}
+ print(f'\n---------------------------------\n{family} State Policy\n')
+
+ details = get_nftables_state_details(family)
+ rows = []
+
+ for state, state_conf in details.items():
+ row = [state, state_conf['conditions']]
+ row.append(state_conf.get('packets', 0))
+ row.append(state_conf.get('bytes', 0))
+ row.append(state_conf.get('conditions'))
+ rows.append(row)
+
+ if rows:
+ if args.rule:
+ rows.pop()
+
+ if args.detail:
+ header = ['State', 'Conditions', 'Packets', 'Bytes']
+ output_firewall_vertical(rows, header)
+ else:
+ header = ['State', 'Packets', 'Bytes', 'Conditions']
+ for i in rows:
+ rows[rows.index(i)].pop(1)
+ print(tabulate.tabulate(rows, header) + '\n')
+
+def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule_id=None):
+ print(f'\n---------------------------------\n{family} Firewall "{hook} {prior}"\n')
+
+ details = get_nftables_details(family, hook, prior)
+ rows = []
+
+ if 'rule' in prior_conf:
+ for rule_id, rule_conf in prior_conf['rule'].items():
+ if single_rule_id and rule_id != single_rule_id:
+ continue
+
+ if 'disable' in rule_conf:
+ continue
+
+ # Get source
+ source_addr = dict_search_args(rule_conf, 'source', 'address')
+ if not source_addr:
+ source_addr = dict_search_args(rule_conf, 'source', 'group', 'address_group')
+ if not source_addr:
+ source_addr = dict_search_args(rule_conf, 'source', 'group', 'network_group')
+ if not source_addr:
+ source_addr = dict_search_args(rule_conf, 'source', 'group', 'domain_group')
+ if not source_addr:
+ source_addr = dict_search_args(rule_conf, 'source', 'fqdn')
+ if not source_addr:
+ source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code')
+ if source_addr:
+ source_addr = str(source_addr)[1:-1].replace('\'','')
+ if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'):
+ source_addr = 'NOT ' + str(source_addr)
+ if not source_addr:
+ source_addr = 'any'
+
+ # Get destination
+ dest_addr = dict_search_args(rule_conf, 'destination', 'address')
+ if not dest_addr:
+ dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'address_group')
+ if not dest_addr:
+ dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'network_group')
+ if not dest_addr:
+ dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'domain_group')
+ if not dest_addr:
+ dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn')
+ if not dest_addr:
+ dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code')
+ if dest_addr:
+ dest_addr = str(dest_addr)[1:-1].replace('\'','')
+ if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'):
+ dest_addr = 'NOT ' + str(dest_addr)
+ if not dest_addr:
+ dest_addr = 'any'
+
+ # Get inbound interface
+ iiface = dict_search_args(rule_conf, 'inbound_interface', 'name')
+ if not iiface:
+ iiface = dict_search_args(rule_conf, 'inbound_interface', 'group')
+ if not iiface:
+ iiface = 'any'
+
+ # Get outbound interface
+ oiface = dict_search_args(rule_conf, 'outbound_interface', 'name')
+ if not oiface:
+ oiface = dict_search_args(rule_conf, 'outbound_interface', 'group')
+ if not oiface:
+ oiface = 'any'
+
+ row = [rule_id, textwrap.fill(rule_conf.get('description') or '', 50)]
+ if rule_id in details:
+ rule_details = details[rule_id]
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ else:
+ row.append('0')
+ row.append('0')
+ row.append(rule_conf['action'])
+ row.append(source_addr)
+ row.append(dest_addr)
+ row.append(iiface)
+ row.append(oiface)
+ rows.append(row)
+
+
+ if hook in ['input', 'forward', 'output']:
+ row = ['default', '']
+ rule_details = details['default-action']
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ if 'default_action' in prior_conf:
+ row.append(prior_conf['default_action'])
+ else:
+ row.append('accept')
+ row.append('any')
+ row.append('any')
+ row.append('any')
+ row.append('any')
+ rows.append(row)
+
+ elif 'default_action' in prior_conf and not single_rule_id:
+ row = ['default', '']
+ if 'default-action' in details:
+ rule_details = details['default-action']
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ else:
+ row.append('0')
+ row.append('0')
+ row.append(prior_conf['default_action'])
+ row.append('any') # Source
+ row.append('any') # Dest
+ row.append('any') # inbound-interface
+ row.append('any') # outbound-interface
+ rows.append(row)
+
+ if rows:
+ if args.detail:
+ header = ['Rule', 'Description', 'Packets', 'Bytes', 'Action', 'Source', 'Destination', 'Inbound-Interface', 'Outbound-interface']
+ output_firewall_vertical(rows, header)
+ else:
+ header = ['Rule', 'Packets', 'Bytes', 'Action', 'Source', 'Destination', 'Inbound-Interface', 'Outbound-interface']
+ for i in rows:
+ rows[rows.index(i)].pop(1)
+ print(tabulate.tabulate(rows, header) + '\n')
+
+def show_firewall():
+ print('Rulesets Information')
+
+ conf = Config()
+ firewall = get_config_node(conf)
+
+ if not firewall:
+ return
+
+ for family in ['ipv4', 'ipv6', 'bridge']:
+ if 'global_options' in firewall:
+ if 'state_policy' in firewall['global_options']:
+ output_firewall_state_policy(family)
+
+ if family in firewall:
+ for hook, hook_conf in firewall[family].items():
+ for prior, prior_conf in firewall[family][hook].items():
+ output_firewall_name(family, hook, prior, prior_conf)
+
+def show_firewall_family(family):
+ print(f'Rulesets {family} Information')
+
+ conf = Config()
+ firewall = get_config_node(conf)
+
+ if not firewall:
+ return
+
+ if 'global_options' in firewall:
+ if 'state_policy' in firewall['global_options']:
+ output_firewall_state_policy(family)
+
+ if family in firewall:
+ for hook, hook_conf in firewall[family].items():
+ for prior, prior_conf in firewall[family][hook].items():
+ output_firewall_name(family, hook, prior, prior_conf)
+
+def show_firewall_name(family, hook, priority):
+ print('Ruleset Information')
+
+ conf = Config()
+ firewall = get_config_node(conf, 'firewall', family, hook, priority)
+ if firewall:
+ output_firewall_name(family, hook, priority, firewall)
+
+def show_firewall_rule(family, hook, priority, rule_id):
+ print('Rule Information')
+
+ conf = Config()
+ firewall = get_config_node(conf, 'firewall', family, hook, priority)
+ if firewall:
+ output_firewall_name(family, hook, priority, firewall, rule_id)
+
+def show_firewall_group(name=None):
+ conf = Config()
+ firewall = get_config_node(conf, node='firewall')
+
+ if 'group' not in firewall:
+ return
+
+ nat = get_config_node(conf, node='nat')
+ policy = get_config_node(conf, node='policy')
+
+ def find_references(group_type, group_name):
+ out = []
+ family = []
+ if group_type in ['address_group', 'network_group']:
+ family = ['ipv4']
+ elif group_type == 'ipv6_address_group':
+ family = ['ipv6']
+ group_type = 'address_group'
+ elif group_type == 'ipv6_network_group':
+ family = ['ipv6']
+ group_type = 'network_group'
+ else:
+ family = ['ipv4', 'ipv6', 'bridge']
+
+ for item in family:
+ # Look references in firewall
+ for name_type in ['name', 'ipv6_name', 'forward', 'input', 'output']:
+ if item in firewall:
+ if name_type not in firewall[item]:
+ continue
+ for priority, priority_conf in firewall[item][name_type].items():
+ if priority not in firewall[item][name_type]:
+ continue
+ if 'rule' not in priority_conf:
+ continue
+ for rule_id, rule_conf in priority_conf['rule'].items():
+ source_group = dict_search_args(rule_conf, 'source', 'group', group_type)
+ dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type)
+ in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group')
+ out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group')
+ dyn_group_source = dict_search_args(rule_conf, 'add_address_to_group', 'source_address', group_type)
+ dyn_group_dst = dict_search_args(rule_conf, 'add_address_to_group', 'destination_address', group_type)
+ if source_group:
+ if source_group[0] == "!":
+ source_group = source_group[1:]
+ if group_name == source_group:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+ if dest_group:
+ if dest_group[0] == "!":
+ dest_group = dest_group[1:]
+ if group_name == dest_group:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+ if in_interface:
+ if in_interface[0] == "!":
+ in_interface = in_interface[1:]
+ if group_name == in_interface:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+ if out_interface:
+ if out_interface[0] == "!":
+ out_interface = out_interface[1:]
+ if group_name == out_interface:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+
+ if dyn_group_source:
+ if group_name == dyn_group_source:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+ if dyn_group_dst:
+ if group_name == dyn_group_dst:
+ out.append(f'{item}-{name_type}-{priority}-{rule_id}')
+
+
+ # Look references in route | route6
+ for name_type in ['route', 'route6']:
+ if name_type not in policy:
+ continue
+ if name_type == 'route' and item == 'ipv6':
+ continue
+ elif name_type == 'route6' and item == 'ipv4':
+ continue
+ else:
+ for policy_name, policy_conf in policy[name_type].items():
+ if 'rule' not in policy_conf:
+ continue
+ for rule_id, rule_conf in policy_conf['rule'].items():
+ source_group = dict_search_args(rule_conf, 'source', 'group', group_type)
+ dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type)
+ in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group')
+ out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group')
+ if source_group:
+ if source_group[0] == "!":
+ source_group = source_group[1:]
+ if group_name == source_group:
+ out.append(f'{name_type}-{policy_name}-{rule_id}')
+ if dest_group:
+ if dest_group[0] == "!":
+ dest_group = dest_group[1:]
+ if group_name == dest_group:
+ out.append(f'{name_type}-{policy_name}-{rule_id}')
+ if in_interface:
+ if in_interface[0] == "!":
+ in_interface = in_interface[1:]
+ if group_name == in_interface:
+ out.append(f'{name_type}-{policy_name}-{rule_id}')
+ if out_interface:
+ if out_interface[0] == "!":
+ out_interface = out_interface[1:]
+ if group_name == out_interface:
+ out.append(f'{name_type}-{policy_name}-{rule_id}')
+
+ ## Look references in nat table
+ for direction in ['source', 'destination']:
+ if direction in nat:
+ if 'rule' not in nat[direction]:
+ continue
+ for rule_id, rule_conf in nat[direction]['rule'].items():
+ source_group = dict_search_args(rule_conf, 'source', 'group', group_type)
+ dest_group = dict_search_args(rule_conf, 'destination', 'group', group_type)
+ in_interface = dict_search_args(rule_conf, 'inbound_interface', 'group')
+ out_interface = dict_search_args(rule_conf, 'outbound_interface', 'group')
+ if source_group:
+ if source_group[0] == "!":
+ source_group = source_group[1:]
+ if group_name == source_group:
+ out.append(f'nat-{direction}-{rule_id}')
+ if dest_group:
+ if dest_group[0] == "!":
+ dest_group = dest_group[1:]
+ if group_name == dest_group:
+ out.append(f'nat-{direction}-{rule_id}')
+ if in_interface:
+ if in_interface[0] == "!":
+ in_interface = in_interface[1:]
+ if group_name == in_interface:
+ out.append(f'nat-{direction}-{rule_id}')
+ if out_interface:
+ if out_interface[0] == "!":
+ out_interface = out_interface[1:]
+ if group_name == out_interface:
+ out.append(f'nat-{direction}-{rule_id}')
+
+ return out
+
+ rows = []
+ header_tail = []
+
+ for group_type, group_type_conf in firewall['group'].items():
+ ##
+ if group_type != 'dynamic_group':
+
+ for group_name, group_conf in group_type_conf.items():
+ if name and name != group_name:
+ continue
+
+ references = find_references(group_type, group_name)
+ row = [group_name, textwrap.fill(group_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D']
+ if 'address' in group_conf:
+ row.append("\n".join(sorted(group_conf['address'])))
+ elif 'network' in group_conf:
+ row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network)))
+ elif 'mac_address' in group_conf:
+ row.append("\n".join(sorted(group_conf['mac_address'])))
+ elif 'port' in group_conf:
+ row.append("\n".join(sorted(group_conf['port'])))
+ elif 'interface' in group_conf:
+ row.append("\n".join(sorted(group_conf['interface'])))
+ else:
+ row.append('N/D')
+ rows.append(row)
+
+ else:
+ if not args.detail:
+ header_tail = ['Timeout', 'Expires']
+
+ for dynamic_type in ['address_group', 'ipv6_address_group']:
+ family = 'ipv4' if dynamic_type == 'address_group' else 'ipv6'
+ prefix = 'DA_' if dynamic_type == 'address_group' else 'DA6_'
+ if dynamic_type in firewall['group']['dynamic_group']:
+ for dynamic_name, dynamic_conf in firewall['group']['dynamic_group'][dynamic_type].items():
+ references = find_references(dynamic_type, dynamic_name)
+ row = [dynamic_name, textwrap.fill(dynamic_conf.get('description') or '', 50), dynamic_type + '(dynamic)', '\n'.join(references) or 'N/D']
+
+ members = get_nftables_group_members(family, 'vyos_filter', f'{prefix}{dynamic_name}')
+
+ if not members:
+ if args.detail:
+ row.append('N/D')
+ else:
+ row += ["N/D"] * 3
+ rows.append(row)
+ continue
+
+ for idx, member in enumerate(members):
+ if isinstance(member, str):
+ # Only member, and no timeout:
+ val = member
+ timeout = "N/D"
+ expires = "N/D"
+ else:
+ val = member.get('val', 'N/D')
+ timeout = str(member.get('timeout', 'N/D'))
+ expires = str(member.get('expires', 'N/D'))
+
+ if args.detail:
+ row.append(f'{val} (timeout: {timeout}, expires: {expires})')
+ continue
+
+ if idx > 0:
+ row = [""] * 4
+
+ row += [val, timeout, expires]
+ rows.append(row)
+
+ if args.detail:
+ header_tail += [""] * (len(members) - 1)
+ rows.append(row)
+
+ if rows:
+ print('Firewall Groups\n')
+ if args.detail:
+ header = ['Name', 'Description', 'Type', 'References', 'Members'] + header_tail
+ output_firewall_vertical(rows, header, adjust=False)
+ else:
+ header = ['Name', 'Type', 'References', 'Members'] + header_tail
+ for i in rows:
+ rows[rows.index(i)].pop(1)
+ print(tabulate.tabulate(rows, header))
+
+def show_summary():
+ print('Ruleset Summary')
+
+ conf = Config()
+ firewall = get_config_node(conf)
+
+ if not firewall:
+ return
+
+ header = ['Ruleset Hook', 'Ruleset Priority', 'Description', 'References']
+ v4_out = []
+ v6_out = []
+ br_out = []
+
+ if 'ipv4' in firewall:
+ for hook, hook_conf in firewall['ipv4'].items():
+ for prior, prior_conf in firewall['ipv4'][hook].items():
+ description = prior_conf.get('description', '')
+ v4_out.append([hook, prior, description])
+
+ if 'ipv6' in firewall:
+ for hook, hook_conf in firewall['ipv6'].items():
+ for prior, prior_conf in firewall['ipv6'][hook].items():
+ description = prior_conf.get('description', '')
+ v6_out.append([hook, prior, description])
+
+ if 'bridge' in firewall:
+ for hook, hook_conf in firewall['bridge'].items():
+ for prior, prior_conf in firewall['bridge'][hook].items():
+ description = prior_conf.get('description', '')
+ br_out.append([hook, prior, description])
+
+ if v6_out:
+ print('\nIPv6 Ruleset:\n')
+ print(tabulate.tabulate(v6_out, header) + '\n')
+
+ if v4_out:
+ print('\nIPv4 Ruleset:\n')
+ print(tabulate.tabulate(v4_out, header) + '\n')
+
+ if br_out:
+ print('\nBridge Ruleset:\n')
+ print(tabulate.tabulate(br_out, header) + '\n')
+
+ show_firewall_group()
+
+def show_statistics():
+ print('Rulesets Statistics')
+
+ conf = Config()
+ firewall = get_config_node(conf)
+
+ if not firewall:
+ return
+
+ for family in ['ipv4', 'ipv6', 'bridge']:
+ if 'global_options' in firewall:
+ if 'state_policy' in firewall['global_options']:
+ output_firewall_state_policy(family)
+
+ if family in firewall:
+ for hook, hook_conf in firewall[family].items():
+ for prior, prior_conf in firewall[family][hook].items():
+ output_firewall_name_statistics(family, hook,prior, prior_conf)
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='Action', required=False)
+ parser.add_argument('--name', help='Firewall name', required=False, action='store', nargs='?', default='')
+ parser.add_argument('--family', help='IP family', required=False, action='store', nargs='?', default='')
+ parser.add_argument('--hook', help='Firewall hook', required=False, action='store', nargs='?', default='')
+ parser.add_argument('--priority', help='Firewall priority', required=False, action='store', nargs='?', default='')
+ parser.add_argument('--rule', help='Firewall Rule ID', required=False)
+ parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true')
+ parser.add_argument('--detail', help='Firewall view select', required=False)
+
+ args = parser.parse_args()
+
+ if args.action == 'show':
+ if not args.rule:
+ show_firewall_name(args.family, args.hook, args.priority)
+ else:
+ show_firewall_rule(args.family, args.hook, args.priority, args.rule)
+ elif args.action == 'show_all':
+ show_firewall()
+ elif args.action == 'show_family':
+ show_firewall_family(args.family)
+ elif args.action == 'show_group':
+ show_firewall_group(args.name)
+ elif args.action == 'show_statistics':
+ show_statistics()
+ elif args.action == 'show_summary':
+ show_summary()
diff --git a/src/op_mode/flow_accounting_op.py b/src/op_mode/flow_accounting_op.py
new file mode 100644
index 0000000..497ccaf
--- /dev/null
+++ b/src/op_mode/flow_accounting_op.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 argparse
+import re
+import ipaddress
+import os.path
+
+from tabulate import tabulate
+from json import loads
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.logger import syslog
+
+# some default values
+uacctd_pidfile = '/var/run/uacctd.pid'
+uacctd_pipefile = '/tmp/uacctd.pipe'
+
+def parse_port(port):
+ try:
+ port_num = int(port)
+ if (port_num >= 0) and (port_num <= 65535):
+ return port_num
+ else:
+ raise ValueError("out of the 0-65535 range".format(port))
+ except ValueError as e:
+ raise ValueError("Incorrect port number \'{0}\': {1}".format(port, e))
+
+def parse_ports(arg):
+ if re.match(r'^\d+$', arg):
+ # Single port
+ port = parse_port(arg)
+ return {"type": "single", "value": port}
+ elif re.match(r'^\d+\-\d+$', arg):
+ # Port range
+ ports = arg.split("-")
+ ports = list(map(parse_port, ports))
+ if ports[0] > ports[1]:
+ raise ValueError("Malformed port range \'{0}\': lower end is greater than the higher".format(arg))
+ else:
+ return {"type": "range", "value": (ports[0], ports[1])}
+ elif re.match(r'^\d+,.*\d$', arg):
+ # Port list
+ ports = re.split(r',+', arg) # This allows duplicate commad like '1,,2,3,4'
+ ports = list(map(parse_port, ports))
+ return {"type": "list", "value": ports}
+ else:
+ raise ValueError("Malformed port spec \'{0}\'".format(arg))
+
+# check if host argument have correct format
+def check_host(host):
+ # define regex for checking
+ if not ipaddress.ip_address(host):
+ raise ValueError("Invalid host \'{}\', must be a valid IP or IPv6 address".format(host))
+
+# check if flow-accounting running
+def _uacctd_running():
+ command = 'systemctl status uacctd.service > /dev/null'
+ return run(command) == 0
+
+
+# get list of interfaces
+def _get_ifaces_dict():
+ # run command to get ifaces list
+ out = cmd('/bin/ip link show')
+
+ # read output
+ ifaces_out = out.splitlines()
+
+ # make a dictionary with interfaces and indexes
+ ifaces_dict = {}
+ regex_filter = re.compile(r'^(?P<iface_index>\d+):\ (?P<iface_name>[\w\d\.]+)[:@].*$')
+ for iface_line in ifaces_out:
+ if regex_filter.search(iface_line):
+ ifaces_dict[int(regex_filter.search(iface_line).group('iface_index'))] = regex_filter.search(iface_line).group('iface_name')
+
+ # return dictioanry
+ return ifaces_dict
+
+
+# get list of flows
+def _get_flows_list():
+ # run command to get flows list
+ out = cmd(f'/usr/bin/pmacct -s -O json -T flows -p {uacctd_pipefile}',
+ message='Failed to get flows list')
+
+ # read output
+ flows_out = out.splitlines()
+
+ # make a list with flows
+ flows_list = []
+ for flow_line in flows_out:
+ try:
+ flows_list.append(loads(flow_line))
+ except Exception as err:
+ syslog.error('Unable to read flow info: {}'.format(err))
+
+ # return list of flows
+ return flows_list
+
+
+# filter and format flows
+def _flows_filter(flows, ifaces):
+ # predefine filtered flows list
+ flows_filtered = []
+
+ # add interface names to flows
+ for flow in flows:
+ if flow['iface_in'] in ifaces:
+ flow['iface_in_name'] = ifaces[flow['iface_in']]
+ else:
+ flow['iface_in_name'] = 'unknown'
+
+ # iterate through flows list
+ for flow in flows:
+ # filter by interface
+ if cmd_args.interface:
+ if flow['iface_in_name'] != cmd_args.interface:
+ continue
+ # filter by host
+ if cmd_args.host:
+ if flow['ip_src'] != cmd_args.host and flow['ip_dst'] != cmd_args.host:
+ continue
+ # filter by ports
+ if cmd_args.ports:
+ if cmd_args.ports['type'] == 'single':
+ if flow['port_src'] != cmd_args.ports['value'] and flow['port_dst'] != cmd_args.ports['value']:
+ continue
+ else:
+ if flow['port_src'] not in cmd_args.ports['value'] and flow['port_dst'] not in cmd_args.ports['value']:
+ continue
+ # add filtered flows to new list
+ flows_filtered.append(flow)
+
+ # stop adding if we already reached top count
+ if cmd_args.top:
+ if len(flows_filtered) == cmd_args.top:
+ break
+
+ # return filtered flows
+ return flows_filtered
+
+
+# print flow table
+def _flows_table_print(flows):
+ # define headers and body
+ table_headers = ['IN_IFACE', 'SRC_MAC', 'DST_MAC', 'SRC_IP', 'DST_IP', 'SRC_PORT', 'DST_PORT', 'PROTOCOL', 'TOS', 'PACKETS', 'FLOWS', 'BYTES']
+ table_body = []
+ # convert flows to list
+ for flow in flows:
+ table_line = [
+ flow.get('iface_in_name'),
+ flow.get('mac_src'),
+ flow.get('mac_dst'),
+ flow.get('ip_src'),
+ flow.get('ip_dst'),
+ flow.get('port_src'),
+ flow.get('port_dst'),
+ flow.get('ip_proto'),
+ flow.get('tos'),
+ flow.get('packets'),
+ flow.get('flows'),
+ flow.get('bytes')
+ ]
+ table_body.append(table_line)
+ # configure and fill table
+ table = tabulate(table_body, table_headers, tablefmt="simple")
+
+ # print formatted table
+ try:
+ print(table)
+ except IOError:
+ sys.exit(0)
+ except KeyboardInterrupt:
+ sys.exit(0)
+
+
+# check if in-memory table is active
+def _check_imt():
+ if not os.path.exists(uacctd_pipefile):
+ print("In-memory table is not available")
+ sys.exit(1)
+
+
+# define program arguments
+cmd_args_parser = argparse.ArgumentParser(description='show flow-accounting')
+cmd_args_parser.add_argument('--action', choices=['show', 'clear', 'restart'], required=True, help='command to flow-accounting daemon')
+cmd_args_parser.add_argument('--filter', choices=['interface', 'host', 'ports', 'top'], required=False, nargs='*', help='filter flows to display')
+cmd_args_parser.add_argument('--interface', required=False, help='interface name for output filtration')
+cmd_args_parser.add_argument('--host', type=str, required=False, help='host address for output filtering')
+cmd_args_parser.add_argument('--ports', type=str, required=False, help='port number, range or list for output filtering')
+cmd_args_parser.add_argument('--top', type=int, required=False, help='top records for output filtering')
+# parse arguments
+cmd_args = cmd_args_parser.parse_args()
+
+try:
+ if cmd_args.host:
+ check_host(cmd_args.host)
+
+ if cmd_args.ports:
+ cmd_args.ports = parse_ports(cmd_args.ports)
+except ValueError as e:
+ print(e)
+ sys.exit(1)
+
+# main logic
+# do nothing if uacctd daemon is not running
+if not _uacctd_running():
+ print("flow-accounting is not active")
+ sys.exit(1)
+
+# restart pmacct daemon
+if cmd_args.action == 'restart':
+ if commit_in_progress():
+ print('Cannot restart flow-accounting while a commit is in progress')
+ exit(1)
+ # run command to restart flow-accounting
+ cmd('systemctl restart uacctd.service',
+ message='Failed to restart flow-accounting')
+
+# clear in-memory collected flows
+if cmd_args.action == 'clear':
+ _check_imt()
+ # run command to clear flows
+ cmd(f'/usr/bin/pmacct -e -p {uacctd_pipefile}',
+ message='Failed to clear flows')
+
+# show table with flows
+if cmd_args.action == 'show':
+ _check_imt()
+ # get interfaces index and names
+ ifaces_dict = _get_ifaces_dict()
+ # get flows
+ flows_list = _get_flows_list()
+
+ # filter and format flows
+ tabledata = _flows_filter(flows_list, ifaces_dict)
+
+ # print flows
+ _flows_table_print(tabledata)
+
+sys.exit(0)
diff --git a/src/op_mode/force_mtu_host.sh b/src/op_mode/force_mtu_host.sh
new file mode 100644
index 0000000..c72fc24
--- /dev/null
+++ b/src/op_mode/force_mtu_host.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2020 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 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/>.
+
+target=$1
+interface=$2
+
+# IPv4 header 20 byte + TCP header 20 byte
+ipv4_overhead=40
+
+# IPv6 headter 40 byte + TCP header 20 byte
+ipv6_overhead=60
+
+# If no arguments
+if [[ $# -eq 0 ]] ; then
+ echo "Target host not defined"
+ exit 1
+fi
+
+# If one argument, it's ip address. If 2, the second arg "interface"
+if [[ $# -eq 1 ]] ; then
+ mtu=$(sudo nmap -T4 --script path-mtu -F $target | grep "PMTU" | awk {'print $NF'})
+elif [[ $# -eq 2 ]]; then
+ mtu=$(sudo nmap -T4 -e $interface --script path-mtu -F $target | grep "PMTU" | awk {'print $NF'})
+fi
+
+tcpv4_mss=$(($mtu-$ipv4_overhead))
+tcpv6_mss=$(($mtu-$ipv6_overhead))
+
+echo "
+Recommended maximum values (or less) for target $target:
+---
+MTU: $mtu
+TCP-MSS: $tcpv4_mss
+TCP-MSS_IPv6: $tcpv6_mss
+"
+
diff --git a/src/op_mode/force_root-partition-auto-resize.sh b/src/op_mode/force_root-partition-auto-resize.sh
new file mode 100644
index 0000000..b39e875
--- /dev/null
+++ b/src/op_mode/force_root-partition-auto-resize.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2021 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 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/>.
+
+# ROOT_PART_DEV – root partition device path
+# ROOT_PART_NAME – root partition device name
+# ROOT_DEV_NAME – disk device name
+# ROOT_DEV – disk device path
+# ROOT_PART_NUM – number of root partition on disk
+# ROOT_DEV_SIZE – disk total size in 512 bytes sectors
+# ROOT_PART_SIZE – root partition total size in 512 bytes sectors
+# ROOT_PART_START – number of 512 bytes sector where root partition starts
+# AVAILABLE_EXTENSION_SIZE – calculation available disk space after root partition in 512 bytes sectors
+ROOT_PART_DEV=$(findmnt /usr/lib/live/mount/persistence -o source -n)
+ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3)
+ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4)
+ROOT_DEV="/dev/${ROOT_DEV_NAME}"
+ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition")
+ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size")
+ROOT_PART_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/size")
+ROOT_PART_START=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/start")
+AVAILABLE_EXTENSION_SIZE=$((ROOT_DEV_SIZE - ROOT_PART_START - ROOT_PART_SIZE - 8))
+
+#
+# Check if device have space for root partition growing up.
+#
+if [ $AVAILABLE_EXTENSION_SIZE -lt 1 ]; then
+ echo "There is no available space for root partition extension"
+ exit 0;
+fi
+
+#
+# Resize the partition and grow the filesystem.
+#
+# "print" and "Fix" directives were added to fix GPT table if it corrupted after virtual drive extension.
+# If GPT table is corrupted we'll get Fix/Ignore dialogue after "print" command.
+# "Fix" will be the answer for this dialogue.
+# If GPT table is fine and no auto-fix dialogue appeared the directive "Fix" simply will print parted utility help info.
+parted -m ${ROOT_DEV} ---pretend-input-tty > /dev/null 2>&1 <<EOF
+print
+Fix
+resizepart
+${ROOT_PART_NUM}
+Yes
+100%
+EOF
+partprobe > /dev/null 2>&1
+resize2fs ${ROOT_PART_DEV} > /dev/null 2>&1
diff --git a/src/op_mode/format_disk.py b/src/op_mode/format_disk.py
new file mode 100644
index 0000000..dc3c963
--- /dev/null
+++ b/src/op_mode/format_disk.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-2021 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 os
+import re
+
+from datetime import datetime
+
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import call
+from vyos.utils.process import cmd
+from vyos.utils.process import DEVNULL
+from vyos.utils.disk import device_from_id
+
+def list_disks():
+ disks = set()
+ with open('/proc/partitions') as partitions_file:
+ for line in partitions_file:
+ fields = line.strip().split()
+ if len(fields) == 4 and fields[3].isalpha() and fields[3] != 'name':
+ disks.add(fields[3])
+ return disks
+
+
+def is_busy(disk: str):
+ """Check if given disk device is busy by re-reading it's partition table"""
+ return call(f'blockdev --rereadpt /dev/{disk}', stderr=DEVNULL) != 0
+
+
+def backup_partitions(disk: str):
+ """Save sfdisk partitions output to a backup file"""
+
+ device_path = f'/dev/{disk}'
+ backup_ts = datetime.now().strftime('%Y%m%d-%H%M')
+ backup_file = f'/var/tmp/backup_{disk}.{backup_ts}'
+ call(f'sfdisk -d {device_path} > {backup_file}')
+ print(f'Partition table backup saved to {backup_file}')
+
+
+def list_partitions(disk: str):
+ """List partition numbers of a given disk"""
+
+ parts = set()
+ part_num_expr = re.compile(disk + '([0-9]+)')
+ with open('/proc/partitions') as partitions_file:
+ for line in partitions_file:
+ fields = line.strip().split()
+ if len(fields) == 4 and fields[3] != 'name' and part_num_expr.match(fields[3]):
+ part_idx = part_num_expr.match(fields[3]).group(1)
+ parts.add(int(part_idx))
+ return parts
+
+
+def delete_partition(disk: str, partition_idx: int):
+ cmd(f'parted /dev/{disk} rm {partition_idx}')
+
+
+def format_disk_like(target: str, proto: str):
+ cmd(f'sfdisk -d /dev/{proto} | sfdisk --force /dev/{target}')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ group = parser.add_argument_group()
+ group.add_argument('-t', '--target', type=str, required=True, help='Target device to format')
+ group.add_argument('-p', '--proto', type=str, required=True, help='Prototype device to use as reference')
+ parser.add_argument('--by-id', action='store_true', help='Specify device by disk id')
+ args = parser.parse_args()
+ target = args.target
+ proto = args.proto
+ if args.by_id:
+ target = device_from_id(target)
+ proto = device_from_id(proto)
+
+ target_disk = target
+ eligible_target_disks = list_disks()
+
+ proto_disk = proto
+ eligible_proto_disks = eligible_target_disks.copy()
+ eligible_proto_disks.remove(target_disk)
+
+ if proto_disk == target_disk:
+ print('The two disk drives must be different.')
+ exit(1)
+
+ if not os.path.exists(f'/dev/{proto_disk}'):
+ print(f'Device /dev/{proto_disk} does not exist')
+ exit(1)
+
+ if not os.path.exists('/dev/' + target_disk):
+ print(f'Device /dev/{target_disk} does not exist')
+ exit(1)
+
+ if target_disk not in eligible_target_disks:
+ print(f'Device {target_disk} can not be formatted')
+ exit(1)
+
+ if proto_disk not in eligible_proto_disks:
+ print(f'Device {proto_disk} can not be used as a prototype for {target_disk}')
+ exit(1)
+
+ if is_busy(target_disk):
+ print(f'Disk device {target_disk} is busy, unable to format')
+ exit(1)
+
+ print(f'\nThis will re-format disk {target_disk} so that it has the same disk'
+ f'\npartion sizes and offsets as {proto_disk}. This will not copy'
+ f'\ndata from {proto_disk} to {target_disk}. But this will erase all'
+ f'\ndata on {target_disk}.\n')
+
+ if not ask_yes_no('Do you wish to proceed?'):
+ print(f'Disk drive {target_disk} will not be re-formated')
+ exit(0)
+
+ print(f'Re-formating disk drive {target_disk}...')
+
+ print('Making backup copy of partitions...')
+ backup_partitions(target_disk)
+
+ print('Deleting old partitions...')
+ for p in list_partitions(target_disk):
+ delete_partition(disk=target_disk, partition_idx=p)
+
+ print(f'Creating new partitions on {target_disk} based on {proto_disk}...')
+ format_disk_like(target=target_disk, proto=proto_disk)
+ print('Done!')
diff --git a/src/op_mode/generate_interfaces_debug_archive.py b/src/op_mode/generate_interfaces_debug_archive.py
new file mode 100644
index 0000000..3059aad
--- /dev/null
+++ b/src/op_mode/generate_interfaces_debug_archive.py
@@ -0,0 +1,115 @@
+#!/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/>.
+
+from datetime import datetime
+from pathlib import Path
+from shutil import rmtree
+from socket import gethostname
+from sys import exit
+from tarfile import open as tar_open
+from vyos.utils.process import rc_cmd
+import os
+
+# define a list of commands that needs to be executed
+
+CMD_LIST: list[str] = [
+ "journalctl -b -n 500",
+ "journalctl -b -k -n 500",
+ "ip -s l",
+ "cat /proc/interrupts",
+ "cat /proc/softirqs",
+ "top -b -d 1 -n 2 -1",
+ "netstat -l",
+ "cat /proc/net/dev",
+ "cat /proc/net/softnet_stat",
+ "cat /proc/net/icmp",
+ "cat /proc/net/udp",
+ "cat /proc/net/tcp",
+ "cat /proc/net/netstat",
+ "sysctl net",
+ "timeout 10 tcpdump -c 500 -eni any port not 22"
+]
+
+CMD_INTERFACES_LIST: list[str] = [
+ "ethtool -i ",
+ "ethtool -S ",
+ "ethtool -g ",
+ "ethtool -c ",
+ "ethtool -a ",
+ "ethtool -k ",
+ "ethtool -i ",
+ "ethtool --phy-statistics "
+]
+
+# get intefaces info
+interfaces_list = os.popen('ls /sys/class/net/').read().split()
+
+# modify CMD_INTERFACES_LIST for all interfaces
+CMD_INTERFACES_LIST_MOD=[]
+for command_interface in interfaces_list:
+ for command_interfacev2 in CMD_INTERFACES_LIST:
+ CMD_INTERFACES_LIST_MOD.append (f'{command_interfacev2}{command_interface}')
+
+# execute a command and save the output to a file
+
+def save_stdout(command: str, file: Path) -> None:
+ rc, stdout = rc_cmd(command)
+ body: str = f'''### {command} ###
+Command: {command}
+Exit code: {rc}
+Stdout:
+{stdout}
+
+'''
+ with file.open(mode='a') as f:
+ f.write(body)
+
+# get local host name
+hostname: str = gethostname()
+# get current time
+time_now: str = datetime.now().isoformat(timespec='seconds')
+
+# define a temporary directory for logs and collected data
+tmp_dir: Path = Path(f'/tmp/drops-debug_{time_now}')
+# set file paths
+drops_file: Path = Path(f'{tmp_dir}/drops.txt')
+interfaces_file: Path = Path(f'{tmp_dir}/interfaces.txt')
+archive_file: str = f'/tmp/packet-drops-debug_{time_now}.tar.bz2'
+
+# create files
+tmp_dir.mkdir()
+drops_file.touch()
+interfaces_file.touch()
+
+try:
+ # execute all commands
+ for command in CMD_LIST:
+ save_stdout(command, drops_file)
+ for command_interface in CMD_INTERFACES_LIST_MOD:
+ save_stdout(command_interface, interfaces_file)
+
+ # create an archive
+ with tar_open(name=archive_file, mode='x:bz2') as tar_file:
+ tar_file.add(tmp_dir)
+
+ # inform user about success
+ print(f'Debug file is generated and located in {archive_file}')
+except Exception as err:
+ print(f'Error during generating a debug file: {err}')
+finally:
+ # cleanup
+ rmtree(tmp_dir)
+ exit()
diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py
new file mode 100644
index 0000000..ca2eeb5
--- /dev/null
+++ b/src/op_mode/generate_ipsec_debug_archive.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+
+from datetime import datetime
+from pathlib import Path
+from shutil import rmtree
+from socket import gethostname
+from sys import exit
+from tarfile import open as tar_open
+from vyos.utils.process import rc_cmd
+
+# define a list of commands that needs to be executed
+CMD_LIST: list[str] = [
+ 'swanctl -L',
+ 'swanctl -l',
+ 'swanctl -P',
+ 'ip x sa show',
+ 'ip x policy show',
+ 'ip tunnel show',
+ 'ip address',
+ 'ip rule show',
+ 'ip route | head -100',
+ 'ip route show table 220'
+]
+JOURNALCTL_CMD: str = 'journalctl --no-hostname --boot --unit strongswan.service'
+
+# execute a command and save the output to a file
+def save_stdout(command: str, file: Path) -> None:
+ rc, stdout = rc_cmd(command)
+ body: str = f'''### {command} ###
+Command: {command}
+Exit code: {rc}
+Stdout:
+{stdout}
+
+'''
+ with file.open(mode='a') as f:
+ f.write(body)
+
+
+# get local host name
+hostname: str = gethostname()
+# get current time
+time_now: str = datetime.now().isoformat(timespec='seconds')
+
+# define a temporary directory for logs and collected data
+tmp_dir: Path = Path(f'/tmp/ipsec_debug_{time_now}')
+# set file paths
+ipsec_status_file: Path = Path(f'{tmp_dir}/ipsec_status.txt')
+journalctl_charon_file: Path = Path(f'{tmp_dir}/journalctl_charon.txt')
+archive_file: str = f'/tmp/ipsec_debug_{time_now}.tar.bz2'
+
+# create files
+tmp_dir.mkdir()
+ipsec_status_file.touch()
+journalctl_charon_file.touch()
+
+try:
+ # execute all commands
+ for command in CMD_LIST:
+ save_stdout(command, ipsec_status_file)
+ save_stdout(JOURNALCTL_CMD, journalctl_charon_file)
+
+ # create an archive
+ with tar_open(name=archive_file, mode='x:bz2') as tar_file:
+ tar_file.add(tmp_dir)
+
+ # inform user about success
+ print(f'Debug file is generated and located in {archive_file}')
+except Exception as err:
+ print(f'Error during generating a debug file: {err}')
+finally:
+ # cleanup
+ rmtree(tmp_dir)
+ exit()
diff --git a/src/op_mode/generate_openconnect_otp_key.py b/src/op_mode/generate_openconnect_otp_key.py
new file mode 100644
index 0000000..99b67d2
--- /dev/null
+++ b/src/op_mode/generate_openconnect_otp_key.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 argparse
+import os
+
+from vyos.utils.process import popen
+from secrets import token_hex
+from base64 import b32encode
+
+if os.geteuid() != 0:
+ exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True)
+ parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval', default="30", required=False)
+ parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False)
+ args = parser.parse_args()
+
+ hostname = os.uname()[1]
+ username = args.username
+ digits = args.digits
+ period = args.interval
+
+ # check variables:
+ if int(digits) < 6 or int(digits) > 8:
+ print("")
+ quit("The number of digits in the one-time password must be between '6' and '8'")
+
+ if int(period) < 5 or int(period) > 86400:
+ print("")
+ quit("Time token interval must be between '5' and '86400' seconds")
+
+ # generate OTP key, URL & QR:
+ key_hex = token_hex(20)
+ key_base32 = b32encode(bytes.fromhex(key_hex)).decode()
+
+ otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period])
+ qrcode,err = popen('qrencode -t ansiutf8', input=otp_url)
+
+ print("# You can share it with the user, he just needs to scan the QR in his OTP app")
+ print("# username: ", username)
+ print("# OTP KEY: ", key_base32)
+ print("# OTP URL: ", otp_url)
+ print(qrcode)
+ print('# To add this OTP key to configuration, run the following commands:')
+ print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'")
+ if period != "30":
+ print(f"set vpn openconnect authentication local-users username {username} otp interval '{period}'")
+ if digits != "6":
+ print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{digits}'")
diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py
new file mode 100644
index 0000000..1d2f106
--- /dev/null
+++ b/src/op_mode/generate_ovpn_client_file.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 argparse
+
+from jinja2 import Template
+from textwrap import fill
+
+from vyos.config import Config
+from vyos.ifconfig import Section
+
+client_config = """
+
+client
+nobind
+remote {{ local_host if local_host else 'x.x.x.x' }} {{ port }}
+remote-cert-tls server
+proto {{ 'tcp-client' if protocol == 'tcp-passive' else 'udp' }}
+dev {{ device_type }}
+dev-type {{ device_type }}
+persist-key
+persist-tun
+verb 3
+
+# Encryption options
+{# Define the encryption map #}
+{% set encryption_map = {
+ 'des': 'DES-CBC',
+ '3des': 'DES-EDE3-CBC',
+ 'bf128': 'BF-CBC',
+ 'bf256': 'BF-CBC',
+ 'aes128gcm': 'AES-128-GCM',
+ 'aes128': 'AES-128-CBC',
+ 'aes192gcm': 'AES-192-GCM',
+ 'aes192': 'AES-192-CBC',
+ 'aes256gcm': 'AES-256-GCM',
+ 'aes256': 'AES-256-CBC'
+} %}
+
+{% if encryption is defined and encryption is not none %}
+{% if encryption.data_ciphers is defined and encryption.data_ciphers is not none %}
+cipher {% for algo in encryption.data_ciphers %}
+{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %}
+{% endfor %}
+
+data-ciphers {% for algo in encryption.data_ciphers %}
+{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %}
+{% endfor %}
+{% endif %}
+{% endif %}
+
+{% if hash is defined and hash is not none %}
+auth {{ hash }}
+{% endif %}
+{{ 'comp-lzo' if use_lzo_compression is defined else '' }}
+
+<ca>
+-----BEGIN CERTIFICATE-----
+{{ ca }}
+-----END CERTIFICATE-----
+
+</ca>
+
+<cert>
+-----BEGIN CERTIFICATE-----
+{{ cert }}
+-----END CERTIFICATE-----
+
+</cert>
+
+<key>
+-----BEGIN PRIVATE KEY-----
+{{ key }}
+-----END PRIVATE KEY-----
+
+</key>
+
+"""
+
+config = Config()
+base = ['interfaces', 'openvpn']
+
+if not config.exists(base):
+ print('OpenVPN not configured')
+ exit(0)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "-i",
+ "--interface",
+ type=str,
+ help='OpenVPN interface the client is connecting to',
+ required=True,
+ )
+ parser.add_argument(
+ "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True
+ )
+ parser.add_argument(
+ "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True
+ )
+ parser.add_argument(
+ "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store"
+ )
+ args = parser.parse_args()
+
+ interface = args.interface
+ ca = args.ca
+ cert = args.cert
+ key = args.key
+ if not key:
+ key = args.cert
+
+ if interface not in Section.interfaces('openvpn'):
+ exit(f'OpenVPN interface "{interface}" does not exist!')
+
+ if not config.exists(['pki', 'ca', ca, 'certificate']):
+ exit(f'OpenVPN CA certificate "{ca}" does not exist!')
+
+ if not config.exists(['pki', 'certificate', cert, 'certificate']):
+ exit(f'OpenVPN certificate "{cert}" does not exist!')
+
+ if not config.exists(['pki', 'certificate', cert, 'private', 'key']):
+ exit(f'OpenVPN certificate key "{key}" does not exist!')
+
+ config = config.get_config_dict(
+ base + [interface],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True,
+ with_pki=True,
+ )
+
+ ca = config['pki']['ca'][ca]['certificate']
+ ca = fill(ca, width=64)
+ cert = config['pki']['certificate'][cert]['certificate']
+ cert = fill(cert, width=64)
+ key = config['pki']['certificate'][key]['private']['key']
+ key = fill(key, width=64)
+
+ config['ca'] = ca
+ config['cert'] = cert
+ config['key'] = key
+ config['port'] = '1194' if 'local_port' not in config else config['local_port']
+
+ client = Template(client_config, trim_blocks=True).render(config)
+ print(client)
diff --git a/src/op_mode/generate_public_key_command.py b/src/op_mode/generate_public_key_command.py
new file mode 100644
index 0000000..8ba55c9
--- /dev/null
+++ b/src/op_mode/generate_public_key_command.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 os
+import sys
+import urllib.parse
+
+import vyos.remote
+from vyos.template import generate_uuid4
+
+
+def get_key(path) -> list:
+ """Get public keys from a local file or remote URL
+
+ Args:
+ path: Path to the public keys file
+
+ Returns: list of public keys split by new line
+
+ """
+ url = urllib.parse.urlparse(path)
+ if url.scheme == 'file' or url.scheme == '':
+ with open(os.path.expanduser(path), 'r') as f:
+ key_string = f.read()
+ else:
+ key_string = vyos.remote.get_remote_config(path)
+ return key_string.split('\n')
+
+
+if __name__ == "__main__":
+ first_loop = True
+
+ for k in get_key(sys.argv[2]):
+ k = k.split()
+ # Skip empty list entry
+ if k == []:
+ continue
+
+ try:
+ username = sys.argv[1]
+ # Github keys don't have identifier for example 'vyos@localhost'
+ # 'ssh-rsa AAAA... vyos@localhost'
+ # Generate uuid4 identifier
+ identifier = f'github@{generate_uuid4("")}' if sys.argv[2].startswith('https://github.com') else k[2]
+ algorithm, key = k[0], k[1]
+ except Exception as e:
+ print("Failed to retrieve the public key: {}".format(e))
+ sys.exit(1)
+
+ if first_loop:
+ print('# To add this key as an embedded key, run the following commands:')
+ print('configure')
+ print(f'set system login user {username} authentication public-keys {identifier} key {key}')
+ print(f'set system login user {username} authentication public-keys {identifier} type {algorithm}')
+
+ first_loop = False
diff --git a/src/op_mode/generate_service_rule-resequence.py b/src/op_mode/generate_service_rule-resequence.py
new file mode 100644
index 0000000..9333d63
--- /dev/null
+++ b/src/op_mode/generate_service_rule-resequence.py
@@ -0,0 +1,145 @@
+#!/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 argparse
+from vyos.configquery import ConfigTreeQuery
+
+
+def convert_to_set_commands(config_dict, parent_key=''):
+ """
+ Converts a configuration dictionary into a list of set commands.
+
+ Args:
+ config_dict (dict): The configuration dictionary.
+ parent_key (str): The parent key for nested dictionaries.
+
+ Returns:
+ list: A list of set commands.
+ """
+ commands = []
+ for key, value in config_dict.items():
+ current_key = parent_key + key if parent_key else key
+
+ if isinstance(value, dict):
+ if not value:
+ commands.append(f"set {current_key}")
+ else:
+ commands.extend(
+ convert_to_set_commands(value, f"{current_key} "))
+
+ elif isinstance(value, list):
+ for item in value:
+ commands.append(f"set {current_key} '{item}'")
+
+ elif isinstance(value, str):
+ commands.append(f"set {current_key} '{value}'")
+
+ return commands
+
+
+def change_rule_numbers(config_dict, start, step):
+ """
+ Changes rule numbers in the configuration dictionary.
+
+ Args:
+ config_dict (dict): The configuration dictionary.
+ start (int): The starting rule number.
+ step (int): The step to increment the rule numbers.
+
+ Returns:
+ None
+ """
+ if 'rule' in config_dict:
+ rule_dict = config_dict['rule']
+ updated_rule_dict = {}
+ rule_num = start
+ for rule_key in sorted(rule_dict.keys()):
+ updated_rule_dict[str(rule_num)] = rule_dict[rule_key]
+ rule_num += step
+ config_dict['rule'] = updated_rule_dict
+
+ for key in config_dict:
+ if isinstance(config_dict[key], dict):
+ change_rule_numbers(config_dict[key], start, step)
+
+
+def convert_rule_keys_to_int(config_dict, prev_key=None):
+ """
+ Converts rule keys in the configuration dictionary to integers.
+
+ Args:
+ config_dict (dict or list): The configuration dictionary or list.
+
+ Returns:
+ dict or list: The modified dictionary or list.
+ """
+ if isinstance(config_dict, dict):
+ new_dict = {}
+ for key, value in config_dict.items():
+ # Convert key to integer if possible
+ new_key = int(key) if key.isdigit() and prev_key == 'rule' else key
+
+ # Recur for nested dictionaries
+ if isinstance(value, dict):
+ new_value = convert_rule_keys_to_int(value, key)
+ else:
+ new_value = value
+
+ new_dict[new_key] = new_value
+
+ return new_dict
+ elif isinstance(config_dict, list):
+ return [convert_rule_keys_to_int(item) for item in config_dict]
+ else:
+ return config_dict
+
+
+if __name__ == "__main__":
+ # Parse command-line arguments
+ parser = argparse.ArgumentParser(description='Convert dictionary to set commands with rule number modifications.')
+ parser.add_argument('--service', type=str, help='Name of service')
+ parser.add_argument('--start', type=int, default=100, help='Start rule number (default: 100)')
+ parser.add_argument('--step', type=int, default=10, help='Step for rule numbers (default: 10)')
+ args = parser.parse_args()
+
+ config = ConfigTreeQuery()
+ if not config.exists(args.service):
+ print(f'{args.service} is not configured')
+ exit(1)
+
+ config_dict = config.get_config_dict(args.service)
+
+ if 'firewall' in config_dict:
+ # Remove global-options, group and flowtable as they don't need sequencing
+ for item in ['global-options', 'group', 'flowtable']:
+ if item in config_dict['firewall']:
+ del config_dict['firewall'][item]
+
+ # Convert rule keys to integers, rule "10" -> rule 10
+ # This is necessary for sorting the rules
+ config_dict = convert_rule_keys_to_int(config_dict)
+
+ # Apply rule number modifications
+ change_rule_numbers(config_dict, start=args.start, step=args.step)
+
+ # Convert to 'set' commands
+ set_commands = convert_to_set_commands(config_dict)
+
+ print()
+ for command in set_commands:
+ print(command)
+ print()
diff --git a/src/op_mode/generate_ssh_server_key.py b/src/op_mode/generate_ssh_server_key.py
new file mode 100644
index 0000000..d6063c4
--- /dev/null
+++ b/src/op_mode/generate_ssh_server_key.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 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/>.
+
+from sys import exit
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import cmd
+from vyos.utils.commit import commit_in_progress
+
+if not ask_yes_no('Do you really want to remove the existing SSH host keys?'):
+ exit(0)
+
+if commit_in_progress():
+ print('Cannot restart SSH while a commit is in progress')
+ exit(1)
+
+cmd('rm -v /etc/ssh/ssh_host_*')
+cmd('dpkg-reconfigure openssh-server')
+cmd('systemctl restart ssh.service')
diff --git a/src/op_mode/generate_system_login_user.py b/src/op_mode/generate_system_login_user.py
new file mode 100644
index 0000000..1b328ea
--- /dev/null
+++ b/src/op_mode/generate_system_login_user.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 argparse
+import os
+
+from vyos.utils.process import popen
+from secrets import token_hex
+from base64 import b32encode
+
+if os.geteuid() != 0:
+ exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-u", "--username", type=str, help='Username used for authentication', required=True)
+ parser.add_argument("-l", "--rate_limit", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 3)', default="3", required=False)
+ parser.add_argument("-t", "--rate_time", type=str, help='Limit number of logins (rate-limit) per rate-time (default: 30)', default="30", required=False)
+ parser.add_argument("-w", "--window_size", type=str, help='Set window of concurrently valid codes (default: 3)', default="3", required=False)
+ parser.add_argument("-i", "--interval", type=str, help='Duration of single time interval', default="30", required=False)
+ parser.add_argument("-d", "--digits", type=str, help='The number of digits in the one-time password', default="6", required=False)
+ args = parser.parse_args()
+
+ hostname = os.uname()[1]
+ username = args.username
+ rate_limit = args.rate_limit
+ rate_time = args.rate_time
+ window_size = args.window_size
+ digits = args.digits
+ period = args.interval
+
+ # check variables:
+ if int(rate_limit) < 1 or int(rate_limit) > 10:
+ print("")
+ quit("Number of logins (rate-limit) must be between '1' and '10'")
+
+ if int(rate_time) < 15 or int(rate_time) > 600:
+ print("")
+ quit("The rate-time must be between '15' and '600' seconds")
+
+ if int(window_size) < 1 or int(window_size) > 21:
+ print("")
+ quit("Window of concurrently valid codes must be between '1' and '21' seconds")
+
+ # generate OTP key, URL & QR:
+ key_hex = token_hex(20)
+ key_base32 = b32encode(bytes.fromhex(key_hex)).decode()
+
+ otp_url=''.join(["otpauth://totp/",username,"@",hostname,"?secret=",key_base32,"&digits=",digits,"&period=",period])
+ qrcode,err = popen('qrencode -t ansiutf8', input=otp_url)
+
+ print("# You can share it with the user, he just needs to scan the QR in his OTP app")
+ print("# username: ", username)
+ print("# OTP KEY: ", key_base32)
+ print("# OTP URL: ", otp_url)
+ print(qrcode)
+ print('# To add this OTP key to configuration, run the following commands:')
+ print(f"set system login user {username} authentication otp key '{key_base32}'")
+ if rate_limit != "3":
+ print(f"set system login user {username} authentication otp rate-limit '{rate_limit}'")
+ if rate_time != "30":
+ print(f"set system login user {username} authentication otp rate-time '{rate_time}'")
+ if window_size != "3":
+ print(f"set system login user {username} authentication otp window-size '{window_size}'")
diff --git a/src/op_mode/generate_tech-support_archive.py b/src/op_mode/generate_tech-support_archive.py
new file mode 100644
index 0000000..41b53cd
--- /dev/null
+++ b/src/op_mode/generate_tech-support_archive.py
@@ -0,0 +1,148 @@
+#!/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 os
+import argparse
+import glob
+from datetime import datetime
+from pathlib import Path
+from shutil import rmtree
+
+from socket import gethostname
+from sys import exit
+from tarfile import open as tar_open
+from vyos.utils.process import rc_cmd
+from vyos.remote import upload
+
+def op(cmd: str) -> str:
+ """Returns a command with the VyOS operational mode wrapper."""
+ return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}'
+
+def save_stdout(command: str, file: Path) -> None:
+ rc, stdout = rc_cmd(command)
+ body: str = f'''### {command} ###
+Command: {command}
+Exit code: {rc}
+Stdout:
+{stdout}
+
+'''
+ with file.open(mode='a') as f:
+ f.write(body)
+def __rotate_logs(path: str, log_pattern:str):
+ files_list = glob.glob(f'{path}/{log_pattern}')
+ if len(files_list) > 5:
+ oldest_file = min(files_list, key=os.path.getctime)
+ os.remove(oldest_file)
+
+
+def __generate_archived_files(location_path: str) -> None:
+ """
+ Generate arhives of main directories
+ :param location_path: path to temporary directory
+ :type location_path: str
+ """
+ # Dictionary arhive_name:directory_to_arhive
+ archive_dict = {
+ 'etc': '/etc',
+ 'home': '/home',
+ 'var-log': '/var/log',
+ 'root': '/root',
+ 'tmp': '/tmp',
+ 'core-dump': '/var/core',
+ 'config': '/opt/vyatta/etc/config'
+ }
+ # Dictionary arhive_name:excluding pattern
+ archive_excludes = {
+ # Old location of archives
+ 'config': 'tech-support-archive',
+ # New locations of arhives
+ 'tmp': 'tech-support-archive'
+ }
+ for archive_name, path in archive_dict.items():
+ archive_file: str = f'{location_path}/{archive_name}.tar.gz'
+ with tar_open(name=archive_file, mode='x:gz') as tar_file:
+ if archive_name in archive_excludes:
+ tar_file.add(path, filter=lambda x: None if str(archive_excludes[archive_name]) in str(x.name) else x)
+ else:
+ tar_file.add(path)
+
+
+def __generate_main_archive_file(archive_file: str, tmp_dir_path: str) -> None:
+ """
+ Generate main arhive file
+ :param archive_file: name of arhive file
+ :type archive_file: str
+ :param tmp_dir_path: path to arhive memeber
+ :type tmp_dir_path: str
+ """
+ with tar_open(name=archive_file, mode='x:gz') as tar_file:
+ tar_file.add(tmp_dir_path, arcname=os.path.basename(tmp_dir_path))
+
+
+if __name__ == '__main__':
+ defualt_tmp_dir = '/tmp'
+ parser = argparse.ArgumentParser()
+ parser.add_argument("path", nargs='?', default=defualt_tmp_dir)
+ args = parser.parse_args()
+ location_path = args.path[:-1] if args.path[-1] == '/' else args.path
+
+ hostname: str = gethostname()
+ time_now: str = datetime.now().isoformat(timespec='seconds').replace(":", "-")
+
+ remote = False
+ tmp_path = ''
+ tmp_dir_path = ''
+ if 'ftp://' in args.path or 'scp://' in args.path:
+ remote = True
+ tmp_path = defualt_tmp_dir
+ else:
+ tmp_path = location_path
+ archive_pattern = f'_tech-support-archive_'
+ archive_file_name = f'{hostname}{archive_pattern}{time_now}.tar.gz'
+
+ # Log rotation in tmp directory
+ if tmp_path == defualt_tmp_dir:
+ __rotate_logs(tmp_path, f'*{archive_pattern}*')
+
+ # Temporary directory creation
+ tmp_dir_path = f'{tmp_path}/drops-debug_{time_now}'
+ tmp_dir: Path = Path(tmp_dir_path)
+ tmp_dir.mkdir(parents=True)
+
+ report_file: Path = Path(f'{tmp_dir_path}/show_tech-support_report.txt')
+ report_file.touch()
+ try:
+
+ save_stdout(op('show tech-support report'), report_file)
+ # Generate included archives
+ __generate_archived_files(tmp_dir_path)
+
+ # Generate main archive
+ __generate_main_archive_file(f'{tmp_path}/{archive_file_name}', tmp_dir_path)
+ # Delete temporary directory
+ rmtree(tmp_dir)
+ # Upload to remote site if it is scpecified
+ if remote:
+ upload(f'{tmp_path}/{archive_file_name}', args.path)
+ print(f'Debug file is generated and located in {location_path}/{archive_file_name}')
+ except Exception as err:
+ print(f'Error during generating a debug file: {err}')
+ # cleanup
+ if tmp_dir.exists():
+ rmtree(tmp_dir)
+ finally:
+ # cleanup
+ exit()
diff --git a/src/op_mode/igmp-proxy.py b/src/op_mode/igmp-proxy.py
new file mode 100644
index 0000000..709e259
--- /dev/null
+++ b/src/op_mode/igmp-proxy.py
@@ -0,0 +1,97 @@
+#!/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/>.
+
+# File: show_igmpproxy.py
+# Purpose:
+# Display istatistics from IPv4 IGMP proxy.
+# Used by the "run show ip multicast" command tree.
+
+import ipaddress
+import json
+import socket
+import sys
+import tabulate
+
+import vyos.config
+import vyos.opmode
+
+from vyos.utils.convert import bytes_to_human
+from vyos.utils.io import print_error
+from vyos.utils.process import process_named_running
+
+def _is_configured():
+ """Check if IGMP proxy is configured"""
+ return vyos.config.Config().exists_effective('protocols igmp-proxy')
+
+def _kernel_to_ip(addr):
+ """
+ Convert any given address from Linux kernel to a proper, IPv4 address
+ using the correct host byte order.
+ """
+ # Convert from hex 'FE000A0A' to decimal '4261415434'
+ addr = int(addr, 16)
+ # Kernel ABI _always_ uses network byte order.
+ addr = socket.ntohl(addr)
+ return str(ipaddress.IPv4Address(addr))
+
+def _process_mr_vif():
+ """Read rows from /proc/net/ip_mr_vif into dicts."""
+ result = []
+ with open('/proc/net/ip_mr_vif', 'r') as f:
+ next(f)
+ for line in f:
+ result.append({
+ 'Interface': line.split()[1],
+ 'PktsIn' : int(line.split()[3]),
+ 'PktsOut' : int(line.split()[5]),
+ 'BytesIn' : int(line.split()[2]),
+ 'BytesOut' : int(line.split()[4]),
+ 'Local' : _kernel_to_ip(line.split()[7]),
+ })
+ return result
+
+def show_interface(raw: bool):
+ if data := _process_mr_vif():
+ if raw:
+ # Make the interface name the key for each row.
+ table = {}
+ for v in data:
+ table[v.pop('Interface')] = v
+ return json.loads(json.dumps(table))
+ # Make byte values human-readable for the table.
+ arr = []
+ for x in data:
+ arr.append({k: bytes_to_human(v) if k.startswith('Bytes') \
+ else v for k, v in x.items()})
+ return tabulate.tabulate(arr, headers='keys')
+
+
+if not _is_configured():
+ print_error('IGMP proxy is not configured.')
+ sys.exit(0)
+if not process_named_running('igmpproxy'):
+ print_error('IGMP proxy is not running.')
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ try:
+ res = vyos.opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, vyos.opmode.Error) as e:
+ print_error(e)
+ sys.exit(1)
diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py
new file mode 100644
index 0000000..cf2bc6d
--- /dev/null
+++ b/src/op_mode/ikev2_profile_generator.py
@@ -0,0 +1,318 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+
+from sys import exit
+from socket import getfqdn
+from cryptography.x509.oid import NameOID
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.config import config_dict_mangle_acme
+from vyos.pki import CERT_BEGIN
+from vyos.pki import CERT_END
+from vyos.pki import find_chain
+from vyos.pki import encode_certificate
+from vyos.pki import load_certificate
+from vyos.template import render_to_string
+from vyos.utils.io import ask_input
+
+# Apple profiles only support one IKE/ESP encryption cipher and hash, whereas
+# VyOS comes with a multitude of different proposals for a connection.
+#
+# We take all available proposals from the VyOS CLI and ask the user which one
+# he would like to get enabled in his profile - thus there is limited possibility
+# to select a proposal that is not supported on the connection profile.
+#
+# IOS supports IKE-SA encryption algorithms:
+# - DES
+# - 3DES
+# - AES-128
+# - AES-256
+# - AES-128-GCM
+# - AES-256-GCM
+# - ChaCha20Poly1305
+#
+vyos2apple_cipher = {
+ '3des' : '3DES',
+ 'aes128' : 'AES-128',
+ 'aes256' : 'AES-256',
+ 'aes128gcm128' : 'AES-128-GCM',
+ 'aes256gcm128' : 'AES-256-GCM',
+ 'chacha20poly1305' : 'ChaCha20Poly1305',
+}
+
+# Windows supports IKE-SA encryption algorithms:
+# - DES3
+# - AES128
+# - AES192
+# - AES256
+# - GCMAES128
+# - GCMAES192
+# - GCMAES256
+#
+vyos2windows_cipher = {
+ '3des' : 'DES3',
+ 'aes128' : 'AES128',
+ 'aes192' : 'AES192',
+ 'aes256' : 'AES256',
+ 'aes128gcm128' : 'GCMAES128',
+ 'aes192gcm128' : 'GCMAES192',
+ 'aes256gcm128' : 'GCMAES256',
+}
+
+# IOS supports IKE-SA integrity algorithms:
+# - SHA1-96
+# - SHA1-160
+# - SHA2-256
+# - SHA2-384
+# - SHA2-512
+#
+vyos2apple_integrity = {
+ 'sha1' : 'SHA1-96',
+ 'sha1_160' : 'SHA1-160',
+ 'sha256' : 'SHA2-256',
+ 'sha384' : 'SHA2-384',
+ 'sha512' : 'SHA2-512',
+}
+
+# Windows supports IKE-SA integrity algorithms:
+# - SHA1-96
+# - SHA1-160
+# - SHA2-256
+# - SHA2-384
+# - SHA2-512
+#
+vyos2windows_integrity = {
+ 'sha1' : 'SHA196',
+ 'sha256' : 'SHA256',
+ 'aes128gmac' : 'GCMAES128',
+ 'aes192gmac' : 'GCMAES192',
+ 'aes256gmac' : 'GCMAES256',
+}
+
+# IOS 14.2 and later do no support dh-group 1,2 and 5. Supported DH groups would
+# be: 14, 15, 16, 17, 18, 19, 20, 21, 31, 32
+vyos2apple_dh_group = {
+ '14' : '14',
+ '15' : '15',
+ '16' : '16',
+ '17' : '17',
+ '18' : '18',
+ '19' : '19',
+ '20' : '20',
+ '21' : '21',
+ '31' : '31',
+ '32' : '32'
+}
+
+# Newer versions of Windows support groups 19 and 20, albeit under a different naming convention
+vyos2windows_dh_group = {
+ '1' : 'Group1',
+ '2' : 'Group2',
+ '14' : 'Group14',
+ '19' : 'ECP256',
+ '20' : 'ECP384',
+ '24' : 'Group24'
+}
+
+# For PFS, Windows also has its own inconsistent naming scheme for each group
+vyos2windows_pfs_group = {
+ '1' : 'PFS1',
+ '2' : 'PFS2',
+ '14' : 'PFS2048',
+ '19' : 'ECP256',
+ '20' : 'ECP384',
+ '24' : 'PFS24'
+}
+
+parser = argparse.ArgumentParser()
+parser.add_argument('--os', const='all', nargs='?', choices=['ios', 'windows'], help='Operating system used for config generation', required=True)
+parser.add_argument("--connection", action="store", help='IPsec IKEv2 remote-access connection name from CLI', required=True)
+parser.add_argument("--remote", action="store", help='VPN connection remote-address where the client will connect to', required=True)
+parser.add_argument("--profile", action="store", help='IKEv2 profile name used in the profile list on the device')
+parser.add_argument("--name", action="store", help='VPN connection name as seen in the VPN application later')
+args = parser.parse_args()
+
+ipsec_base = ['vpn', 'ipsec']
+config_base = ipsec_base + ['remote-access', 'connection']
+pki_base = ['pki']
+conf = ConfigTreeQuery()
+if not conf.exists(config_base):
+ exit('IPsec remote-access is not configured!')
+if not conf.exists(pki_base):
+ exit('PKI is not configured!')
+
+profile_name = 'VyOS IKEv2 Profile'
+if args.profile:
+ profile_name = args.profile
+
+vpn_name = 'VyOS IKEv2 VPN'
+if args.name:
+ vpn_name = args.name
+
+conn_base = config_base + [args.connection]
+if not conf.exists(conn_base):
+ exit(f'IPsec remote-access connection "{args.connection}" does not exist!')
+
+data = conf.get_config_dict(conn_base, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+data['profile_name'] = profile_name
+data['vpn_name'] = vpn_name
+data['remote'] = args.remote
+# This is a reverse-DNS style unique identifier used to detect duplicate profiles
+tmp = getfqdn().split('.')
+tmp = reversed(tmp)
+data['rfqdn'] = '.'.join(tmp)
+
+if args.os == 'ios':
+ pki = conf.get_config_dict(pki_base, get_first_key=True)
+ if 'certificate' in pki:
+ for certificate in pki['certificate']:
+ pki['certificate'][certificate] = config_dict_mangle_acme(certificate, pki['certificate'][certificate])
+
+ cert_name = data['authentication']['x509']['certificate']
+
+
+ cert_data = load_certificate(pki['certificate'][cert_name]['certificate'])
+ data['cert_common_name'] = cert_data.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
+ data['ca_common_name'] = cert_data.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
+ data['ca_certificates'] = []
+
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in pki['ca'].values()} if 'ca' in pki else {}
+
+ for ca_name in data['authentication']['x509']['ca_certificate']:
+ loaded_ca_cert = load_certificate(pki['ca'][ca_name]['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ for ca in ca_full_chain:
+ tmp = {
+ 'ca_name' : ca.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
+ 'ca_chain' : encode_certificate(ca).replace(CERT_BEGIN, '').replace(CERT_END, '').replace('\n', ''),
+ }
+ data['ca_certificates'].append(tmp)
+
+ # Remove duplicate list entries for CA certificates, as they are added by their common name
+ # https://stackoverflow.com/a/9427216
+ data['ca_certificates'] = [dict(t) for t in {tuple(d.items()) for d in data['ca_certificates']}]
+
+esp_group = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group']],
+ key_mangling=('-', '_'), get_first_key=True)
+ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'], 'proposal'],
+ key_mangling=('-', '_'), get_first_key=True)
+
+# This script works only for Apple iOS/iPadOS and Windows. Both operating systems
+# have different limitations thus we load the limitations based on the operating
+# system used.
+
+vyos2client_cipher = vyos2apple_cipher if args.os == 'ios' else vyos2windows_cipher;
+vyos2client_integrity = vyos2apple_integrity if args.os == 'ios' else vyos2windows_integrity;
+vyos2client_dh_group = vyos2apple_dh_group if args.os == 'ios' else vyos2windows_dh_group
+
+def transform_pfs(pfs, ike_dh_group):
+ pfs_enabled = (pfs != 'disable')
+ if pfs == 'enable':
+ pfs_dh_group = ike_dh_group
+ elif pfs.startswith('dh-group'):
+ pfs_dh_group = pfs.removeprefix('dh-group')
+
+ if args.os == 'ios':
+ if pfs_enabled:
+ if pfs_dh_group not in set(vyos2apple_dh_group):
+ exit(f'The PFS group configured for "{args.connection}" is not supported by the client!')
+ return pfs_dh_group
+ else:
+ return None
+ else:
+ if pfs_enabled:
+ if pfs_dh_group not in set(vyos2windows_pfs_group):
+ exit(f'The PFS group configured for "{args.connection}" is not supported by the client!')
+ return vyos2windows_pfs_group[ pfs_dh_group ]
+ else:
+ return 'None'
+
+# Create a dictionary containing client conform IKE settings
+ike = {}
+count = 1
+for _, proposal in ike_proposal.items():
+ if {'dh_group', 'encryption', 'hash'} <= set(proposal):
+ if (proposal['encryption'] in set(vyos2client_cipher) and
+ proposal['hash'] in set(vyos2client_integrity) and
+ proposal['dh_group'] in set(vyos2client_dh_group)):
+
+ # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme
+ proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ]
+ proposal['hash'] = vyos2client_integrity[ proposal['hash'] ]
+ # DH group will need to be transformed later after we calculate PFS group
+
+ ike.update( { str(count) : proposal } )
+ count += 1
+
+# Create a dictionary containing client conform ESP settings
+esp = {}
+count = 1
+for _, proposal in esp_group['proposal'].items():
+ if {'encryption', 'hash'} <= set(proposal):
+ if proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity):
+ # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme
+ proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ]
+ proposal['hash'] = vyos2client_integrity[ proposal['hash'] ]
+ # Copy PFS setting from the group, if present (we will need to
+ # transform this later once the IKE group is selected)
+ proposal['pfs'] = esp_group.get('pfs', 'enable')
+
+ esp.update( { str(count) : proposal } )
+ count += 1
+try:
+ if len(ike) > 1:
+ # Propare the input questions for the user
+ tmp = '\n'
+ for number, options in ike.items():
+ tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}, DH group {options["dh_group"]}\n'
+ tmp += '\nSelect one of the above IKE groups: '
+ data['ike_encryption'] = ike[ ask_input(tmp, valid_responses=list(ike)) ]
+ elif len(ike) == 1:
+ data['ike_encryption'] = ike['1']
+ else:
+ exit(f'None of the configured IKE proposals for "{args.connection}" are supported by the client!')
+
+ if len(esp) > 1:
+ tmp = '\n'
+ for number, options in esp.items():
+ tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}\n'
+ tmp += '\nSelect one of the above ESP groups: '
+ data['esp_encryption'] = esp[ ask_input(tmp, valid_responses=list(esp)) ]
+ elif len(esp) == 1:
+ data['esp_encryption'] = esp['1']
+ else:
+ exit(f'None of the configured ESP proposals for "{args.connection}" are supported by the client!')
+
+except KeyboardInterrupt:
+ exit("Interrupted")
+
+# Transform the DH and PFS groups now that all selections are known
+data['esp_encryption']['pfs'] = transform_pfs(data['esp_encryption']['pfs'], data['ike_encryption']['dh_group'])
+data['ike_encryption']['dh_group'] = vyos2client_dh_group[ data['ike_encryption']['dh_group'] ]
+
+print('\n\n==== <snip> ====')
+if args.os == 'ios':
+ print(render_to_string('ipsec/ios_profile.j2', data))
+ print('==== </snip> ====\n')
+ print('Save the XML from above to a new file named "vyos.mobileconfig" and E-Mail it to your phone.')
+elif args.os == 'windows':
+ print(render_to_string('ipsec/windows_profile.j2', data))
+ print('==== </snip> ====\n')
diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py
new file mode 100644
index 0000000..56aefcd
--- /dev/null
+++ b/src/op_mode/image_info.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+import sys
+from typing import Union
+
+from tabulate import tabulate
+
+from vyos import opmode
+from vyos.system import disk
+from vyos.system import grub
+from vyos.system import image
+from vyos.utils.convert import bytes_to_human
+
+
+def _format_show_images_summary(images_summary: image.BootDetails) -> str:
+ headers: list[str] = ['Name', 'Default boot', 'Running']
+ table_data: list[list[str]] = list()
+ for image_item in images_summary.get('images_available', []):
+ name: str = image_item
+ if images_summary.get('image_default') == name:
+ default: str = 'Yes'
+ else:
+ default: str = ''
+
+ if images_summary.get('image_running') == name:
+ running: str = 'Yes'
+ else:
+ running: str = ''
+
+ table_data.append([name, default, running])
+ tabulated: str = tabulate(table_data, headers)
+
+ return tabulated
+
+
+def _format_show_images_details(
+ images_details: list[image.ImageDetails]) -> str:
+ headers: list[str] = [
+ 'Name', 'Version', 'Storage Read-Only', 'Storage Read-Write',
+ 'Storage Total'
+ ]
+ table_data: list[list[Union[str, int]]] = list()
+ for image_item in images_details:
+ name: str = image_item.get('name')
+ version: str = image_item.get('version')
+ disk_ro: str = bytes_to_human(image_item.get('disk_ro'),
+ precision=1, int_below_exponent=30)
+ disk_rw: str = bytes_to_human(image_item.get('disk_rw'),
+ precision=1, int_below_exponent=30)
+ disk_total: str = bytes_to_human(image_item.get('disk_total'),
+ precision=1, int_below_exponent=30)
+ table_data.append([name, version, disk_ro, disk_rw, disk_total])
+ tabulated: str = tabulate(table_data, headers,
+ colalign=('left', 'left', 'right', 'right', 'right'))
+
+ return tabulated
+
+
+def show_images_summary(raw: bool) -> Union[image.BootDetails, str]:
+ images_available: list[str] = grub.version_list()
+ root_dir: str = disk.find_persistence()
+ boot_vars: dict = grub.vars_read(f'{root_dir}/{image.CFG_VYOS_VARS}')
+
+ images_summary: image.BootDetails = dict()
+
+ images_summary['image_default'] = image.get_default_image()
+ images_summary['image_running'] = image.get_running_image()
+ images_summary['images_available'] = images_available
+ images_summary['console_type'] = boot_vars.get('console_type')
+ images_summary['console_num'] = boot_vars.get('console_num')
+
+ if raw:
+ return images_summary
+ else:
+ return _format_show_images_summary(images_summary)
+
+
+def show_images_details(raw: bool) -> Union[list[image.ImageDetails], str]:
+ images_details = image.get_images_details()
+
+ if raw:
+ return images_details
+ else:
+ return _format_show_images_details(images_details)
+
+
+if __name__ == '__main__':
+ try:
+ res = opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, opmode.Error) as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
new file mode 100644
index 0000000..bdc16de
--- /dev/null
+++ b/src/op_mode/image_installer.py
@@ -0,0 +1,1056 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from argparse import ArgumentParser, Namespace
+from pathlib import Path
+from shutil import copy, chown, rmtree, copytree
+from glob import glob
+from sys import exit
+from os import environ
+from os import readlink
+from os import getpid, getppid
+from typing import Union
+from urllib.parse import urlparse
+from passlib.hosts import linux_context
+from errno import ENOSPC
+
+from psutil import disk_partitions
+
+from vyos.configtree import ConfigTree
+from vyos.configquery import ConfigTreeQuery
+from vyos.remote import download
+from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER
+from vyos.template import render
+from vyos.utils.io import ask_input, ask_yes_no, select_entry
+from vyos.utils.file import chmod_2775
+from vyos.utils.process import cmd, run
+from vyos.version import get_remote_version, get_version_data
+
+# define text messages
+MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.'
+MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.'
+MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.'
+MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.'
+MSG_ERR_ARCHITECTURE_MISMATCH: str = 'Upgrading to a different image architecture will break your system.'
+MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.'
+MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation'
+MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.'
+MSG_INFO_INSTALL_DISKS_LIST: str = 'The following disks were found:'
+MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?'
+MSG_INFO_INSTALL_RAID_CONFIGURE: str = 'Would you like to configure RAID-1 mirroring?'
+MSG_INFO_INSTALL_RAID_FOUND_DISKS: str = 'Would you like to configure RAID-1 mirroring on them?'
+MSG_INFO_INSTALL_RAID_CHOOSE_DISKS: str = 'Would you like to choose two disks for RAID-1 mirroring?'
+MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. Continue?'
+MSG_INFO_INSTALL_RAID_CONFIRM: str = 'Installation will delete all data on both drives. Continue?'
+MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...'
+MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?'
+MSG_INPUT_CONFIG_CHOICE: str = 'The following config files are available for boot:'
+MSG_INPUT_CONFIG_CHOOSE: str = 'Which file would you like as boot config?'
+MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?'
+MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?'
+MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user:'
+MSG_INPUT_PASSWORD_CONFIRM: str = 'Please confirm password for the "vyos" user:'
+MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?'
+MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?'
+MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial)?'
+MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?'
+MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?'
+MSG_INPUT_COPY_ENC_DATA: str = 'Would you like to copy the encrypted config to the new image?'
+MSG_INPUT_CHOOSE_COPY_ENC_DATA: str = 'From which image would you like to copy the encrypted config?'
+MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?'
+MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?'
+MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.'
+MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again'
+MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\
+'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9'
+MSG_WARN_PASSWORD_CONFIRM: str = 'The entered values did not match. Try again'
+MSG_WARN_FLAVOR_MISMATCH: str = 'The running image flavor is "{0}". The new image flavor is "{1}".\n' \
+'Installing a different image flavor may cause functionality degradation or break your system.\n' \
+'Do you want to continue with installation?'
+CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB
+CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB
+# a reserved space: 2MB for header, 1 MB for BIOS partition, 256 MB for EFI
+CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2
+
+# define directories and paths
+DIR_INSTALLATION: str = '/mnt/installation'
+DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src'
+DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst'
+DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src'
+DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst'
+DIR_KERNEL_SRC: str = '/boot/'
+FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs'
+ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso'
+
+external_download_script = '/usr/libexec/vyos/simple-download.py'
+
+# default boot variables
+DEFAULT_BOOT_VARS: dict[str, str] = {
+ 'timeout': '5',
+ 'console_type': 'tty',
+ 'console_num': '0',
+ 'console_speed': '115200',
+ 'bootmode': 'normal'
+}
+
+
+def bytes_to_gb(size: int) -> float:
+ """Convert Bytes to GBytes, rounded to 1 decimal number
+
+ Args:
+ size (int): input size in bytes
+
+ Returns:
+ float: size in GB
+ """
+ return round(size / 1024**3, 1)
+
+
+def gb_to_bytes(size: float) -> int:
+ """Convert GBytes to Bytes
+
+ Args:
+ size (float): input size in GBytes
+
+ Returns:
+ int: size in bytes
+ """
+ return int(size * 1024**3)
+
+
+def find_disks() -> dict[str, int]:
+ """Find a target disk for installation
+
+ Returns:
+ dict[str, int]: a list of available disks by name and size
+ """
+ # check for available disks
+ print('Probing disks')
+ disks_available: dict[str, int] = disk.disks_size()
+ for disk_name, disk_size in disks_available.copy().items():
+ if disk_size < CONST_MIN_DISK_SIZE:
+ del disks_available[disk_name]
+ if not disks_available:
+ print(MSG_ERR_NO_DISK)
+ exit(MSG_INFO_INSTALL_EXIT)
+
+ num_disks: int = len(disks_available)
+ print(f'{num_disks} disk(s) found')
+
+ return disks_available
+
+
+def ask_root_size(available_space: int) -> int:
+ """Define a size of root partition
+
+ Args:
+ available_space (int): available space in bytes for a root partition
+
+ Returns:
+ int: defined size
+ """
+ if ask_yes_no(MSG_INPUT_ROOT_SIZE_ALL, default=True):
+ return available_space
+
+ while True:
+ root_size_gb: str = ask_input(MSG_INPUT_ROOT_SIZE_SET)
+ root_size_kbytes: int = (gb_to_bytes(float(root_size_gb))) // 1024
+
+ if root_size_kbytes > available_space:
+ print(MSG_WARN_ROOT_SIZE_TOOBIG)
+ continue
+ if root_size_kbytes < CONST_MIN_ROOT_SIZE / 1024:
+ print(MSG_WARN_ROOT_SIZE_TOOSMALL)
+ continue
+
+ return root_size_kbytes
+
+def create_partitions(target_disk: str, target_size: int,
+ prompt: bool = True) -> None:
+ """Create partitions on a target disk
+
+ Args:
+ target_disk (str): a target disk
+ target_size (int): size of disk in bytes
+ """
+ # define target rootfs size in KB (smallest unit acceptable by sgdisk)
+ available_size: int = (target_size - CONST_RESERVED_SPACE) // 1024
+ if prompt:
+ rootfs_size: int = ask_root_size(available_size)
+ else:
+ rootfs_size: int = available_size
+
+ print(MSG_INFO_INSTALL_PARTITONING)
+ raid.clear()
+ disk.disk_cleanup(target_disk)
+ disk_details: disk.DiskDetails = disk.parttable_create(target_disk,
+ rootfs_size)
+
+ return disk_details
+
+
+def search_format_selection(image: tuple[str, str]) -> str:
+ """Format a string for selection of image
+
+ Args:
+ image (tuple[str, str]): a tuple of image name and drive
+
+ Returns:
+ str: formatted string
+ """
+ return f'{image[0]} on {image[1]}'
+
+
+def search_previous_installation(disks: list[str]) -> None:
+ """Search disks for previous installation config and SSH keys
+
+ Args:
+ disks (list[str]): a list of available disks
+ """
+ mnt_config = '/mnt/config'
+ mnt_encrypted_config = '/mnt/encrypted_config'
+ mnt_ssh = '/mnt/ssh'
+ mnt_tmp = '/mnt/tmp'
+ rmtree(Path(mnt_config), ignore_errors=True)
+ rmtree(Path(mnt_ssh), ignore_errors=True)
+ Path(mnt_tmp).mkdir(exist_ok=True)
+ Path(mnt_encrypted_config).unlink(missing_ok=True)
+
+ print('Searching for data from previous installations')
+ image_data = []
+ encrypted_configs = []
+ for disk_name in disks:
+ for partition in disk.partition_list(disk_name):
+ if disk.partition_mount(partition, mnt_tmp):
+ if Path(mnt_tmp + '/boot').exists():
+ for path in Path(mnt_tmp + '/boot').iterdir():
+ if path.joinpath('rw/config/.vyatta_config').exists():
+ image_data.append((path.name, partition))
+ if Path(mnt_tmp + '/luks').exists():
+ for path in Path(mnt_tmp + '/luks').iterdir():
+ encrypted_configs.append((path.name, partition))
+
+ disk.partition_umount(partition)
+
+ image_name = None
+ image_drive = None
+ encrypted = False
+
+ if len(image_data) > 0:
+ if len(image_data) == 1:
+ print('Found data from previous installation:')
+ print(f'\t{" on ".join(image_data[0])}')
+ if ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
+ image_name, image_drive = image_data[0]
+
+ elif len(image_data) > 1:
+ print('Found data from previous installations')
+ if ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
+ image_name, image_drive = select_entry(image_data,
+ 'Available versions:',
+ MSG_INPUT_CHOOSE_COPY_DATA,
+ search_format_selection)
+ elif len(encrypted_configs) > 0:
+ if len(encrypted_configs) == 1:
+ print('Found encrypted config from previous installation:')
+ print(f'\t{" on ".join(encrypted_configs[0])}')
+ if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True):
+ image_name, image_drive = encrypted_configs[0]
+ encrypted = True
+
+ elif len(encrypted_configs) > 1:
+ print('Found encrypted configs from previous installations')
+ if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True):
+ image_name, image_drive = select_entry(encrypted_configs,
+ 'Available versions:',
+ MSG_INPUT_CHOOSE_COPY_ENC_DATA,
+ search_format_selection)
+ encrypted = True
+
+ else:
+ print('No previous installation found')
+ return
+
+ if not image_name:
+ return
+
+ disk.partition_mount(image_drive, mnt_tmp)
+
+ if not encrypted:
+ copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config)
+ else:
+ copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config)
+
+ Path(mnt_ssh).mkdir()
+ host_keys: list[str] = glob(f'{mnt_tmp}/boot/{image_name}/rw/etc/ssh/ssh_host*')
+ for host_key in host_keys:
+ copy(host_key, mnt_ssh)
+
+ disk.partition_umount(image_drive)
+
+def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True):
+ if not Path(src).is_file():
+ return
+ if Path(dst).is_dir():
+ dst = Path(dst).joinpath(Path(src).name)
+ st = Path(src).stat()
+ copy(src, dst, follow_symlinks=follow_symlinks)
+ chown(dst, user=st.st_uid)
+
+
+def copy_previous_installation_data(target_dir: str) -> None:
+ if Path('/mnt/config').exists():
+ copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config',
+ dirs_exist_ok=True)
+ if Path('/mnt/ssh').exists():
+ copytree('/mnt/ssh', f'{target_dir}/etc/ssh',
+ dirs_exist_ok=True)
+
+
+def copy_previous_encrypted_config(target_dir: str, image_name: str) -> None:
+ if Path('/mnt/encrypted_config').exists():
+ Path(target_dir).mkdir(exist_ok=True)
+ copy('/mnt/encrypted_config', Path(target_dir).joinpath(image_name))
+
+
+def ask_single_disk(disks_available: dict[str, int]) -> str:
+ """Ask user to select a disk for installation
+
+ Args:
+ disks_available (dict[str, int]): a list of available disks
+ """
+ print(MSG_INFO_INSTALL_DISKS_LIST)
+ default_disk: str = list(disks_available)[0]
+ for disk_name, disk_size in disks_available.items():
+ disk_size_human: str = bytes_to_gb(disk_size)
+ print(f'Drive: {disk_name} ({disk_size_human} GB)')
+ disk_selected: str = ask_input(MSG_INFO_INSTALL_DISK_SELECT,
+ default=default_disk,
+ valid_responses=list(disks_available))
+
+ # create partitions
+ if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ search_previous_installation(list(disks_available))
+
+ disk_details: disk.DiskDetails = create_partitions(disk_selected,
+ disks_available[disk_selected])
+
+ disk.filesystem_create(disk_details.partition['efi'], 'efi')
+ disk.filesystem_create(disk_details.partition['root'], 'ext4')
+
+ return disk_details
+
+
+def check_raid_install(disks_available: dict[str, int]) -> Union[str, None]:
+ """Ask user to select disks for RAID installation
+
+ Args:
+ disks_available (dict[str, int]): a list of available disks
+ """
+ if len(disks_available) < 2:
+ return None
+
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIGURE, default=True):
+ return None
+
+ def format_selection(disk_name: str) -> str:
+ return f'{disk_name}\t({bytes_to_gb(disks_available[disk_name])} GB)'
+
+ disk0, disk1 = list(disks_available)[0], list(disks_available)[1]
+ disks_selected: dict[str, int] = { disk0: disks_available[disk0],
+ disk1: disks_available[disk1] }
+
+ target_size: int = min(disks_selected[disk0], disks_selected[disk1])
+
+ print(MSG_INFO_INSTALL_DISKS_LIST)
+ for disk_name, disk_size in disks_selected.items():
+ disk_size_human: str = bytes_to_gb(disk_size)
+ print(f'\t{disk_name} ({disk_size_human} GB)')
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_FOUND_DISKS, default=True):
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CHOOSE_DISKS, default=True):
+ return None
+ else:
+ disks_selected = {}
+ disk0 = select_entry(list(disks_available), 'Disks available:',
+ 'Select first disk:', format_selection)
+
+ disks_selected[disk0] = disks_available[disk0]
+ del disks_available[disk0]
+ disk1 = select_entry(list(disks_available), 'Remaining disks:',
+ 'Select second disk:', format_selection)
+ disks_selected[disk1] = disks_available[disk1]
+
+ target_size: int = min(disks_selected[disk0],
+ disks_selected[disk1])
+
+ # create partitions
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIRM):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ search_previous_installation(list(disks_available))
+
+ disks: list[disk.DiskDetails] = []
+ for disk_selected in list(disks_selected):
+ print(f'Creating partitions on {disk_selected}')
+ disk_details = create_partitions(disk_selected, target_size,
+ prompt=False)
+ disk.filesystem_create(disk_details.partition['efi'], 'efi')
+
+ disks.append(disk_details)
+
+ print('Creating RAID array')
+ members = [disk.partition['root'] for disk in disks]
+ raid_details: raid.RaidDetails = raid.raid_create(members)
+ # raid init stuff
+ print('Updating initramfs')
+ raid.update_initramfs()
+ # end init
+ print('Creating filesystem on RAID array')
+ disk.filesystem_create(raid_details.name, 'ext4')
+
+ return raid_details
+
+
+def prepare_tmp_disr() -> None:
+ """Create temporary directories for installation
+ """
+ print('Creating temporary directories')
+ for dir in [DIR_ROOTFS_SRC, DIR_ROOTFS_DST, DIR_DST_ROOT]:
+ dirpath = Path(dir)
+ dirpath.mkdir(mode=0o755, parents=True)
+
+
+def setup_grub(root_dir: str) -> None:
+ """Install GRUB configurations
+
+ Args:
+ root_dir (str): a path to the root of target filesystem
+ """
+ print('Installing GRUB configuration files')
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_DIR_MAIN}/grub.cfg'
+ grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+ grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}'
+ grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}'
+ grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}'
+
+ # create new files
+ render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {})
+ grub.common_write(root_dir)
+ grub.vars_write(grub_cfg_vars, DEFAULT_BOOT_VARS)
+ grub.modules_write(grub_cfg_modules, [])
+ grub.write_cfg_ver(1, root_dir)
+ render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {})
+ render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {})
+
+
+def configure_authentication(config_file: str, password: str) -> None:
+ """Write encrypted password to config file
+
+ Args:
+ config_file (str): path of target config file
+ password (str): plaintext password
+
+ N.B. this can not be deferred by simply setting the plaintext password
+ and relying on the config mode script to process at boot, as the config
+ will not automatically be saved in that case, thus leaving the
+ plaintext exposed
+ """
+ encrypted_password = linux_context.hash(password)
+
+ with open(config_file) as f:
+ config_string = f.read()
+
+ config = ConfigTree(config_string)
+ config.set([
+ 'system', 'login', 'user', 'vyos', 'authentication',
+ 'encrypted-password'
+ ],
+ value=encrypted_password,
+ replace=True)
+ config.set_tag(['system', 'login', 'user'])
+
+ with open(config_file, 'w') as f:
+ f.write(config.to_string())
+
+def validate_signature(file_path: str, sign_type: str) -> None:
+ """Validate a file by signature and delete a signature file
+
+ Args:
+ file_path (str): a path to file
+ sign_type (str): a signature type
+ """
+ print('Validating signature')
+ signature_valid: bool = False
+ # validate with minisig
+ if sign_type == 'minisig':
+ pub_key_list = glob('/usr/share/vyos/keys/*.minisign.pub')
+ for pubkey in pub_key_list:
+ if run(f'minisign -V -q -p {pubkey} -m {file_path} -x {file_path}.minisig'
+ ) == 0:
+ signature_valid = True
+ break
+ Path(f'{file_path}.minisig').unlink()
+ # validate with GPG
+ if sign_type == 'asc':
+ if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0:
+ signature_valid = True
+ Path(f'{file_path}.asc').unlink()
+
+ # warn or pass
+ if not signature_valid:
+ if not ask_yes_no(MSG_WARN_ISO_SIGN_INVALID, default=False):
+ exit(MSG_INFO_INSTALL_EXIT)
+ else:
+ print('Signature is valid')
+
+def download_file(local_file: str, remote_path: str, vrf: str,
+ username: str, password: str,
+ progressbar: bool = False, check_space: bool = False):
+ environ['REMOTE_USERNAME'] = username
+ environ['REMOTE_PASSWORD'] = password
+ if vrf is None:
+ download(local_file, remote_path, progressbar=progressbar,
+ check_space=check_space, raise_error=True)
+ else:
+ vrf_cmd = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \
+ ip vrf exec {vrf} {external_download_script} \
+ --local-file {local_file} --remote-path {remote_path}'
+ cmd(vrf_cmd)
+
+def image_fetch(image_path: str, vrf: str = None,
+ username: str = '', password: str = '',
+ no_prompt: bool = False) -> Path:
+ """Fetch an ISO image
+
+ Args:
+ image_path (str): a path, remote or local
+
+ Returns:
+ Path: a path to a local file
+ """
+ # Latest version gets url from configured "system update-check url"
+ if image_path == 'latest':
+ config = ConfigTreeQuery()
+ if config.exists('system update-check url'):
+ configured_url_version = config.value('system update-check url')
+ remote_url_list = get_remote_version(configured_url_version)
+ image_path = remote_url_list[0].get('url')
+
+ try:
+ # check a type of path
+ if urlparse(image_path).scheme:
+ # download an image
+ download_file(ISO_DOWNLOAD_PATH, image_path, vrf,
+ username, password,
+ progressbar=True, check_space=True)
+
+ # download a signature
+ sign_file = (False, '')
+ for sign_type in ['minisig', 'asc']:
+ try:
+ download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}',
+ f'{image_path}.{sign_type}', vrf,
+ username, password)
+ sign_file = (True, sign_type)
+ break
+ except Exception:
+ print(f'{sign_type} signature is not available')
+ # validate a signature if it is available
+ if sign_file[0]:
+ validate_signature(ISO_DOWNLOAD_PATH, sign_file[1])
+ else:
+ if (not no_prompt and
+ not ask_yes_no(MSG_WARN_ISO_SIGN_UNAVAL, default=False)):
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
+
+ return Path(ISO_DOWNLOAD_PATH)
+ else:
+ local_path: Path = Path(image_path)
+ if local_path.is_file():
+ return local_path
+ else:
+ raise FileNotFoundError
+ except Exception as e:
+ print(f'The image cannot be fetched from: {image_path} {e}')
+ exit(1)
+
+
+def migrate_config() -> bool:
+ """Check for active config and ask user for migration
+
+ Returns:
+ bool: user's decision
+ """
+ active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot')
+ if active_config_path.exists():
+ if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True):
+ return True
+ return False
+
+
+def copy_ssh_host_keys() -> bool:
+ """Ask user to copy SSH host keys
+
+ Returns:
+ bool: user's decision
+ """
+ if ask_yes_no('Would you like to copy SSH host keys?', default=True):
+ return True
+ return False
+
+
+def console_hint() -> str:
+ pid = getppid() if 'SUDO_USER' in environ else getpid()
+ try:
+ path = readlink(f'/proc/{pid}/fd/1')
+ except OSError:
+ path = '/dev/tty'
+
+ name = Path(path).name
+ if name == 'ttyS0':
+ return 'S'
+ else:
+ return 'K'
+
+
+def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None:
+ """Clean up after installation
+
+ Args:
+ mounts (list[str], optional): List of mounts to unmount.
+ Defaults to [].
+ remove_items (list[str], optional): List of files or directories
+ to remove. Defaults to [].
+ """
+ print('Cleaning up')
+ # clean up installation directory by default
+ mounts_all = disk_partitions(all=True)
+ for mounted_device in mounts_all:
+ if mounted_device.mountpoint.startswith(DIR_INSTALLATION) and not (
+ mounted_device.device in mounts or
+ mounted_device.mountpoint in mounts):
+ mounts.append(mounted_device.mountpoint)
+ # add installation dir to cleanup list
+ if DIR_INSTALLATION not in remove_items:
+ remove_items.append(DIR_INSTALLATION)
+ # also delete an ISO file
+ if Path(ISO_DOWNLOAD_PATH).exists(
+ ) and ISO_DOWNLOAD_PATH not in remove_items:
+ remove_items.append(ISO_DOWNLOAD_PATH)
+
+ if mounts:
+ print('Unmounting target filesystems')
+ for mountpoint in mounts:
+ disk.partition_umount(mountpoint)
+ for mountpoint in mounts:
+ disk.wait_for_umount(mountpoint)
+ if remove_items:
+ print('Removing temporary files')
+ for remove_item in remove_items:
+ if Path(remove_item).exists():
+ if Path(remove_item).is_file():
+ Path(remove_item).unlink()
+ if Path(remove_item).is_dir():
+ rmtree(remove_item, ignore_errors=True)
+
+
+def cleanup_raid(details: raid.RaidDetails) -> None:
+ efiparts = []
+ for raid_disk in details.disks:
+ efiparts.append(raid_disk.partition['efi'])
+ cleanup([details.name, *efiparts],
+ ['/mnt/installation'])
+
+
+def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -> bool:
+ """Check if installation target is a RAID array
+
+ Args:
+ install_object (Union[disk.DiskDetails, raid.RaidDetails]): a target disk
+
+ Returns:
+ bool: True if it is a RAID array
+ """
+ if isinstance(install_object, raid.RaidDetails):
+ return True
+ return False
+
+
+def validate_compatibility(iso_path: str) -> None:
+ """Check architecture and flavor compatibility with the running image
+
+ Args:
+ iso_path (str): a path to the mounted ISO image
+ """
+ old_data = get_version_data()
+ old_flavor = old_data.get('flavor', '')
+ old_architecture = old_data.get('architecture') or cmd('dpkg --print-architecture')
+
+ new_data = get_version_data(f'{iso_path}/version.json')
+ new_flavor = new_data.get('flavor', '')
+ new_architecture = new_data.get('architecture', '')
+
+ if not old_architecture == new_architecture:
+ print(MSG_ERR_ARCHITECTURE_MISMATCH)
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
+
+ if not old_flavor == new_flavor:
+ if not ask_yes_no(MSG_WARN_FLAVOR_MISMATCH.format(old_flavor, new_flavor), default=False):
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
+
+
+def install_image() -> None:
+ """Install an image to a disk
+ """
+ if not image.is_live_boot():
+ exit(MSG_ERR_NOT_LIVE)
+
+ print(MSG_INFO_INSTALL_WELCOME)
+ if not ask_yes_no('Would you like to continue?'):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ # configure image name
+ running_image_name: str = image.get_running_image()
+ while True:
+ image_name: str = ask_input(MSG_INPUT_IMAGE_NAME,
+ running_image_name)
+ if image.validate_name(image_name):
+ break
+ print(MSG_WARN_IMAGE_NAME_WRONG)
+
+ # ask for password
+ while True:
+ user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True,
+ non_empty=True)
+ confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True,
+ non_empty=True)
+ if user_password == confirm:
+ break
+ print(MSG_WARN_PASSWORD_CONFIRM)
+
+ # ask for default console
+ console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE,
+ default=console_hint(),
+ valid_responses=['K', 'S'])
+ console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS'}
+
+ config_boot_list = ['/opt/vyatta/etc/config/config.boot',
+ '/opt/vyatta/etc/config.boot.default']
+ default_config = config_boot_list[0]
+
+ disks: dict[str, int] = find_disks()
+
+ install_target: Union[disk.DiskDetails, raid.RaidDetails, None] = None
+ try:
+ install_target = check_raid_install(disks)
+ if install_target is None:
+ install_target = ask_single_disk(disks)
+
+ # if previous install was selected in search_previous_installation,
+ # directory /mnt/config was prepared for copy below; if not, prompt:
+ if not Path('/mnt/config').exists():
+ default_config: str = select_entry(config_boot_list,
+ MSG_INPUT_CONFIG_CHOICE,
+ MSG_INPUT_CONFIG_CHOOSE,
+ default_entry=1) # select_entry indexes from 1
+
+ # create directories for installation media
+ prepare_tmp_disr()
+
+ # mount target filesystem and create required dirs inside
+ print('Mounting new partitions')
+ if is_raid_install(install_target):
+ disk.partition_mount(install_target.name, DIR_DST_ROOT)
+ Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
+ else:
+ disk.partition_mount(install_target.partition['root'], DIR_DST_ROOT)
+ Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
+ disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi')
+
+ # a config dir. It is the deepest one, so the comand will
+ # create all the rest in a single step
+ print('Creating a configuration file')
+ target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/'
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ # copy config
+ copy(default_config, f'{target_config_dir}/config.boot')
+ configure_authentication(f'{target_config_dir}/config.boot',
+ user_password)
+ Path(f'{target_config_dir}/.vyatta_config').touch()
+
+ # create a persistence.conf
+ Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n')
+
+ # copy system image and kernel files
+ print('Copying system image files')
+ for file in Path(DIR_KERNEL_SRC).iterdir():
+ if file.is_file():
+ copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/')
+ copy(FILE_ROOTFS_SRC,
+ f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs')
+
+ # copy saved config data and SSH keys
+ # owner restored on copy of config data by chmod_2775, above
+ copy_previous_installation_data(f'{DIR_DST_ROOT}/boot/{image_name}/rw')
+
+ # copy saved encrypted config volume
+ copy_previous_encrypted_config(f'{DIR_DST_ROOT}/luks', image_name)
+
+ if is_raid_install(install_target):
+ write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw'
+ raid.update_default(write_dir)
+
+ setup_grub(DIR_DST_ROOT)
+ # add information about version
+ grub.create_structure()
+ grub.version_add(image_name, DIR_DST_ROOT)
+ grub.set_default(image_name, DIR_DST_ROOT)
+ grub.set_console_type(console_dict[console_type], DIR_DST_ROOT)
+
+ if is_raid_install(install_target):
+ # add RAID specific modules
+ grub.modules_write(f'{DIR_DST_ROOT}/{grub.CFG_VYOS_MODULES}',
+ ['part_msdos', 'part_gpt', 'diskfilter',
+ 'ext2','mdraid1x'])
+ # install GRUB
+ if is_raid_install(install_target):
+ print('Installing GRUB to the drives')
+ l = install_target.disks
+ for disk_target in l:
+ disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi')
+ grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/',
+ f'{DIR_DST_ROOT}/boot/efi',
+ id=f'VyOS (RAID disk {l.index(disk_target) + 1})')
+ disk.partition_umount(disk_target.partition['efi'])
+ else:
+ print('Installing GRUB to the drive')
+ grub.install(install_target.name, f'{DIR_DST_ROOT}/boot/',
+ f'{DIR_DST_ROOT}/boot/efi')
+
+ # sort inodes (to make GRUB read config files in alphabetical order)
+ grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS}')
+ grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS_VERS}')
+
+ # umount filesystems and remove temporary files
+ if is_raid_install(install_target):
+ cleanup([install_target.name],
+ ['/mnt/installation'])
+ else:
+ cleanup([install_target.partition['efi'],
+ install_target.partition['root']],
+ ['/mnt/installation'])
+
+ # we are done
+ print(MSG_INFO_INSTALL_SUCCESS)
+ exit()
+
+ except Exception as err:
+ print(f'Unable to install VyOS: {err}')
+ # unmount filesystems and clenup
+ try:
+ if install_target is not None:
+ if is_raid_install(install_target):
+ cleanup_raid(install_target)
+ else:
+ cleanup([install_target.partition['efi'],
+ install_target.partition['root']],
+ ['/mnt/installation'])
+ except Exception as err:
+ print(f'Cleanup failed: {err}')
+
+ exit(1)
+
+
+@compat.grub_cfg_update
+def add_image(image_path: str, vrf: str = None, username: str = '',
+ password: str = '', no_prompt: bool = False) -> None:
+ """Add a new image
+
+ Args:
+ image_path (str): a path to an ISO image
+ """
+ if image.is_live_boot():
+ exit(MSG_ERR_LIVE)
+
+ # fetch an image
+ iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt)
+ try:
+ # mount an ISO
+ Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True)
+ disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660')
+
+ print('Validating image compatibility')
+ validate_compatibility(DIR_ISO_MOUNT)
+
+ # check sums
+ print('Validating image checksums')
+ if not Path(DIR_ISO_MOUNT).joinpath('sha256sum.txt').exists():
+ cleanup()
+ exit(MSG_ERR_IMPROPER_IMAGE)
+ if run(f'cd {DIR_ISO_MOUNT} && sha256sum --status -c sha256sum.txt'):
+ cleanup()
+ exit('Image checksum verification failed.')
+
+ # mount rootfs (to get a system version)
+ Path(DIR_ROOTFS_SRC).mkdir(mode=0o755, parents=True)
+ disk.partition_mount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs',
+ DIR_ROOTFS_SRC, 'squashfs')
+
+ cfg_ver: str = image.get_image_tools_version(DIR_ROOTFS_SRC)
+ version_name: str = image.get_image_version(DIR_ROOTFS_SRC)
+
+ disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs')
+
+ if cfg_ver < SYSTEM_CFG_VER:
+ raise compat.DowngradingImageTools(
+ f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed')
+
+ if not no_prompt:
+ while True:
+ image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name)
+ if image.validate_name(image_name):
+ break
+ print(MSG_WARN_IMAGE_NAME_WRONG)
+ set_as_default: bool = ask_yes_no(MSG_INPUT_IMAGE_DEFAULT, default=True)
+ else:
+ image_name: str = version_name
+ set_as_default: bool = True
+
+ # find target directory
+ root_dir: str = disk.find_persistence()
+
+ # a config dir. It is the deepest one, so the comand will
+ # create all the rest in a single step
+ target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/'
+ # copy config
+ if no_prompt or migrate_config():
+ print('Copying configuration directory')
+ # copytree preserves perms but not ownership:
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ copytree('/opt/vyatta/etc/config/', target_config_dir,
+ copy_function=copy_preserve_owner, dirs_exist_ok=True)
+ else:
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ Path(f'{target_config_dir}/.vyatta_config').touch()
+
+ target_ssh_dir: str = f'{root_dir}/boot/{image_name}/rw/etc/ssh/'
+ if no_prompt or copy_ssh_host_keys():
+ print('Copying SSH host keys')
+ Path(target_ssh_dir).mkdir(parents=True)
+ host_keys: list[str] = glob('/etc/ssh/ssh_host*')
+ for host_key in host_keys:
+ copy(host_key, target_ssh_dir)
+
+ # copy system image and kernel files
+ print('Copying system image files')
+ for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir():
+ if file.is_file() and (file.match('initrd*') or
+ file.match('vmlinuz*')):
+ copy(file, f'{root_dir}/boot/{image_name}/')
+ copy(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs',
+ f'{root_dir}/boot/{image_name}/{image_name}.squashfs')
+
+ # unmount an ISO and cleanup
+ cleanup([str(iso_path)])
+
+ # add information about version
+ grub.version_add(image_name, root_dir)
+ if set_as_default:
+ grub.set_default(image_name, root_dir)
+
+ except OSError as e:
+ # if no space error, remove image dir and cleanup
+ if e.errno == ENOSPC:
+ cleanup(mounts=[str(iso_path)],
+ remove_items=[f'{root_dir}/boot/{image_name}'])
+ else:
+ # unmount an ISO and cleanup
+ cleanup([str(iso_path)])
+ exit(f'Error: {e}')
+
+ except Exception as err:
+ # unmount an ISO and cleanup
+ cleanup([str(iso_path)])
+ exit(f'Error: {err}')
+
+
+def parse_arguments() -> Namespace:
+ """Parse arguments
+
+ Returns:
+ Namespace: a namespace with parsed arguments
+ """
+ parser: ArgumentParser = ArgumentParser(
+ description='Install new system images')
+ parser.add_argument('--action',
+ choices=['install', 'add'],
+ required=True,
+ help='action to perform with an image')
+ parser.add_argument('--vrf',
+ help='vrf name for image download')
+ parser.add_argument('--no-prompt', action='store_true',
+ help='perform action non-interactively')
+ parser.add_argument('--username', default='',
+ help='username for image download')
+ parser.add_argument('--password', default='',
+ help='password for image download')
+ parser.add_argument('--image-path',
+ help='a path (HTTP or local file) to an image that needs to be installed'
+ )
+ # parser.add_argument('--image_new_name', help='a new name for image')
+ args: Namespace = parser.parse_args()
+ # Validate arguments
+ if args.action == 'add' and not args.image_path:
+ exit('A path to image is required for add action')
+
+ return args
+
+
+if __name__ == '__main__':
+ try:
+ args: Namespace = parse_arguments()
+ if args.action == 'install':
+ install_image()
+ if args.action == 'add':
+ add_image(args.image_path, args.vrf,
+ args.username, args.password, args.no_prompt)
+
+ exit()
+
+ except KeyboardInterrupt:
+ print('Stopped by Ctrl+C')
+ cleanup()
+ exit()
+
+ except Exception as err:
+ exit(f'{err}')
diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py
new file mode 100644
index 0000000..fb4286d
--- /dev/null
+++ b/src/op_mode/image_manager.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from argparse import ArgumentParser, Namespace
+from pathlib import Path
+from shutil import rmtree
+from sys import exit
+from typing import Optional, Literal, TypeAlias, get_args
+
+from vyos.system import disk, grub, image, compat
+from vyos.utils.io import ask_yes_no, select_entry
+
+SET_IMAGE_LIST_MSG: str = 'The following images are available:'
+SET_IMAGE_PROMPT_MSG: str = 'Select an image to set as default:'
+DELETE_IMAGE_LIST_MSG: str = 'The following images are installed:'
+DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:'
+MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first'
+MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first'
+
+ConsoleType: TypeAlias = Literal['tty', 'ttyS']
+
+def annotate_list(images_list: list[str]) -> list[str]:
+ """Annotate list of images with additional info
+
+ Args:
+ images_list (list[str]): a list of image names
+
+ Returns:
+ dict[str, str]: a dict of annotations indexed by image name
+ """
+ running = image.get_running_image()
+ default = image.get_default_image()
+ annotated = {}
+ for image_name in images_list:
+ annotated[image_name] = f'{image_name}'
+ if running in images_list:
+ annotated[running] = annotated[running] + ' (running)'
+ if default in images_list:
+ annotated[default] = annotated[default] + ' (default boot)'
+ return annotated
+
+def define_format(images):
+ annotated = annotate_list(images)
+ def format_selection(image_name):
+ return annotated[image_name]
+ return format_selection
+
+@compat.grub_cfg_update
+def delete_image(image_name: Optional[str] = None,
+ no_prompt: bool = False) -> None:
+ """Remove installed image files and boot entry
+
+ Args:
+ image_name (str): a name of image to delete
+ """
+ available_images: list[str] = grub.version_list()
+ format_selection = define_format(available_images)
+ if image_name is None:
+ if no_prompt:
+ exit('An image name is required for delete action')
+ else:
+ image_name = select_entry(available_images,
+ DELETE_IMAGE_LIST_MSG,
+ DELETE_IMAGE_PROMPT_MSG,
+ format_selection)
+ if image_name == image.get_running_image():
+ exit(MSG_DELETE_IMAGE_RUNNING)
+ if image_name == image.get_default_image():
+ exit(MSG_DELETE_IMAGE_DEFAULT)
+ if image_name not in available_images:
+ exit(f'The image "{image_name}" cannot be found')
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ if (not no_prompt and
+ not ask_yes_no(f'Do you really want to delete the image {image_name}?',
+ default=False)):
+ exit()
+
+ # remove files and menu entry
+ version_path: Path = Path(f'{persistence_storage}/boot/{image_name}')
+ try:
+ rmtree(version_path)
+ grub.version_del(image_name, persistence_storage)
+ print(f'The image "{image_name}" was successfully deleted')
+ except Exception as err:
+ exit(f'Unable to remove the image "{image_name}": {err}')
+
+ # remove LUKS volume if it exists
+ luks_path: Path = Path(f'{persistence_storage}/luks/{image_name}')
+ if luks_path.is_file():
+ try:
+ luks_path.unlink()
+ print(f'The encrypted config for "{image_name}" was successfully deleted')
+ except Exception as err:
+ exit(f'Unable to remove the encrypted config for "{image_name}": {err}')
+
+
+@compat.grub_cfg_update
+def set_image(image_name: Optional[str] = None,
+ prompt: bool = True) -> None:
+ """Set default boot image
+
+ Args:
+ image_name (str): an image name
+ """
+ available_images: list[str] = grub.version_list()
+ format_selection = define_format(available_images)
+ if image_name is None:
+ if not prompt:
+ exit('An image name is required for set action')
+ else:
+ image_name = select_entry(available_images,
+ SET_IMAGE_LIST_MSG,
+ SET_IMAGE_PROMPT_MSG,
+ format_selection)
+ if image_name == image.get_default_image():
+ exit(f'The image "{image_name}" already configured as default')
+ if image_name not in available_images:
+ exit(f'The image "{image_name}" cannot be found')
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ # set default boot image
+ try:
+ grub.set_default(image_name, persistence_storage)
+ print(f'The image "{image_name}" is now default boot image')
+ except Exception as err:
+ exit(f'Unable to set default image "{image_name}": {err}')
+
+
+@compat.grub_cfg_update
+def rename_image(name_old: str, name_new: str) -> None:
+ """Rename installed image
+
+ Args:
+ name_old (str): old name
+ name_new (str): new name
+ """
+ if name_old == image.get_running_image():
+ exit('Currently running image cannot be renamed')
+ available_images: list[str] = grub.version_list()
+ if name_old not in available_images:
+ exit(f'The image "{name_old}" cannot be found')
+ if name_new in available_images:
+ exit(f'The image "{name_new}" already exists')
+ if not image.validate_name(name_new):
+ exit(f'The image name "{name_new}" is not allowed')
+
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ if not ask_yes_no(
+ f'Do you really want to rename the image {name_old} '
+ f'to the {name_new}?',
+ default=False):
+ exit()
+
+ try:
+ # replace default boot item
+ if name_old == image.get_default_image():
+ grub.set_default(name_new, persistence_storage)
+
+ # rename files and dirs
+ old_path: Path = Path(f'{persistence_storage}/boot/{name_old}')
+ new_path: Path = Path(f'{persistence_storage}/boot/{name_new}')
+ old_path.rename(new_path)
+
+ # replace boot item
+ grub.version_del(name_old, persistence_storage)
+ grub.version_add(name_new, persistence_storage)
+
+ print(f'The image "{name_old}" was renamed to "{name_new}"')
+ except Exception as err:
+ exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}')
+
+ # rename LUKS volume if it exists
+ old_luks_path: Path = Path(f'{persistence_storage}/luks/{name_old}')
+ if old_luks_path.is_file():
+ try:
+ new_luks_path: Path = Path(f'{persistence_storage}/luks/{name_new}')
+ old_luks_path.rename(new_luks_path)
+ print(f'The encrypted config for "{name_old}" was successfully renamed to "{name_new}"')
+ except Exception as err:
+ exit(f'Unable to rename the encrypted config for "{name_old}" to "{name_new}": {err}')
+
+
+@compat.grub_cfg_update
+def set_console_type(console_type: ConsoleType) -> None:
+ console_choice = get_args(ConsoleType)
+ if console_type not in console_choice:
+ exit(f'console type \'{console_type}\' not available')
+
+ grub.set_console_type(console_type)
+
+
+def list_images() -> None:
+ """Print list of available images for CLI hints"""
+ images_list: list[str] = grub.version_list()
+ for image_name in images_list:
+ print(image_name)
+
+
+def list_console_types() -> None:
+ """Print list of console types for CLI hints"""
+ console_types: list[str] = list(get_args(ConsoleType))
+ for console_type in console_types:
+ print(console_type)
+
+
+def parse_arguments() -> Namespace:
+ """Parse arguments
+
+ Returns:
+ Namespace: a namespace with parsed arguments
+ """
+ parser: ArgumentParser = ArgumentParser(description='Manage system images')
+ parser.add_argument('--action',
+ choices=['delete', 'set', 'set_console_type',
+ 'rename', 'list', 'list_console_types'],
+ required=True,
+ help='action to perform with an image')
+ parser.add_argument('--no-prompt', action='store_true',
+ help='perform action non-interactively')
+ parser.add_argument(
+ '--image-name',
+ help=
+ 'a name of an image to add, delete, install, rename, or set as default')
+ parser.add_argument('--image-new-name', help='a new name for image')
+ parser.add_argument('--console-type', help='console type for boot')
+ args: Namespace = parser.parse_args()
+ # Validate arguments
+ if args.action == 'rename' and (not args.image_name or
+ not args.image_new_name):
+ exit('Both old and new image names are required for rename action')
+
+ return args
+
+
+if __name__ == '__main__':
+ try:
+ args: Namespace = parse_arguments()
+ if args.action == 'delete':
+ delete_image(args.image_name, args.no_prompt)
+ if args.action == 'set':
+ set_image(args.image_name)
+ if args.action == 'set_console_type':
+ set_console_type(args.console_type)
+ if args.action == 'rename':
+ rename_image(args.image_name, args.image_new_name)
+ if args.action == 'list':
+ list_images()
+ if args.action == 'list_console_types':
+ list_console_types()
+
+ exit()
+
+ except KeyboardInterrupt:
+ print('Stopped by Ctrl+C')
+ exit()
+
+ except Exception as err:
+ exit(f'{err}')
diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py
new file mode 100644
index 0000000..e7afc4c
--- /dev/null
+++ b/src/op_mode/interfaces.py
@@ -0,0 +1,511 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 os
+import re
+import sys
+import glob
+import json
+import typing
+from datetime import datetime
+from tabulate import tabulate
+
+import vyos.opmode
+from vyos.ifconfig import Section
+from vyos.ifconfig import Interface
+from vyos.ifconfig import VRRP
+from vyos.utils.process import cmd
+from vyos.utils.process import rc_cmd
+from vyos.utils.process import call
+
+def catch_broken_pipe(func):
+ def wrapped(*args, **kwargs):
+ try:
+ func(*args, **kwargs)
+ except (BrokenPipeError, KeyboardInterrupt):
+ # Flush output to /dev/null and bail out.
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+ return wrapped
+
+# The original implementation of filtered_interfaces has signature:
+# (ifnames: list, iftypes: typing.Union[str, list], vif: bool, vrrp: bool) -> intf: Interface:
+# Arg types allowed in CLI (ifnames: str, iftypes: str) were manually
+# re-typed from argparse args.
+# We include the function in a general form, however op-mode standard
+# functions will restrict to the CLI-allowed arg types, wrapped in Optional.
+def filtered_interfaces(ifnames: typing.Union[str, list],
+ iftypes: typing.Union[str, list],
+ vif: bool, vrrp: bool) -> Interface:
+ """
+ get all interfaces from the OS and return them; ifnames can be used to
+ filter which interfaces should be considered
+
+ ifnames: a list of interface names to consider, empty do not filter
+
+ return an instance of the Interface class
+ """
+ if isinstance(ifnames, str):
+ ifnames = [ifnames] if ifnames else []
+ if isinstance(iftypes, list):
+ for iftype in iftypes:
+ yield from filtered_interfaces(ifnames, iftype, vif, vrrp)
+
+ for ifname in Section.interfaces(iftypes):
+ # Bail out early if interface name not part of our search list
+ if ifnames and ifname not in ifnames:
+ continue
+
+ # As we are only "reading" from the interface - we must use the
+ # generic base class which exposes all the data via a common API
+ interface = Interface(ifname, create=False, debug=False)
+
+ # VLAN interfaces have a '.' in their name by convention
+ if vif and not '.' in ifname:
+ continue
+
+ if vrrp:
+ vrrp_interfaces = VRRP.active_interfaces()
+ if ifname not in vrrp_interfaces:
+ continue
+
+ yield interface
+
+def _split_text(text, used=0):
+ """
+ take a string and attempt to split it to fit with the width of the screen
+
+ text: the string to split
+ used: number of characted already used in the screen
+ """
+ no_tty = call('tty -s')
+
+ returned = cmd('stty size') if not no_tty else ''
+ returned = returned.split()
+ if len(returned) == 2:
+ _, columns = tuple(int(_) for _ in returned)
+ else:
+ _, columns = (40, 80)
+
+ desc_len = columns - used
+
+ line = ''
+ for word in text.split():
+ if len(line) + len(word) < desc_len:
+ line = f'{line} {word}'
+ continue
+ if line:
+ yield line[1:]
+ else:
+ line = f'{line} {word}'
+
+ yield line[1:]
+
+def _get_counter_val(prev, now):
+ """
+ attempt to correct a counter if it wrapped, copied from perl
+
+ prev: previous counter
+ now: the current counter
+ """
+ # This function has to deal with both 32 and 64 bit counters
+ if prev == 0:
+ return now
+
+ # device is using 64 bit values assume they never wrap
+ value = now - prev
+ if (now >> 32) != 0:
+ return value
+
+ # The counter has rolled. If the counter has rolled
+ # multiple times since the prev value, then this math
+ # is meaningless.
+ if value < 0:
+ value = (4294967296 - prev) + now
+
+ return value
+
+def _pppoe(ifname):
+ out = cmd('ps -C pppd -f')
+ if ifname in out:
+ return 'C'
+ if ifname in [_.split('/')[-1] for _ in glob.glob('/etc/ppp/peers/pppoe*')]:
+ return 'D'
+ return ''
+
+def _find_intf_by_ifname(intf_l: list, name: str):
+ for d in intf_l:
+ if d['ifname'] == name:
+ return d
+ return {}
+
+# lifted out of operational.py to separate formatting from data
+def _format_stats(stats, indent=4):
+ stat_names = {
+ 'rx': ['bytes', 'packets', 'errors', 'dropped', 'overrun', 'mcast'],
+ 'tx': ['bytes', 'packets', 'errors', 'dropped', 'carrier', 'collisions'],
+ }
+
+ stats_dir = {
+ 'rx': ['rx_bytes', 'rx_packets', 'rx_errors', 'rx_dropped', 'rx_over_errors', 'multicast'],
+ 'tx': ['tx_bytes', 'tx_packets', 'tx_errors', 'tx_dropped', 'tx_carrier_errors', 'collisions'],
+ }
+ tabs = []
+ for rtx in list(stats_dir):
+ tabs.append([f'{rtx.upper()}:', ] + stat_names[rtx])
+ tabs.append(['', ] + [stats[_] for _ in stats_dir[rtx]])
+
+ s = tabulate(
+ tabs,
+ stralign="right",
+ numalign="right",
+ tablefmt="plain"
+ )
+
+ p = ' '*indent
+ return f'{p}' + s.replace('\n', f'\n{p}')
+
+def _get_raw_data(ifname: typing.Optional[str],
+ iftype: typing.Optional[str],
+ vif: bool, vrrp: bool) -> list:
+ if ifname is None:
+ ifname = ''
+ if iftype is None:
+ iftype = ''
+ ret =[]
+ for interface in filtered_interfaces(ifname, iftype, vif, vrrp):
+ res_intf = {}
+ cache = interface.operational.load_counters()
+
+ out = cmd(f'ip -json addr show {interface.ifname}')
+ res_intf_l = json.loads(out)
+ res_intf = res_intf_l[0]
+
+ if res_intf['link_type'] == 'tunnel6':
+ # Note that 'ip -6 tun show {interface.ifname}' is not json
+ # aware, so find in list
+ out = cmd('ip -json -6 tun show')
+ tunnel = json.loads(out)
+ res_intf['tunnel6'] = _find_intf_by_ifname(tunnel,
+ interface.ifname)
+ if 'ip6_tnl_f_use_orig_tclass' in res_intf['tunnel6']:
+ res_intf['tunnel6']['tclass'] = 'inherit'
+ del res_intf['tunnel6']['ip6_tnl_f_use_orig_tclass']
+
+ res_intf['counters_last_clear'] = int(cache.get('timestamp', 0))
+
+ res_intf['description'] = interface.get_alias()
+
+ stats = interface.operational.get_stats()
+ for k in list(stats):
+ stats[k] = _get_counter_val(cache[k], stats[k])
+
+ res_intf['stats'] = stats
+
+ ret.append(res_intf)
+
+ # find pppoe interfaces that are in a transitional/dead state
+ if ifname.startswith('pppoe') and not _find_intf_by_ifname(ret, ifname):
+ pppoe_intf = {}
+ pppoe_intf['unhandled'] = None
+ pppoe_intf['ifname'] = ifname
+ pppoe_intf['state'] = _pppoe(ifname)
+ ret.append(pppoe_intf)
+
+ return ret
+
+def _get_summary_data(ifname: typing.Optional[str],
+ iftype: typing.Optional[str],
+ vif: bool, vrrp: bool) -> list:
+ if ifname is None:
+ ifname = ''
+ if iftype is None:
+ iftype = ''
+ ret = []
+
+ def is_interface_has_mac(interface_name):
+ interface_no_mac = ('tun', 'wg')
+ return not any(interface_name.startswith(prefix) for prefix in interface_no_mac)
+
+ for interface in filtered_interfaces(ifname, iftype, vif, vrrp):
+ res_intf = {}
+
+ res_intf['ifname'] = interface.ifname
+ res_intf['oper_state'] = interface.operational.get_state()
+ res_intf['admin_state'] = interface.get_admin_state()
+ res_intf['addr'] = [_ for _ in interface.get_addr() if not _.startswith('fe80::')]
+ res_intf['description'] = interface.get_alias()
+ res_intf['mtu'] = interface.get_mtu()
+ res_intf['mac'] = interface.get_mac() if is_interface_has_mac(interface.ifname) else 'n/a'
+ res_intf['vrf'] = interface.get_vrf()
+
+ ret.append(res_intf)
+
+ # find pppoe interfaces that are in a transitional/dead state
+ if ifname.startswith('pppoe') and not _find_intf_by_ifname(ret, ifname):
+ pppoe_intf = {}
+ pppoe_intf['unhandled'] = None
+ pppoe_intf['ifname'] = ifname
+ pppoe_intf['state'] = _pppoe(ifname)
+ ret.append(pppoe_intf)
+
+ return ret
+
+def _get_counter_data(ifname: typing.Optional[str],
+ iftype: typing.Optional[str],
+ vif: bool, vrrp: bool) -> list:
+ if ifname is None:
+ ifname = ''
+ if iftype is None:
+ iftype = ''
+ ret = []
+ for interface in filtered_interfaces(ifname, iftype, vif, vrrp):
+ res_intf = {}
+
+ oper = interface.operational.get_state()
+
+ if oper not in ('up','unknown'):
+ continue
+
+ stats = interface.operational.get_stats()
+ cache = interface.operational.load_counters()
+ res_intf['ifname'] = interface.ifname
+ res_intf['rx_packets'] = _get_counter_val(cache['rx_packets'], stats['rx_packets'])
+ res_intf['rx_bytes'] = _get_counter_val(cache['rx_bytes'], stats['rx_bytes'])
+ res_intf['tx_packets'] = _get_counter_val(cache['tx_packets'], stats['tx_packets'])
+ res_intf['tx_bytes'] = _get_counter_val(cache['tx_bytes'], stats['tx_bytes'])
+ res_intf['rx_dropped'] = _get_counter_val(cache['rx_dropped'], stats['rx_dropped'])
+ res_intf['tx_dropped'] = _get_counter_val(cache['tx_dropped'], stats['tx_dropped'])
+ res_intf['rx_over_errors'] = _get_counter_val(cache['rx_over_errors'], stats['rx_over_errors'])
+ res_intf['tx_carrier_errors'] = _get_counter_val(cache['tx_carrier_errors'], stats['tx_carrier_errors'])
+
+ ret.append(res_intf)
+
+ return ret
+
+@catch_broken_pipe
+def _format_show_data(data: list):
+ unhandled = []
+ for intf in data:
+ if 'unhandled' in intf:
+ unhandled.append(intf)
+ continue
+ # instead of reformatting data, call non-json output:
+ rc, out = rc_cmd(f"ip addr show {intf['ifname']}")
+ if rc != 0:
+ continue
+ out = re.sub('^\d+:\s+','',out)
+ # add additional data already collected
+ if 'tunnel6' in intf:
+ t6_d = intf['tunnel6']
+ t6_str = 'encaplimit %s hoplimit %s tclass %s flowlabel %s (flowinfo %s)' % (
+ t6_d.get('encap_limit', ''), t6_d.get('hoplimit', ''),
+ t6_d.get('tclass', ''), t6_d.get('flowlabel', ''),
+ t6_d.get('flowinfo', ''))
+ out = re.sub('(\n\s+)(link/tunnel6)', f'\g<1>{t6_str}\g<1>\g<2>', out)
+ print(out)
+ ts = intf.get('counters_last_clear', 0)
+ if ts:
+ when = datetime.fromtimestamp(ts).strftime("%a %b %d %R:%S %Z %Y")
+ print(f' Last clear: {when}')
+ description = intf.get('description', '')
+ if description:
+ print(f' Description: {description}')
+
+ stats = intf.get('stats', {})
+ if stats:
+ print()
+ print(_format_stats(stats))
+
+ for intf in unhandled:
+ string = {
+ 'C': 'Coming up',
+ 'D': 'Link down'
+ }[intf['state']]
+ print(f"{intf['ifname']}: {string}")
+
+ return 0
+
+@catch_broken_pipe
+def _format_show_summary(data):
+ format1 = '%-16s %-33s %-4s %s'
+ format2 = '%-16s %s'
+
+ print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down')
+ print(format1 % ("Interface", "IP Address", "S/L", "Description"))
+ print(format1 % ("---------", "----------", "---", "-----------"))
+
+ unhandled = []
+ for intf in data:
+ if 'unhandled' in intf:
+ unhandled.append(intf)
+ continue
+ ifname = [intf['ifname'],]
+ oper = ['u',] if intf['oper_state'] in ('up', 'unknown') else ['D',]
+ admin = ['u',] if intf['admin_state'] in ('up', 'unknown') else ['A',]
+ addrs = intf['addr'] or ['-',]
+ descs = list(_split_text(intf['description'], 0))
+
+ while ifname or oper or admin or addrs or descs:
+ i = ifname.pop(0) if ifname else ''
+ a = addrs.pop(0) if addrs else ''
+ d = descs.pop(0) if descs else ''
+ s = [admin.pop(0)] if admin else []
+ l = [oper.pop(0)] if oper else []
+ if len(a) < 33:
+ print(format1 % (i, a, '/'.join(s+l), d))
+ else:
+ print(format2 % (i, a))
+ print(format1 % ('', '', '/'.join(s+l), d))
+
+ for intf in unhandled:
+ string = {
+ 'C': 'u/D',
+ 'D': 'A/D'
+ }[intf['state']]
+ print(format1 % (ifname, '', string, ''))
+
+ return 0
+
+@catch_broken_pipe
+def _format_show_summary_extended(data):
+ headers = ["Interface", "IP Address", "MAC", "VRF", "MTU", "S/L", "Description"]
+ table_data = []
+
+ print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down')
+
+ for intf in data:
+ if 'unhandled' in intf:
+ continue
+
+ ifname = intf['ifname']
+ oper_state = 'u' if intf['oper_state'] in ('up', 'unknown') else 'D'
+ admin_state = 'u' if intf['admin_state'] in ('up', 'unknown') else 'A'
+ addrs = intf['addr'] or ['-']
+ description = '\n'.join(_split_text(intf['description'], 0))
+ mac = intf['mac'] if intf['mac'] else 'n/a'
+ mtu = intf['mtu'] if intf['mtu'] else 'n/a'
+ vrf = intf['vrf'] if intf['vrf'] else 'default'
+
+ ip_addresses = '\n'.join(ip for ip in addrs)
+
+ # Create a row for the table
+ row = [
+ ifname,
+ ip_addresses,
+ mac,
+ vrf,
+ mtu,
+ f"{admin_state}/{oper_state}",
+ description,
+ ]
+
+ # Append the row to the table data
+ table_data.append(row)
+
+ for intf in data:
+ if 'unhandled' in intf:
+ string = {'C': 'u/D', 'D': 'A/D'}[intf['state']]
+ table_data.append([intf['ifname'], '', '', '', '', string, ''])
+
+ print(tabulate(table_data, headers))
+
+ return 0
+
+@catch_broken_pipe
+def _format_show_counters(data: list):
+ data_entries = []
+ for entry in data:
+ interface = entry.get('ifname')
+ rx_packets = entry.get('rx_packets')
+ rx_bytes = entry.get('rx_bytes')
+ tx_packets = entry.get('tx_packets')
+ tx_bytes = entry.get('tx_bytes')
+ rx_dropped = entry.get('rx_dropped')
+ tx_dropped = entry.get('tx_dropped')
+ rx_errors = entry.get('rx_over_errors')
+ tx_errors = entry.get('tx_carrier_errors')
+ data_entries.append([interface, rx_packets, rx_bytes, tx_packets, tx_bytes, rx_dropped, tx_dropped, rx_errors, tx_errors])
+
+ headers = ['Interface', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes', 'Rx Dropped', 'Tx Dropped', 'Rx Errors', 'Tx Errors']
+ output = tabulate(data_entries, headers, numalign="left")
+ print (output)
+ return output
+
+
+def _show_raw(data: list, intf_name: str):
+ if intf_name is not None and len(data) <= 1:
+ try:
+ return data[0]
+ except IndexError:
+ raise vyos.opmode.UnconfiguredObject(
+ f"Interface {intf_name} does not exist")
+ else:
+ return data
+
+
+def show(raw: bool, intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ data = _get_raw_data(intf_name, intf_type, vif, vrrp)
+ if raw:
+ return _show_raw(data, intf_name)
+ return _format_show_data(data)
+
+def show_summary(raw: bool, intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ data = _get_summary_data(intf_name, intf_type, vif, vrrp)
+ if raw:
+ return _show_raw(data, intf_name)
+ return _format_show_summary(data)
+
+def show_summary_extended(raw: bool, intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ data = _get_summary_data(intf_name, intf_type, vif, vrrp)
+ if raw:
+ return _show_raw(data, intf_name)
+ return _format_show_summary_extended(data)
+
+def show_counters(raw: bool, intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ data = _get_counter_data(intf_name, intf_type, vif, vrrp)
+ if raw:
+ return _show_raw(data, intf_name)
+ return _format_show_counters(data)
+
+def clear_counters(intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ for interface in filtered_interfaces(intf_name, intf_type, vif, vrrp):
+ interface.operational.clear_counters()
+
+def reset_counters(intf_name: typing.Optional[str],
+ intf_type: typing.Optional[str],
+ vif: bool, vrrp: bool):
+ for interface in filtered_interfaces(intf_name, intf_type, vif, vrrp):
+ interface.operational.reset_counters()
+
+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/interfaces_wireguard.py b/src/op_mode/interfaces_wireguard.py
new file mode 100644
index 0000000..627af05
--- /dev/null
+++ b/src/op_mode/interfaces_wireguard.py
@@ -0,0 +1,53 @@
+#!/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 sys
+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('intf_name')
+ 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 show_summary(raw: bool, intf_name: str):
+ intf = WireGuardIf(intf_name, create=False, debug=False)
+ return intf.operational.show_interface()
+
+
+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/interfaces_wireless.py b/src/op_mode/interfaces_wireless.py
new file mode 100644
index 0000000..bf6e462
--- /dev/null
+++ b/src/op_mode/interfaces_wireless.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 re
+import sys
+import vyos.opmode
+
+from copy import deepcopy
+from tabulate import tabulate
+from vyos.utils.process import popen
+from vyos.configquery import ConfigTreeQuery
+
+def _verify(func):
+ """Decorator checks if Wireless LAN config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ if not config.exists(['interfaces', 'wireless']):
+ unconf_message = 'No Wireless interfaces configured'
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ return func(*args, **kwargs)
+ return _wrapper
+
+def _get_raw_info_data():
+ output_data = []
+
+ config = ConfigTreeQuery()
+ raw = config.get_config_dict(['interfaces', 'wireless'], effective=True,
+ get_first_key=True, key_mangling=('-', '_'))
+ for interface, interface_config in raw.items():
+ tmp = {'name' : interface}
+
+ if 'type' in interface_config:
+ tmp.update({'type' : interface_config['type']})
+ else:
+ tmp.update({'type' : '-'})
+
+ if 'ssid' in interface_config:
+ tmp.update({'ssid' : interface_config['ssid']})
+ else:
+ tmp.update({'ssid' : '-'})
+
+ if 'channel' in interface_config:
+ tmp.update({'channel' : interface_config['channel']})
+ else:
+ tmp.update({'channel' : '-'})
+
+ output_data.append(tmp)
+
+ return output_data
+
+def _get_formatted_info_output(raw_data):
+ output=[]
+ for ssid in raw_data:
+ output.append([ssid['name'], ssid['type'], ssid['ssid'], ssid['channel']])
+
+ headers = ["Interface", "Type", "SSID", "Channel"]
+ print(tabulate(output, headers, numalign="left"))
+
+def _get_raw_scan_data(intf_name):
+ # XXX: This ignores errors
+ tmp, _ = popen(f'iw dev {intf_name} scan ap-force')
+ networks = []
+ data = {
+ 'ssid': '',
+ 'mac': '',
+ 'channel': '',
+ 'signal': ''
+ }
+ re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})')
+ for line in tmp.splitlines():
+ if line.startswith('BSS '):
+ ssid = deepcopy(data)
+ ssid['mac'] = re.search(re_mac, line).group()
+
+ elif line.lstrip().startswith('SSID: '):
+ # SSID can be " SSID: WLAN-57 6405", thus strip all leading whitespaces
+ ssid['ssid'] = line.lstrip().split(':')[-1].lstrip()
+
+ elif line.lstrip().startswith('signal: '):
+ # Siganl can be " signal: -67.00 dBm", thus strip all leading whitespaces
+ ssid['signal'] = line.lstrip().split(':')[-1].split()[0]
+
+ elif line.lstrip().startswith('DS Parameter set: channel'):
+ # Channel can be " DS Parameter set: channel 6" , thus
+ # strip all leading whitespaces
+ ssid['channel'] = line.lstrip().split(':')[-1].split()[-1]
+ networks.append(ssid)
+ continue
+
+ return networks
+
+def _format_scan_data(raw_data):
+ output=[]
+ for ssid in raw_data:
+ output.append([ssid['mac'], ssid['ssid'], ssid['channel'], ssid['signal']])
+ headers = ["Address", "SSID", "Channel", "Signal (dbm)"]
+ return tabulate(output, headers, numalign="left")
+
+def _get_raw_station_data(intf_name):
+ # XXX: This ignores errors
+ tmp, _ = popen(f'iw dev {intf_name} station dump')
+ clients = []
+ data = {
+ 'mac': '',
+ 'signal': '',
+ 'rx_bytes': '',
+ 'rx_packets': '',
+ 'tx_bytes': '',
+ 'tx_packets': ''
+ }
+ re_mac = re.compile(r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})')
+ for line in tmp.splitlines():
+ if line.startswith('Station'):
+ client = deepcopy(data)
+ client['mac'] = re.search(re_mac, line).group()
+
+ elif line.lstrip().startswith('signal avg:'):
+ client['signal'] = line.lstrip().split(':')[-1].lstrip().split()[0]
+
+ elif line.lstrip().startswith('rx bytes:'):
+ client['rx_bytes'] = line.lstrip().split(':')[-1].lstrip()
+
+ elif line.lstrip().startswith('rx packets:'):
+ client['rx_packets'] = line.lstrip().split(':')[-1].lstrip()
+
+ elif line.lstrip().startswith('tx bytes:'):
+ client['tx_bytes'] = line.lstrip().split(':')[-1].lstrip()
+
+ elif line.lstrip().startswith('tx packets:'):
+ client['tx_packets'] = line.lstrip().split(':')[-1].lstrip()
+ clients.append(client)
+ continue
+
+ return clients
+
+def _format_station_data(raw_data):
+ output=[]
+ for ssid in raw_data:
+ output.append([ssid['mac'], ssid['signal'], ssid['rx_bytes'], ssid['rx_packets'], ssid['tx_bytes'], ssid['tx_packets']])
+ headers = ["Station", "Signal", "RX bytes", "RX packets", "TX bytes", "TX packets"]
+ return tabulate(output, headers, numalign="left")
+
+@_verify
+def show_info(raw: bool):
+ info_data = _get_raw_info_data()
+ if raw:
+ return info_data
+ return _get_formatted_info_output(info_data)
+
+def show_scan(raw: bool, intf_name: str):
+ data = _get_raw_scan_data(intf_name)
+ if raw:
+ return data
+ return _format_scan_data(data)
+
+@_verify
+def show_stations(raw: bool, intf_name: str):
+ data = _get_raw_station_data(intf_name)
+ if raw:
+ return data
+ return _format_station_data(data)
+
+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/ipoe-control.py b/src/op_mode/ipoe-control.py
new file mode 100644
index 0000000..b7d6a0c
--- /dev/null
+++ b/src/op_mode/ipoe-control.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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 argparse
+
+from vyos.config import Config
+from vyos.utils.process import popen
+from vyos.utils.process import run
+
+cmd_dict = {
+ 'cmd_base' : '/usr/bin/accel-cmd -p 2002 ',
+ 'selector' : ['if', 'username', 'sid'],
+ 'actions' : {
+ 'show_sessions' : 'show sessions',
+ 'show_stat' : 'show stat',
+ 'terminate' : 'teminate'
+ }
+}
+
+def is_ipoe_configured():
+ if not Config().exists_effective('service ipoe-server'):
+ print("Service IPoE is not configured")
+ sys.exit(1)
+
+def main():
+ #parese args
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='Control action', required=True)
+ parser.add_argument('--selector', help='Selector username|ifname|sid', required=False)
+ parser.add_argument('--target', help='Target must contain username|ifname|sid', required=False)
+ args = parser.parse_args()
+
+
+ # Check is IPoE configured
+ is_ipoe_configured()
+
+ if args.action == "restart":
+ run(cmd_dict['cmd_base'] + "restart")
+ sys.exit(0)
+
+ if args.action in cmd_dict['actions']:
+ if args.selector in cmd_dict['selector'] and args.target:
+ run(cmd_dict['cmd_base'] + "{0} {1} {2}".format(args.action, args.selector, args.target))
+ else:
+ if args.action == "show_sessions":
+ ses_pattern = " ifname,username,calling-sid,ip,ip6,ip6-dp,rate-limit,type,comp,state,uptime"
+ else:
+ ses_pattern = ""
+ output, err = popen(cmd_dict['cmd_base'] + cmd_dict['actions'][args.action] + ses_pattern, decode='utf-8')
+ if not err:
+ print(output)
+ else:
+ print("IPoE server is not running")
+
+if __name__ == '__main__':
+ main()
diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py
new file mode 100644
index 0000000..02ba126
--- /dev/null
+++ b/src/op_mode/ipsec.py
@@ -0,0 +1,1053 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 re
+import sys
+import typing
+
+from hurry import filesize
+from re import split as re_split
+from tabulate import tabulate
+
+from vyos.utils.convert import convert_data
+from vyos.utils.convert import seconds_to_human
+from vyos.utils.process import cmd
+from vyos.configquery import ConfigTreeQuery
+from vyos.base import Warning
+
+import vyos.opmode
+import vyos.ipsec
+
+
+def _convert(text):
+ return int(text) if text.isdigit() else text.lower()
+
+
+def _alphanum_key(key):
+ return [_convert(c) for c in re_split('([0-9]+)', str(key))]
+
+
+def _get_raw_data_sas():
+ try:
+ get_sas = vyos.ipsec.get_vici_sas()
+ sas = convert_data(get_sas)
+ return sas
+ except vyos.ipsec.ViciInitiateError as err:
+ raise vyos.opmode.UnconfiguredSubsystem(err)
+
+
+def _get_output_swanctl_sas_from_list(ra_output_list: list) -> str:
+ """
+ Template for output for VICI
+ Inserts \n after each IKE SA
+ :param ra_output_list: IKE SAs list
+ :type ra_output_list: list
+ :return: formatted string
+ :rtype: str
+ """
+ output = ''
+ for sa_val in ra_output_list:
+ for sa in sa_val.values():
+ swanctl_output: str = cmd(f'sudo swanctl -l --ike-id {sa["uniqueid"]}')
+ output = f'{output}{swanctl_output}\n\n'
+ return output
+
+
+def _get_formatted_output_sas(sas):
+ sa_data = []
+ for sa in sas:
+ for parent_sa in sa.values():
+ # create an item for each child-sa
+ for child_sa in parent_sa.get('child-sas', {}).values():
+ # prepare a list for output data
+ sa_out_name = sa_out_state = sa_out_uptime = sa_out_bytes = (
+ sa_out_packets
+ ) = sa_out_remote_addr = sa_out_remote_id = sa_out_proposal = 'N/A'
+
+ # collect raw data
+ sa_name = child_sa.get('name')
+ sa_state = child_sa.get('state')
+ sa_uptime = child_sa.get('install-time')
+ sa_bytes_in = child_sa.get('bytes-in')
+ sa_bytes_out = child_sa.get('bytes-out')
+ sa_packets_in = child_sa.get('packets-in')
+ sa_packets_out = child_sa.get('packets-out')
+ sa_remote_addr = parent_sa.get('remote-host')
+ sa_remote_id = parent_sa.get('remote-id')
+ sa_proposal_encr_alg = child_sa.get('encr-alg')
+ sa_proposal_integ_alg = child_sa.get('integ-alg')
+ sa_proposal_encr_keysize = child_sa.get('encr-keysize')
+ sa_proposal_dh_group = child_sa.get('dh-group')
+
+ # format data to display
+ if sa_name:
+ sa_out_name = sa_name
+ if sa_state:
+ if sa_state == 'INSTALLED':
+ sa_out_state = 'up'
+ else:
+ sa_out_state = 'down'
+ if sa_uptime:
+ sa_out_uptime = seconds_to_human(sa_uptime)
+ if sa_bytes_in and sa_bytes_out:
+ bytes_in = filesize.size(int(sa_bytes_in))
+ bytes_out = filesize.size(int(sa_bytes_out))
+ sa_out_bytes = f'{bytes_in}/{bytes_out}'
+ if sa_packets_in and sa_packets_out:
+ packets_in = filesize.size(int(sa_packets_in), system=filesize.si)
+ packets_out = filesize.size(int(sa_packets_out), system=filesize.si)
+ packets_str = f'{packets_in}/{packets_out}'
+ sa_out_packets = re.sub(r'B', r'', packets_str)
+ if sa_remote_addr:
+ sa_out_remote_addr = sa_remote_addr
+ if sa_remote_id:
+ sa_out_remote_id = sa_remote_id
+ # format proposal
+ if sa_proposal_encr_alg:
+ sa_out_proposal = sa_proposal_encr_alg
+ if sa_proposal_encr_keysize:
+ sa_proposal_encr_keysize_str = sa_proposal_encr_keysize
+ sa_out_proposal = (
+ f'{sa_out_proposal}_{sa_proposal_encr_keysize_str}'
+ )
+ if sa_proposal_integ_alg:
+ sa_proposal_integ_alg_str = sa_proposal_integ_alg
+ sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_integ_alg_str}'
+ if sa_proposal_dh_group:
+ sa_proposal_dh_group_str = sa_proposal_dh_group
+ sa_out_proposal = f'{sa_out_proposal}/{sa_proposal_dh_group_str}'
+
+ # add a new item to output data
+ sa_data.append(
+ [
+ sa_out_name,
+ sa_out_state,
+ sa_out_uptime,
+ sa_out_bytes,
+ sa_out_packets,
+ sa_out_remote_addr,
+ sa_out_remote_id,
+ sa_out_proposal,
+ ]
+ )
+
+ headers = [
+ 'Connection',
+ 'State',
+ 'Uptime',
+ 'Bytes In/Out',
+ 'Packets In/Out',
+ 'Remote address',
+ 'Remote ID',
+ 'Proposal',
+ ]
+ sa_data = sorted(sa_data, key=_alphanum_key)
+ output = tabulate(sa_data, headers)
+ return output
+
+
+# Connections block
+
+
+def _get_convert_data_connections():
+ try:
+ get_connections = vyos.ipsec.get_vici_connections()
+ connections = convert_data(get_connections)
+ return connections
+ except vyos.ipsec.ViciInitiateError as err:
+ raise vyos.opmode.UnconfiguredSubsystem(err)
+
+
+def _get_parent_sa_proposal(connection_name: str, data: list) -> dict:
+ """Get parent SA proposals by connection name
+ if connections not in the 'down' state
+
+ Args:
+ connection_name (str): Connection name
+ data (list): List of current SAs from vici
+
+ Returns:
+ str: Parent SA connection proposal
+ AES_CBC/256/HMAC_SHA2_256_128/MODP_1024
+ """
+ if not data:
+ return {}
+ for sa in data:
+ # check if parent SA exist
+ if connection_name not in sa.keys():
+ continue
+ if 'encr-alg' in sa[connection_name]:
+ encr_alg = sa.get(connection_name, '').get('encr-alg')
+ cipher = encr_alg.split('_')[0]
+ mode = encr_alg.split('_')[1]
+ encr_keysize = sa.get(connection_name, '').get('encr-keysize')
+ integ_alg = sa.get(connection_name, '').get('integ-alg')
+ # prf_alg = sa.get(connection_name, '').get('prf-alg')
+ dh_group = sa.get(connection_name, '').get('dh-group')
+ proposal = {
+ 'cipher': cipher,
+ 'mode': mode,
+ 'key_size': encr_keysize,
+ 'hash': integ_alg,
+ 'dh': dh_group,
+ }
+ return proposal
+ return {}
+
+
+def _get_parent_sa_state(connection_name: str, data: list) -> str:
+ """Get parent SA state by connection name
+
+ Args:
+ connection_name (str): Connection name
+ data (list): List of current SAs from vici
+
+ Returns:
+ Parent SA connection state
+ """
+ ike_state = 'down'
+ if not data:
+ return ike_state
+ for sa in data:
+ # check if parent SA exist
+ for connection, connection_conf in sa.items():
+ if connection_name != connection:
+ continue
+ if connection_conf['state'].lower() == 'established':
+ ike_state = 'up'
+ return ike_state
+
+
+def _get_child_sa_state(connection_name: str, tunnel_name: str, data: list) -> str:
+ """Get child SA state by connection and tunnel name
+
+ Args:
+ connection_name (str): Connection name
+ tunnel_name (str): Tunnel name
+ data (list): List of current SAs from vici
+
+ Returns:
+ str: `up` if child SA state is 'installed' otherwise `down`
+ """
+ child_sa = 'down'
+ if not data:
+ return child_sa
+ for sa in data:
+ # check if parent SA exist
+ if connection_name not in sa.keys():
+ continue
+ child_sas = sa[connection_name]['child-sas']
+ # Get all child SA states
+ # there can be multiple SAs per tunnel
+ child_sa_states = [
+ v['state'] for k, v in child_sas.items() if v['name'] == tunnel_name
+ ]
+ return 'up' if 'INSTALLED' in child_sa_states else child_sa
+
+
+def _get_child_sa_info(connection_name: str, tunnel_name: str, data: list) -> dict:
+ """Get child SA installed info by connection and tunnel name
+
+ Args:
+ connection_name (str): Connection name
+ tunnel_name (str): Tunnel name
+ data (list): List of current SAs from vici
+
+ Returns:
+ dict: Info of the child SA in the dictionary format
+ """
+ for sa in data:
+ # check if parent SA exist
+ if connection_name not in sa.keys():
+ continue
+ child_sas = sa[connection_name]['child-sas']
+ # Get all child SA data
+ # Skip temp SA name (first key), get only SA values as dict
+ # {'OFFICE-B-tunnel-0-46': {'name': 'OFFICE-B-tunnel-0'}...}
+ # i.e get all data after 'OFFICE-B-tunnel-0-46'
+ child_sa_info = [
+ v
+ for k, v in child_sas.items()
+ if 'name' in v and v['name'] == tunnel_name and v['state'] == 'INSTALLED'
+ ]
+ return child_sa_info[-1] if child_sa_info else {}
+
+
+def _get_child_sa_proposal(child_sa_data: dict) -> dict:
+ if child_sa_data and 'encr-alg' in child_sa_data:
+ encr_alg = child_sa_data.get('encr-alg')
+ cipher = encr_alg.split('_')[0]
+ mode = encr_alg.split('_')[1]
+ key_size = child_sa_data.get('encr-keysize')
+ integ_alg = child_sa_data.get('integ-alg')
+ dh_group = child_sa_data.get('dh-group')
+ proposal = {
+ 'cipher': cipher,
+ 'mode': mode,
+ 'key_size': key_size,
+ 'hash': integ_alg,
+ 'dh': dh_group,
+ }
+ return proposal
+ return {}
+
+
+def _get_raw_data_connections(list_connections: list, list_sas: list) -> list:
+ """Get configured VPN IKE connections and IPsec states
+
+ Args:
+ list_connections (list): List of configured connections from vici
+ list_sas (list): List of current SAs from vici
+
+ Returns:
+ list: List and status of IKE/IPsec connections/tunnels
+ """
+ base_dict = []
+ for connections in list_connections:
+ base_list = {}
+ for connection, conn_conf in connections.items():
+ base_list['ike_connection_name'] = connection
+ base_list['ike_connection_state'] = _get_parent_sa_state(
+ connection, list_sas
+ )
+ base_list['ike_remote_address'] = conn_conf['remote_addrs']
+ base_list['ike_proposal'] = _get_parent_sa_proposal(connection, list_sas)
+ base_list['local_id'] = conn_conf.get('local-1', '').get('id')
+ base_list['remote_id'] = conn_conf.get('remote-1', '').get('id')
+ base_list['version'] = conn_conf.get('version', 'IKE')
+ base_list['children'] = []
+ children = conn_conf['children']
+ for tunnel, tun_options in children.items():
+ state = _get_child_sa_state(connection, tunnel, list_sas)
+ local_ts = tun_options.get('local-ts')
+ remote_ts = tun_options.get('remote-ts')
+ dpd_action = tun_options.get('dpd_action')
+ close_action = tun_options.get('close_action')
+ sa_info = _get_child_sa_info(connection, tunnel, list_sas)
+ esp_proposal = _get_child_sa_proposal(sa_info)
+ base_list['children'].append(
+ {
+ 'name': tunnel,
+ 'state': state,
+ 'local_ts': local_ts,
+ 'remote_ts': remote_ts,
+ 'dpd_action': dpd_action,
+ 'close_action': close_action,
+ 'sa': sa_info,
+ 'esp_proposal': esp_proposal,
+ }
+ )
+ base_dict.append(base_list)
+ return base_dict
+
+
+def _get_raw_connections_summary(list_conn, list_sas):
+ import jmespath
+
+ data = _get_raw_data_connections(list_conn, list_sas)
+ match = '[*].children[]'
+ child = jmespath.search(match, data)
+ tunnels_down = len([k for k in child if k['state'] == 'down'])
+ tunnels_up = len([k for k in child if k['state'] == 'up'])
+ tun_dict = {
+ 'tunnels': child,
+ 'total': len(child),
+ 'down': tunnels_down,
+ 'up': tunnels_up,
+ }
+ return tun_dict
+
+
+def _get_formatted_output_conections(data):
+ from tabulate import tabulate
+
+ connections = []
+ for entry in data:
+ ike_name = entry['ike_connection_name']
+ ike_state = entry['ike_connection_state']
+ conn_type = entry.get('version', 'IKE')
+ remote_addrs = ','.join(entry['ike_remote_address'])
+ local_ts, remote_ts = '-', '-'
+ local_id = entry['local_id']
+ remote_id = entry['remote_id']
+ proposal = '-'
+ if entry.get('ike_proposal'):
+ proposal = (
+ f'{entry["ike_proposal"]["cipher"]}_'
+ f'{entry["ike_proposal"]["mode"]}/'
+ f'{entry["ike_proposal"]["key_size"]}/'
+ f'{entry["ike_proposal"]["hash"]}/'
+ f'{entry["ike_proposal"]["dh"]}'
+ )
+ connections.append(
+ [
+ ike_name,
+ ike_state,
+ conn_type,
+ remote_addrs,
+ local_ts,
+ remote_ts,
+ local_id,
+ remote_id,
+ proposal,
+ ]
+ )
+ for tun in entry['children']:
+ tun_name = tun.get('name')
+ tun_state = tun.get('state')
+ conn_type = 'IPsec'
+ local_ts = '\n'.join(tun.get('local_ts'))
+ remote_ts = '\n'.join(tun.get('remote_ts'))
+ proposal = '-'
+ if tun.get('esp_proposal'):
+ proposal = (
+ f'{tun["esp_proposal"]["cipher"]}_'
+ f'{tun["esp_proposal"]["mode"]}/'
+ f'{tun["esp_proposal"]["key_size"]}/'
+ f'{tun["esp_proposal"]["hash"]}/'
+ f'{tun["esp_proposal"]["dh"]}'
+ )
+ connections.append(
+ [
+ tun_name,
+ tun_state,
+ conn_type,
+ remote_addrs,
+ local_ts,
+ remote_ts,
+ local_id,
+ remote_id,
+ proposal,
+ ]
+ )
+ connection_headers = [
+ 'Connection',
+ 'State',
+ 'Type',
+ 'Remote address',
+ 'Local TS',
+ 'Remote TS',
+ 'Local id',
+ 'Remote id',
+ 'Proposal',
+ ]
+ output = tabulate(connections, connection_headers, numalign='left')
+ return output
+
+
+# Connections block end
+
+
+def _get_childsa_id_list(ike_sas: list) -> list:
+ """
+ Generate list of CHILD SA ids based on list of OrderingDict
+ wich is returned by vici
+ :param ike_sas: list of IKE SAs generated by vici
+ :type ike_sas: list
+ :return: list of IKE SAs ids
+ :rtype: list
+ """
+ list_childsa_id: list = []
+ for ike in ike_sas:
+ for ike_sa in ike.values():
+ for child_sa in ike_sa['child-sas'].values():
+ list_childsa_id.append(child_sa['uniqueid'].decode('ascii'))
+ return list_childsa_id
+
+
+def _get_con_childsa_name_list(
+ ike_sas: list, filter_dict: typing.Optional[dict] = None
+) -> list:
+ """
+ Generate list of CHILD SA ids based on list of OrderingDict
+ wich is returned by vici
+ :param ike_sas: list of IKE SAs connections generated by vici
+ :type ike_sas: list
+ :param filter_dict: dict of filter options
+ :type filter_dict: dict
+ :return: list of IKE SAs name
+ :rtype: list
+ """
+ list_childsa_name: list = []
+ for ike in ike_sas:
+ for ike_name, ike_values in ike.items():
+ for sa, sa_values in ike_values['children'].items():
+ if filter_dict:
+ if filter_dict.items() <= sa_values.items():
+ list_childsa_name.append(sa)
+ else:
+ list_childsa_name.append(sa)
+ return list_childsa_name
+
+
+def _get_all_sitetosite_peers_name_list() -> list:
+ """
+ Return site-to-site peers configuration
+ :return: site-to-site peers configuration
+ :rtype: list
+ """
+ conf: ConfigTreeQuery = ConfigTreeQuery()
+ config_path = ['vpn', 'ipsec', 'site-to-site', 'peer']
+ peers_config = conf.get_config_dict(
+ config_path,
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ )
+ peers_list: list = []
+ for name in peers_config:
+ peers_list.append(name)
+ return peers_list
+
+
+def _get_tunnel_sw_format(peer: str, tunnel: str) -> str:
+ """
+ Convert tunnel to Strongwan format of CHILD_SA
+ :param peer: Peer name (IKE_SA)
+ :type peer: str
+ :param tunnel: tunnel number (CHILD_SA)
+ :type tunnel: str
+ :return: Converted tunnel name (CHILD_SA)
+ :rtype: str
+ """
+ tunnel_sw = None
+ if tunnel:
+ if tunnel.isnumeric():
+ tunnel_sw = f'{peer}-tunnel-{tunnel}'
+ elif tunnel == 'vti':
+ tunnel_sw = f'{peer}-vti'
+ return tunnel_sw
+
+
+def _initiate_peer_with_childsas(
+ peer: str, tunnel: typing.Optional[str] = None
+) -> None:
+ """
+ Initiate IPSEC peer SAs by vici.
+ If tunnel is None it initiates all peers tunnels
+ :param peer: Peer name (IKE_SA)
+ :type peer: str
+ :param tunnel: tunnel number (CHILD_SA)
+ :type tunnel: str
+ """
+ tunnel_sw = _get_tunnel_sw_format(peer, tunnel)
+ try:
+ con_list: list = vyos.ipsec.get_vici_connection_by_name(peer)
+ if not con_list:
+ raise vyos.opmode.IncorrectValue(
+ f"Peer's {peer} SA(s) not loaded. Initiation was failed"
+ )
+ childsa_name_list: list = _get_con_childsa_name_list(con_list)
+
+ if not tunnel_sw:
+ vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, childsa_name_list)
+ print(f'Peer {peer} initiate result: success')
+ return
+
+ if tunnel_sw in childsa_name_list:
+ vyos.ipsec.vici_initiate_all_child_sa_by_ike(peer, [tunnel_sw])
+ print(f'Peer {peer} tunnel {tunnel} initiate result: success')
+ return
+
+ raise vyos.opmode.IncorrectValue(f'Peer {peer} SA {tunnel} not found, aborting')
+
+ except vyos.ipsec.ViciInitiateError as err:
+ raise vyos.opmode.UnconfiguredSubsystem(err)
+ except vyos.ipsec.ViciCommandError as err:
+ raise vyos.opmode.IncorrectValue(err)
+
+
+def _terminate_peer(peer: str, tunnel: typing.Optional[str] = None) -> None:
+ """
+ Terminate IPSEC peer SAs by vici.
+ If tunnel is None it terminates all peers tunnels
+ :param peer: Peer name (IKE_SA)
+ :type peer: str
+ :param tunnel: tunnel number (CHILD_SA)
+ :type tunnel: str
+ """
+ # Convert tunnel to Strongwan format of CHILD_SA
+ tunnel_sw = _get_tunnel_sw_format(peer, tunnel)
+ try:
+ sa_list: list = vyos.ipsec.get_vici_sas_by_name(peer, tunnel_sw)
+ if sa_list:
+ if tunnel:
+ childsa_id_list: list = _get_childsa_id_list(sa_list)
+ if childsa_id_list:
+ vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw)
+ print(f'Peer {peer} tunnel {tunnel} terminate result: success')
+ else:
+ Warning(
+ f'Peer {peer} tunnel {tunnel} SA is not initiated. Nothing to terminate'
+ )
+ else:
+ vyos.ipsec.terminate_vici_by_name(peer, tunnel_sw)
+ print(f'Peer {peer} terminate result: success')
+ else:
+ Warning(f"Peer's {peer} SAs are not initiated. Nothing to terminate")
+
+ except vyos.ipsec.ViciInitiateError as err:
+ raise vyos.opmode.UnconfiguredSubsystem(err)
+ except vyos.ipsec.ViciCommandError as err:
+ raise vyos.opmode.IncorrectValue(err)
+
+
+def reset_peer(peer: str, tunnel: typing.Optional[str] = None) -> None:
+ """
+ Reset IPSEC peer SAs.
+ If tunnel is None it resets all peers tunnels
+ :param peer: Peer name (IKE_SA)
+ :type peer: str
+ :param tunnel: tunnel number (CHILD_SA)
+ :type tunnel: str
+ """
+ _terminate_peer(peer, tunnel)
+ peer_config = _get_sitetosite_peer_config(peer)
+ # initiate SAs only if 'connection-type=initiate'
+ if (
+ 'connection_type' in peer_config
+ and peer_config['connection_type'] == 'initiate'
+ ):
+ _initiate_peer_with_childsas(peer, tunnel)
+
+
+def reset_all_peers() -> None:
+ sitetosite_list = _get_all_sitetosite_peers_name_list()
+ if sitetosite_list:
+ for peer_name in sitetosite_list:
+ try:
+ reset_peer(peer_name)
+ except vyos.opmode.IncorrectValue as err:
+ print(err)
+ print('Peers reset result: success')
+ else:
+ raise vyos.opmode.UnconfiguredSubsystem(
+ 'VPN IPSec site-to-site is not configured, aborting'
+ )
+
+
+def _get_ra_session_list_by_username(username: typing.Optional[str] = None):
+ """
+ Return list of remote-access IKE_SAs uniqueids
+ :param username:
+ :type username:
+ :return:
+ :rtype:
+ """
+ list_sa_id = []
+ sa_list = _get_raw_data_sas()
+ for sa_val in sa_list:
+ for sa in sa_val.values():
+ if 'remote-eap-id' in sa:
+ if username:
+ if username == sa['remote-eap-id']:
+ list_sa_id.append(sa['uniqueid'])
+ else:
+ list_sa_id.append(sa['uniqueid'])
+ return list_sa_id
+
+
+def reset_ra(username: typing.Optional[str] = None):
+ # Reset remote-access ipsec sessions
+ if username:
+ list_sa_id = _get_ra_session_list_by_username(username)
+ else:
+ list_sa_id = _get_ra_session_list_by_username()
+ if list_sa_id:
+ vyos.ipsec.terminate_vici_ikeid_list(list_sa_id)
+
+
+def reset_profile_dst(profile: str, tunnel: str, nbma_dst: str):
+ if profile and tunnel and nbma_dst:
+ ike_sa_name = f'dmvpn-{profile}-{tunnel}'
+ try:
+ # Get IKE SAs
+ sa_list = convert_data(vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None))
+ if not sa_list:
+ raise vyos.opmode.IncorrectValue(
+ f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting'
+ )
+ sa_nbma_list = list(
+ [
+ x
+ for x in sa_list
+ if ike_sa_name in x and x[ike_sa_name]['remote-host'] == nbma_dst
+ ]
+ )
+ if not sa_nbma_list:
+ raise vyos.opmode.IncorrectValue(
+ f'SA(s) for profile {profile} tunnel {tunnel} remote-host {nbma_dst} not found, aborting'
+ )
+ # terminate IKE SAs
+ vyos.ipsec.terminate_vici_ikeid_list(
+ list(
+ [
+ x[ike_sa_name]['uniqueid']
+ for x in sa_nbma_list
+ if ike_sa_name in x
+ ]
+ )
+ )
+ # 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'
+ )
+ except vyos.ipsec.ViciInitiateError as err:
+ raise vyos.opmode.UnconfiguredSubsystem(err)
+ except vyos.ipsec.ViciCommandError as err:
+ raise vyos.opmode.IncorrectValue(err)
+
+
+def reset_profile_all(profile: str, tunnel: str):
+ if profile and tunnel:
+ ike_sa_name = f'dmvpn-{profile}-{tunnel}'
+ try:
+ # Get IKE SAs
+ sa_list: list = convert_data(
+ vyos.ipsec.get_vici_sas_by_name(ike_sa_name, None)
+ )
+ if not sa_list:
+ raise vyos.opmode.IncorrectValue(
+ f'SA(s) for profile {profile} tunnel {tunnel} not found, aborting'
+ )
+ # 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)
+ except vyos.ipsec.ViciCommandError as err:
+ raise vyos.opmode.IncorrectValue(err)
+
+
+def show_sa(raw: bool):
+ sa_data = _get_raw_data_sas()
+ if raw:
+ return sa_data
+ return _get_formatted_output_sas(sa_data)
+
+
+def _get_output_sas_detail(ra_output_list: list) -> str:
+ """
+ Formate all IKE SAs detail output
+ :param ra_output_list: IKE SAs list
+ :type ra_output_list: list
+ :return: formatted RA IKE SAs detail output
+ :rtype: str
+ """
+ return _get_output_swanctl_sas_from_list(ra_output_list)
+
+
+def show_sa_detail(raw: bool):
+ sa_data = _get_raw_data_sas()
+ if raw:
+ return sa_data
+ return _get_output_sas_detail(sa_data)
+
+
+def show_connections(raw: bool):
+ list_conns = _get_convert_data_connections()
+ list_sas = _get_raw_data_sas()
+ if raw:
+ return _get_raw_data_connections(list_conns, list_sas)
+
+ connections = _get_raw_data_connections(list_conns, list_sas)
+ return _get_formatted_output_conections(connections)
+
+
+def show_connections_summary(raw: bool):
+ list_conns = _get_convert_data_connections()
+ list_sas = _get_raw_data_sas()
+ if raw:
+ return _get_raw_connections_summary(list_conns, list_sas)
+
+
+def _get_ra_sessions(username: typing.Optional[str] = None) -> list:
+ """
+ Return list of remote-access IKE_SAs from VICI by username.
+ If username unspecified, return all remote-access IKE_SAs
+ :param username: Username of RA connection
+ :type username: str
+ :return: list of ra remote-access IKE_SAs
+ :rtype: list
+ """
+ list_sa = []
+ sa_list = _get_raw_data_sas()
+ for conn in sa_list:
+ for sa in conn.values():
+ if 'remote-eap-id' in sa:
+ if username:
+ if username == sa['remote-eap-id']:
+ list_sa.append(conn)
+ else:
+ list_sa.append(conn)
+ return list_sa
+
+
+def _filter_ikesas(list_sa: list, filter_key: str, filter_value: str) -> list:
+ """
+ Filter IKE SAs by specifice key
+ :param list_sa: list of IKE SAs
+ :type list_sa: list
+ :param filter_key: Filter Key
+ :type filter_key: str
+ :param filter_value: Filter Value
+ :type filter_value: str
+ :return: Filtered list of IKE SAs
+ :rtype: list
+ """
+ filtered_sa_list = []
+ for conn in list_sa:
+ for sa in conn.values():
+ if sa[filter_key] and sa[filter_key] == filter_value:
+ filtered_sa_list.append(conn)
+ return filtered_sa_list
+
+
+def _get_last_installed_childsa(sa: dict) -> str:
+ """
+ Return name of last installed active Child SA
+ :param sa: Dictionary with Child SAs
+ :type sa: dict
+ :return: Name of the Last installed active Child SA
+ :rtype: str
+ """
+ child_sa_name = None
+ child_sa_id = 0
+ for sa_name, child_sa in sa['child-sas'].items():
+ if child_sa['state'] == 'INSTALLED':
+ if child_sa_id == 0 or int(child_sa['uniqueid']) > child_sa_id:
+ child_sa_id = int(child_sa['uniqueid'])
+ child_sa_name = sa_name
+ return child_sa_name
+
+
+def _get_formatted_ike_proposal(sa: dict) -> str:
+ """
+ Return IKE proposal string in format
+ EncrALG-EncrKeySize/PFR/HASH/DH-GROUP
+ :param sa: IKE SA
+ :type sa: dict
+ :return: IKE proposal string
+ :rtype: str
+ """
+ proposal = ''
+ proposal = f'{proposal}{sa["encr-alg"]}' if 'encr-alg' in sa else proposal
+ proposal = f'{proposal}-{sa["encr-keysize"]}' if 'encr-keysize' in sa else proposal
+ proposal = f'{proposal}/{sa["prf-alg"]}' if 'prf-alg' in sa else proposal
+ proposal = f'{proposal}/{sa["integ-alg"]}' if 'integ-alg' in sa else proposal
+ proposal = f'{proposal}/{sa["dh-group"]}' if 'dh-group' in sa else proposal
+ return proposal
+
+
+def _get_formatted_ipsec_proposal(sa: dict) -> str:
+ """
+ Return IPSec proposal string in format
+ Protocol: EncrALG-EncrKeySize/HASH/PFS
+ :param sa: Child SA
+ :type sa: dict
+ :return: IPSec proposal string
+ :rtype: str
+ """
+ proposal = ''
+ proposal = f'{proposal}{sa["protocol"]}' if 'protocol' in sa else proposal
+ proposal = f'{proposal}:{sa["encr-alg"]}' if 'encr-alg' in sa else proposal
+ proposal = f'{proposal}-{sa["encr-keysize"]}' if 'encr-keysize' in sa else proposal
+ proposal = f'{proposal}/{sa["integ-alg"]}' if 'integ-alg' in sa else proposal
+ proposal = f'{proposal}/{sa["dh-group"]}' if 'dh-group' in sa else proposal
+ return proposal
+
+
+def _get_output_ra_sas_detail(ra_output_list: list) -> str:
+ """
+ Formate RA IKE SAs detail output
+ :param ra_output_list: IKE SAs list
+ :type ra_output_list: list
+ :return: formatted RA IKE SAs detail output
+ :rtype: str
+ """
+ return _get_output_swanctl_sas_from_list(ra_output_list)
+
+
+def _get_formatted_output_ra_summary(ra_output_list: list):
+ sa_data = []
+ for conn in ra_output_list:
+ for sa in conn.values():
+ sa_id = sa['uniqueid'] if 'uniqueid' in sa else ''
+ sa_username = sa['remote-eap-id'] if 'remote-eap-id' in sa else ''
+ sa_protocol = f'IKEv{sa["version"]}' if 'version' in sa else ''
+ sa_remotehost = sa['remote-host'] if 'remote-host' in sa else ''
+ sa_remoteid = sa['remote-id'] if 'remote-id' in sa else ''
+ sa_ike_proposal = _get_formatted_ike_proposal(sa)
+ sa_tunnel_ip = sa['remote-vips'][0]
+ child_sa_key = _get_last_installed_childsa(sa)
+ if child_sa_key:
+ child_sa = sa['child-sas'][child_sa_key]
+ sa_ipsec_proposal = _get_formatted_ipsec_proposal(child_sa)
+ sa_state = 'UP'
+ sa_uptime = seconds_to_human(sa['established'])
+ else:
+ sa_ipsec_proposal = ''
+ sa_state = 'DOWN'
+ sa_uptime = ''
+ sa_data.append(
+ [
+ sa_id,
+ sa_username,
+ sa_protocol,
+ sa_state,
+ sa_uptime,
+ sa_tunnel_ip,
+ sa_remotehost,
+ sa_remoteid,
+ sa_ike_proposal,
+ sa_ipsec_proposal,
+ ]
+ )
+
+ headers = [
+ 'Connection ID',
+ 'Username',
+ 'Protocol',
+ 'State',
+ 'Uptime',
+ 'Tunnel IP',
+ 'Remote Host',
+ 'Remote ID',
+ 'IKE Proposal',
+ 'IPSec Proposal',
+ ]
+ sa_data = sorted(sa_data, key=_alphanum_key)
+ output = tabulate(sa_data, headers)
+ return output
+
+
+def show_ra_detail(
+ raw: bool,
+ username: typing.Optional[str] = None,
+ conn_id: typing.Optional[str] = None,
+):
+ list_sa: list = _get_ra_sessions()
+ if username:
+ list_sa = _filter_ikesas(list_sa, 'remote-eap-id', username)
+ elif conn_id:
+ list_sa = _filter_ikesas(list_sa, 'uniqueid', conn_id)
+ if not list_sa:
+ raise vyos.opmode.IncorrectValue('No active connections found, aborting')
+ if raw:
+ return list_sa
+ return _get_output_ra_sas_detail(list_sa)
+
+
+def show_ra_summary(raw: bool):
+ list_sa: list = _get_ra_sessions()
+ if not list_sa:
+ raise vyos.opmode.IncorrectValue('No active connections found, aborting')
+ if raw:
+ return list_sa
+ return _get_formatted_output_ra_summary(list_sa)
+
+
+# PSK block
+def _get_raw_psk():
+ conf: ConfigTreeQuery = ConfigTreeQuery()
+ config_path = ['vpn', 'ipsec', 'authentication', 'psk']
+ psk_config = conf.get_config_dict(
+ config_path,
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ )
+
+ psk_list = []
+ for psk, psk_data in psk_config.items():
+ psk_data['psk'] = psk
+ psk_list.append(psk_data)
+
+ return psk_list
+
+
+def _get_formatted_psk(psk_list):
+ headers = ['PSK', 'Id', 'Secret']
+ formatted_data = []
+
+ for psk_data in psk_list:
+ formatted_data.append(
+ [psk_data['psk'], '\n'.join(psk_data['id']), psk_data['secret']]
+ )
+
+ return tabulate(formatted_data, headers=headers)
+
+
+def show_psk(raw: bool):
+ config = ConfigTreeQuery()
+ if not config.exists('vpn ipsec authentication psk'):
+ raise vyos.opmode.UnconfiguredSubsystem(
+ 'VPN ipsec psk authentication is not configured'
+ )
+
+ psk = _get_raw_psk()
+ if raw:
+ return psk
+ return _get_formatted_psk(psk)
+
+
+# PSK block end
+
+
+def _get_sitetosite_peer_config(peer: str):
+ """
+ Return site-to-site peers configuration
+ :return: site-to-site peers configuration
+ :rtype: list
+ """
+ conf: ConfigTreeQuery = ConfigTreeQuery()
+ config_path = ['vpn', 'ipsec', 'site-to-site', 'peer', peer]
+ peers_config = conf.get_config_dict(
+ config_path,
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ )
+ return peers_config
+
+
+if __name__ == '__main__':
+ try:
+ res = vyos.opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, vyos.opmode.Error) as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/op_mode/kernel_modules.py b/src/op_mode/kernel_modules.py
new file mode 100644
index 0000000..e381a1d
--- /dev/null
+++ b/src/op_mode/kernel_modules.py
@@ -0,0 +1,82 @@
+#!/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/>.
+#
+# Purpose:
+# Provides commands for retrieving information about kernel modules.
+
+import sys
+import typing
+
+import vyos.opmode
+
+
+lsmod_tmpl = """
+{% for m in modules -%}
+Module: {{m.name}}
+
+{% if m.holders -%}
+Holders: {{m.holders | join(", ")}}
+{%- endif %}
+
+{% if m.drivers -%}
+Drivers: {{m.drivers | join(", ")}}
+{%- endif %}
+
+{% for k in m.fields -%}
+{{k}}: {{m["fields"][k]}}
+{% endfor %}
+{% if m.parameters %}
+
+Parameters:
+
+{% for p in m.parameters -%}
+{{p}}: {{m["parameters"][p]}}
+{% endfor -%}
+{% endif -%}
+
+-------------
+
+{% endfor %}
+"""
+
+def _get_raw_data(module=None):
+ from vyos.utils.kernel import get_module_data, lsmod
+
+ if module:
+ return [get_module_data(module)]
+ else:
+ return lsmod()
+
+def show(raw: bool, module: typing.Optional[str]):
+ from jinja2 import Template
+
+ data = _get_raw_data(module=module)
+
+ if raw:
+ return data
+ else:
+ t = Template(lsmod_tmpl)
+ output = t.render({"modules": data})
+ return output
+
+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/lldp.py b/src/op_mode/lldp.py
new file mode 100644
index 0000000..fac622b
--- /dev/null
+++ b/src/op_mode/lldp.py
@@ -0,0 +1,163 @@
+#!/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 jmespath
+import json
+import sys
+import typing
+
+from tabulate import tabulate
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search
+
+import vyos.opmode
+unconf_message = 'LLDP is not configured'
+capability_codes = """Capability Codes: R - Router, B - Bridge, W - Wlan r - Repeater, S - Station
+ D - Docsis, T - Telephone, O - Other
+
+"""
+
+def _verify(func):
+ """Decorator checks if LLDP config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ if not config.exists(['service', 'lldp']):
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ return func(*args, **kwargs)
+ return _wrapper
+
+def _get_raw_data(interface=None, detail=False):
+ """
+ If interface name is not set - get all interfaces
+ """
+ tmp = 'lldpcli -f json show neighbors'
+ if detail:
+ tmp += f' details'
+ if interface:
+ tmp += f' ports {interface}'
+ output = cmd(tmp)
+ data = json.loads(output)
+ if not data:
+ return []
+ return data
+
+def _get_formatted_output(raw_data):
+ data_entries = []
+ tmp = dict_search('lldp.interface', raw_data)
+ if not tmp:
+ return None
+ # One can not always ensure that "interface" is of type list, add safeguard.
+ # E.G. Juniper Networks, Inc. ex2300-c-12t only has a dict, not a list of dicts
+ if isinstance(tmp, dict):
+ tmp = [tmp]
+ for neighbor in tmp:
+ for local_if, values in neighbor.items():
+ tmp = []
+
+ # Device field
+ if 'chassis' in values:
+ tmp.append(next(iter(values['chassis'])))
+ else:
+ tmp.append('')
+
+ # Local Port field
+ tmp.append(local_if)
+
+ # Protocol field
+ tmp.append(values['via'])
+
+ # Capabilities
+ cap = ''
+ capabilities = jmespath.search('chassis.[*][0][0].capability', values)
+ # One can not always ensure that "capability" is of type list, add
+ # safeguard. E.G. Unify US-24-250W only has a dict, not a list of dicts
+ if isinstance(capabilities, dict):
+ capabilities = [capabilities]
+ if capabilities:
+ for capability in capabilities:
+ if capability['enabled']:
+ if capability['type'] == 'Router':
+ cap += 'R'
+ if capability['type'] == 'Bridge':
+ cap += 'B'
+ if capability['type'] == 'Wlan':
+ cap += 'W'
+ if capability['type'] == 'Station':
+ cap += 'S'
+ if capability['type'] == 'Repeater':
+ cap += 'r'
+ if capability['type'] == 'Telephone':
+ cap += 'T'
+ if capability['type'] == 'Docsis':
+ cap += 'D'
+ if capability['type'] == 'Other':
+ cap += 'O'
+ tmp.append(cap)
+
+ # Remote software platform
+ platform = jmespath.search('chassis.[*][0][0].descr', values)
+ if platform:
+ tmp.append(platform[:37])
+ else:
+ tmp.append('')
+
+ # Remote interface
+ interface = None
+ if jmespath.search('port.id.type', values) == 'ifname':
+ # Remote peer has explicitly returned the interface name as the PortID
+ interface = jmespath.search('port.id.value', values)
+ if not interface:
+ interface = jmespath.search('port.descr', values)
+ if not interface:
+ interface = jmespath.search('port.id.value', values)
+ if not interface:
+ interface = 'Unknown'
+ tmp.append(interface)
+
+ # Add individual neighbor to output list
+ data_entries.append(tmp)
+
+ headers = ["Device", "Local Port", "Protocol", "Capability", "Platform", "Remote Port"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return capability_codes + output
+
+@_verify
+def show_neighbors(raw: bool, interface: typing.Optional[str], detail: typing.Optional[bool]):
+ if raw or not detail:
+ lldp_data = _get_raw_data(interface=interface, detail=detail)
+ if raw:
+ return lldp_data
+ else:
+ return _get_formatted_output(lldp_data)
+ else: # non-raw, detail
+ tmp = 'lldpcli -f text show neighbors details'
+ if interface:
+ tmp += f' ports {interface}'
+ return cmd(tmp)
+
+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/log.py b/src/op_mode/log.py
new file mode 100644
index 0000000..797ba5a
--- /dev/null
+++ b/src/op_mode/log.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 json
+import re
+import sys
+import typing
+
+from jinja2 import Template
+
+from vyos.utils.process import rc_cmd
+
+import vyos.opmode
+
+journalctl_command_template = Template("""
+--no-hostname
+--quiet
+
+{% if boot %}
+ --boot
+{% endif %}
+
+{% if count %}
+ --lines={{ count }}
+{% endif %}
+
+{% if reverse %}
+ --reverse
+{% endif %}
+
+{% if since %}
+ --since={{ since }}
+{% endif %}
+
+{% if unit %}
+ --unit={{ unit }}
+{% endif %}
+
+{% if utc %}
+ --utc
+{% endif %}
+
+{% if raw %}
+{# By default show 100 only lines for raw option if count does not set #}
+{# Protection from parsing the full log by default #}
+{% if not boot %}
+ --lines={{ '' ~ count if count else '100' }}
+{% endif %}
+ --no-pager
+ --output=json
+{% endif %}
+""")
+
+
+def show(raw: bool,
+ boot: typing.Optional[bool],
+ count: typing.Optional[int],
+ facility: typing.Optional[str],
+ reverse: typing.Optional[bool],
+ utc: typing.Optional[bool],
+ unit: typing.Optional[str]):
+ kwargs = dict(locals())
+
+ journalctl_options = journalctl_command_template.render(kwargs)
+ journalctl_options = re.sub(r'\s+', ' ', journalctl_options)
+ rc, output = rc_cmd(f'journalctl {journalctl_options}')
+ if raw:
+ # Each 'journalctl --output json' line is a separate JSON object
+ # So we should return list of dict
+ return [json.loads(line) for line in output.split('\n')]
+ return output
+
+
+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/maya_date.py b/src/op_mode/maya_date.py
new file mode 100644
index 0000000..847b543
--- /dev/null
+++ b/src/op_mode/maya_date.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2013, 2018 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
+
+class MayaDate(object):
+ """ Converts number of days since UNIX epoch
+ to the Maya calendar date.
+
+ Ancient Maya people used three independent calendars for
+ different purposes.
+
+ The long count calendar is for recording historical events.
+ It represents the number of days passed
+ since some date in the past the Maya believed is the day
+ our world was created.
+
+ Tzolkin calendar is for religious purposes, it has
+ two independent cycles of 13 and 20 days, where 13 day
+ cycle days are numbered, and 20 day cycle days are named.
+
+ Haab calendar is for agriculture and daily life, it's a
+ 365 day calendar with 18 months 20 days each, and 5
+ nameless days.
+
+ The smallest unit of the long count calendar is one day (kin).
+ """
+
+ """ The long count calendar uses five different base 18 or base 20
+ cycles. Modern scholars write long count calendar dates in a dot separated format
+ from longest to shortest cycle,
+ <baktun>.<katun>.<tun>.<winal>.<kin>
+ for example, "13.0.0.9.2".
+
+ Classic version actually used by the ancient Maya wraps around
+ every 13th baktun, but modern historians often use longer cycles
+ such as piktun = 20 baktun.
+
+ """
+ kin = 1
+ winal = 20 # 20 kin
+ tun = 360 # 18 winal
+ katun = 7200 # 20 tun
+ baktun = 144000 # 20 katun
+
+ """ Tzolk'in date is composed of two independent cycles.
+ Dates repeat every 260 days, 13 Ajaw is considered the end
+ of tzolk'in.
+
+ Every day of the 20 day cycle has unique name, we number
+ them from zero so it's easier to map the remainder to day:
+ """
+ tzolkin_days = { 0: "Imix'",
+ 1: "Ik'",
+ 2: "Ak'b'al",
+ 3: "K'an",
+ 4: "Chikchan",
+ 5: "Kimi",
+ 6: "Manik'",
+ 7: "Lamat",
+ 8: "Muluk",
+ 9: "Ok",
+ 10: "Chuwen",
+ 11: "Eb'",
+ 12: "B'en",
+ 13: "Ix",
+ 14: "Men",
+ 15: "Kib'",
+ 16: "Kab'an",
+ 17: "Etz'nab'",
+ 18: "Kawak",
+ 19: "Ajaw" }
+
+ """ As said above, haab (year) has 19 months. Only 18 are
+ true months of 20 days each, the remaining 5 days called "wayeb"
+ do not really belong to any month, but we think of them as a pseudo-month
+ for convenience.
+
+ Also, note that days of the month are actually numbered from 0, not from 1,
+ it's not for technical reasons.
+ """
+ haab_months = { 0: "Pop",
+ 1: "Wo'",
+ 2: "Sip",
+ 3: "Sotz'",
+ 4: "Sek",
+ 5: "Xul",
+ 6: "Yaxk'in'",
+ 7: "Mol",
+ 8: "Ch'en",
+ 9: "Yax",
+ 10: "Sak'",
+ 11: "Keh",
+ 12: "Mak",
+ 13: "K'ank'in",
+ 14: "Muwan'",
+ 15: "Pax",
+ 16: "K'ayab",
+ 17: "Kumk'u",
+ 18: "Wayeb'" }
+
+ """ Now we need to map the beginning of UNIX epoch
+ (Jan 1 1970 00:00 UTC) to the beginning of the long count
+ calendar (0.0.0.0.0, 4 Ajaw, 8 Kumk'u).
+
+ The problem with mapping the long count calendar to
+ any other is that its start date is not known exactly.
+
+ The most widely accepted hypothesis suggests it was
+ August 11, 3114 BC gregorian date. In this case UNIX epoch
+ starts on 12.17.16.7.5, 13 Chikchan, 3 K'ank'in
+
+ It's known as Goodman-Martinez-Thompson (GMT) correlation
+ constant.
+ """
+ start_days = 1856305
+
+ """ Seconds in day, for conversion from timestamp """
+ seconds_in_day = 60 * 60 * 24
+
+ def __init__(self, timestamp):
+ if timestamp is None:
+ self.days = self.start_days
+ else:
+ self.days = self.start_days + (int(timestamp) // self.seconds_in_day)
+
+ def long_count_date(self):
+ """ Returns long count date string """
+ days = self.days
+
+ cur_baktun = days // self.baktun
+ days = days % self.baktun
+
+ cur_katun = days // self.katun
+ days = days % self.katun
+
+ cur_tun = days // self.tun
+ days = days % self.tun
+
+ cur_winal = days // self.winal
+ days = days % self.winal
+
+ cur_kin = days
+
+ longcount_string = "{0}.{1}.{2}.{3}.{4}".format( cur_baktun,
+ cur_katun,
+ cur_tun,
+ cur_winal,
+ cur_kin )
+ return(longcount_string)
+
+ def tzolkin_date(self):
+ """ Returns tzolkin date string """
+ days = self.days
+
+ """ The start date is not the beginning of both cycles,
+ it's 4 Ajaw. So we need to add 4 to the 13 days cycle day,
+ and substract 1 from the 20 day cycle to get correct result.
+ """
+ tzolkin_13 = (days + 4) % 13
+ tzolkin_20 = (days - 1) % 20
+
+ tzolkin_string = "{0} {1}".format(tzolkin_13, self.tzolkin_days[tzolkin_20])
+
+ return(tzolkin_string)
+
+ def haab_date(self):
+ """ Returns haab date string.
+
+ The time start on 8 Kumk'u rather than 0 Pop, which is
+ 17 days before the new haab, so we need to substract 17
+ from the current date to get correct result.
+ """
+ days = self.days
+
+ haab_day = (days - 17) % 365
+ haab_month = haab_day // 20
+ haab_day_of_month = haab_day % 20
+
+ haab_string = "{0} {1}".format(haab_day_of_month, self.haab_months[haab_month])
+
+ return(haab_string)
+
+ def date(self):
+ return("{0}, {1}, {2}".format( self.long_count_date(), self.tzolkin_date(), self.haab_date() ))
+
+if __name__ == '__main__':
+ try:
+ timestamp = sys.argv[1]
+ except:
+ print("Please specify timestamp in the argument")
+ sys.exit(1)
+
+ maya_date = MayaDate(timestamp)
+ print(maya_date.date())
diff --git a/src/op_mode/memory.py b/src/op_mode/memory.py
new file mode 100644
index 0000000..eb53003
--- /dev/null
+++ b/src/op_mode/memory.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-2022 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 vyos.opmode
+
+
+def _get_raw_data():
+ from re import search as re_search
+
+ def find_value(keyword, mem_data):
+ regex = keyword + ':\s+(\d+)'
+ res = re_search(regex, mem_data).group(1)
+ return int(res)
+
+ with open("/proc/meminfo", "r") as f:
+ mem_data = f.read()
+
+ total = find_value('MemTotal', mem_data)
+ available = find_value('MemAvailable', mem_data)
+ buffers = find_value('Buffers', mem_data)
+ cached = find_value('Cached', mem_data)
+
+ used = total - available
+
+ mem_data = {
+ "total": total,
+ "free": available,
+ "used": used,
+ "buffers": buffers,
+ "cached": cached
+ }
+
+ for key in mem_data:
+ # The Linux kernel exposes memory values in kilobytes,
+ # so we need to normalize them
+ mem_data[key] = mem_data[key] * 1024
+
+ return mem_data
+
+def _get_formatted_output(mem):
+ from vyos.utils.convert import bytes_to_human
+
+ # For human-readable outputs, we convert bytes to more convenient units
+ # (100M, 1.3G...)
+ for key in mem:
+ mem[key] = bytes_to_human(mem[key])
+
+ out = "Total: {}\n".format(mem["total"])
+ out += "Free: {}\n".format(mem["free"])
+ out += "Used: {}".format(mem["used"])
+
+ return out
+
+def show(raw: bool):
+ ram_data = _get_raw_data()
+
+ if raw:
+ return ram_data
+ else:
+ return _get_formatted_output(ram_data)
+
+
+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/mtr.py b/src/op_mode/mtr.py
new file mode 100644
index 0000000..de139f2
--- /dev/null
+++ b/src/op_mode/mtr.py
@@ -0,0 +1,306 @@
+#! /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 socket
+import ipaddress
+
+from vyos.utils.network import interface_list
+from vyos.utils.network import vrf_list
+from vyos.utils.process import call
+
+options = {
+ 'report': {
+ 'mtr': '{command} --report',
+ 'type': 'noarg',
+ 'help': 'This option puts mtr into report mode. When in this mode, mtr will run for the number of cycles specified by the -c option, and then print statistics and exit.'
+ },
+ 'report-wide': {
+ 'mtr': '{command} --report-wide',
+ 'type': 'noarg',
+ 'help': 'This option puts mtr into wide report mode. When in this mode, mtr will not cut hostnames in the report.'
+ },
+ 'raw': {
+ 'mtr': '{command} --raw',
+ 'type': 'noarg',
+ 'help': 'Use the raw output format. This format is better suited for archival of the measurement results.'
+ },
+ 'json': {
+ 'mtr': '{command} --json',
+ 'type': 'noarg',
+ 'help': 'Use this option to tell mtr to use the JSON output format.'
+ },
+ 'split': {
+ 'mtr': '{command} --split',
+ 'type': 'noarg',
+ 'help': 'Use this option to set mtr to spit out a format that is suitable for a split-user interface.'
+ },
+ 'no-dns': {
+ 'mtr': '{command} --no-dns',
+ 'type': 'noarg',
+ 'help': 'Use this option to force mtr to display numeric IP numbers and not try to resolve the host names.'
+ },
+ 'show-ips': {
+ 'mtr': '{command} --show-ips {value}',
+ 'type': '<num>',
+ 'help': 'Use this option to tell mtr to display both the host names and numeric IP numbers.'
+ },
+ 'ipinfo': {
+ 'mtr': '{command} --ipinfo {value}',
+ 'type': '<num>',
+ 'help': 'Displays information about each IP hop.'
+ },
+ 'aslookup': {
+ 'mtr': '{command} --aslookup',
+ 'type': 'noarg',
+ 'help': 'Displays the Autonomous System (AS) number alongside each hop. Equivalent to --ipinfo 0.'
+ },
+ 'interval': {
+ 'mtr': '{command} --interval {value}',
+ 'type': '<num>',
+ 'help': 'Use this option to specify the positive number of seconds between ICMP ECHO requests. The default value for this parameter is one second. The root user may choose values between zero and one.'
+ },
+ 'report-cycles': {
+ 'mtr': '{command} --report-cycles {value}',
+ 'type': '<num>',
+ 'help': 'Use this option to set the number of pings sent to determine both the machines on the network and the reliability of those machines. Each cycle lasts one second.'
+ },
+ 'psize': {
+ 'mtr': '{command} --psize {value}',
+ 'type': '<num>',
+ 'help': 'This option sets the packet size used for probing. It is in bytes, inclusive IP and ICMP headers. If set to a negative number, every iteration will use a different, random packet size up to that number.'
+ },
+ 'bitpattern': {
+ 'mtr': '{command} --bitpattern {value}',
+ 'type': '<num>',
+ 'help': 'Specifies bit pattern to use in payload. Should be within range 0 - 255. If NUM is greater than 255, a random pattern is used.'
+ },
+ 'gracetime': {
+ 'mtr': '{command} --gracetime {value}',
+ 'type': '<num>',
+ 'help': 'Use this option to specify the positive number of seconds to wait for responses after the final request. The default value is five seconds.'
+ },
+ 'tos': {
+ 'mtr': '{command} --tos {value}',
+ 'type': '<tos>',
+ 'help': 'Specifies value for type of service field in IP header. Should be within range 0 - 255.'
+ },
+ 'mpls': {
+ 'mtr': '{command} --mpls {value}',
+ 'type': 'noarg',
+ 'help': 'Use this option to tell mtr to display information from ICMP extensions for MPLS (RFC 4950) that are encoded in the response packets.'
+ },
+ 'interface': {
+ 'mtr': '{command} --interface {value}',
+ 'type': '<interface>',
+ 'helpfunction': interface_list,
+ 'help': 'Use the network interface with a specific name for sending network probes. This can be useful when you have multiple network interfaces with routes to your destination, for example both wired Ethernet and WiFi, and wish to test a particular interface.'
+ },
+ 'address': {
+ 'mtr': '{command} --address {value}',
+ 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>',
+ 'help': 'Use this option to bind the outgoing socket to ADDRESS, so that all packets will be sent with ADDRESS as source address.'
+ },
+ 'first-ttl': {
+ 'mtr': '{command} --first-ttl {value}',
+ 'type': '<num>',
+ 'help': 'Specifies with what TTL to start. Defaults to 1.'
+ },
+ 'max-ttl': {
+ 'mtr': '{command} --max-ttl {value}',
+ 'type': '<num>',
+ 'help': 'Specifies the maximum number of hops or max time-to-live value mtr will probe. Default is 30.'
+ },
+ 'max-unknown': {
+ 'mtr': '{command} --max-unknown {value}',
+ 'type': '<num>',
+ 'help': 'Specifies the maximum unknown host. Default is 5.'
+ },
+ 'udp': {
+ 'mtr': '{command} --udp',
+ 'type': 'noarg',
+ 'help': 'Use UDP datagrams instead of ICMP ECHO.'
+ },
+ 'tcp': {
+ 'mtr': '{command} --tcp',
+ 'type': 'noarg',
+ 'help': ' Use TCP SYN packets instead of ICMP ECHO. PACKETSIZE is ignored, since SYN packets can not contain data.'
+ },
+ 'sctp': {
+ 'mtr': '{command} --sctp',
+ 'type': 'noarg',
+ 'help': 'Use Stream Control Transmission Protocol packets instead of ICMP ECHO.'
+ },
+ 'port': {
+ 'mtr': '{command} --port {value}',
+ 'type': '<port>',
+ 'help': 'The target port number for TCP/SCTP/UDP traces.'
+ },
+ 'localport': {
+ 'mtr': '{command} --localport {value}',
+ 'type': '<port>',
+ 'help': 'The source port number for UDP traces.'
+ },
+ 'timeout': {
+ 'mtr': '{command} --timeout {value}',
+ 'type': '<num>',
+ 'help': ' The number of seconds to keep probe sockets open before giving up on the connection.'
+ },
+ 'mark': {
+ 'mtr': '{command} --mark {value}',
+ 'type': '<num>',
+ 'help': ' Set the mark for each packet sent through this socket similar to the netfilter MARK target but socket-based. MARK is 32 unsigned integer.'
+ },
+ 'vrf': {
+ 'mtr': 'sudo ip vrf exec {value} {command}',
+ 'type': '<vrf>',
+ 'help': 'Use specified VRF table',
+ 'helpfunction': vrf_list,
+ 'dflt': 'default'
+ }
+ }
+
+mtr = {
+ 4: '/bin/mtr -4',
+ 6: '/bin/mtr -6',
+}
+
+class List(list):
+ def first(self):
+ return self.pop(0) if self else ''
+
+ def last(self):
+ return self.pop() if self else ''
+
+ def prepend(self, value):
+ self.insert(0, value)
+
+
+def completion_failure(option: str) -> None:
+ """
+ Shows failure message after TAB when option is wrong
+ :param option: failure option
+ :type str:
+ """
+ sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option))
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def expension_failure(option, completions):
+ reason = 'Ambiguous' if completions else 'Invalid'
+ sys.stderr.write(
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
+ option))
+ if completions:
+ sys.stderr.write(' Possible completions:\n ')
+ sys.stderr.write('\n '.join(completions))
+ sys.stderr.write('\n')
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def complete(prefix):
+ return [o for o in options if o.startswith(prefix)]
+
+
+def convert(command, args):
+ while args:
+ shortname = args.first()
+ longnames = complete(shortname)
+ if len(longnames) != 1:
+ expension_failure(shortname, longnames)
+ longname = longnames[0]
+ if options[longname]['type'] == 'noarg':
+ command = options[longname]['mtr'].format(
+ command=command, value='')
+ elif not args:
+ sys.exit(f'mtr: missing argument for {longname} option')
+ else:
+ command = options[longname]['mtr'].format(
+ command=command, value=args.first())
+ return command
+
+
+if __name__ == '__main__':
+ args = List(sys.argv[1:])
+ host = args.first()
+
+ if not host:
+ sys.exit("mtr: Missing host")
+
+
+ if host == '--get-options' or host == '--get-options-nested':
+ if host == '--get-options-nested':
+ args.first() # pop monitor
+ args.first() # pop mtr | traceroute
+ args.first() # pop IP
+ usedoptionslist = []
+ while args:
+ option = args.first() # pop option
+ matched = complete(option) # get option parameters
+ usedoptionslist.append(option) # list of used options
+ # Select options
+ if not args:
+ # remove from Possible completions used options
+ for o in usedoptionslist:
+ if o in matched:
+ matched.remove(o)
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+
+ if len(matched) > 1:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+ # If option doesn't have value
+ if matched:
+ if options[matched[0]]['type'] == 'noarg':
+ continue
+ else:
+ # Unexpected option
+ completion_failure(option)
+
+ value = args.first() # pop option's value
+ if not args:
+ matched = complete(option)
+ helplines = options[matched[0]]['type']
+ # Run helpfunction to get list of possible values
+ if 'helpfunction' in options[matched[0]]:
+ result = options[matched[0]]['helpfunction']()
+ if result:
+ helplines = '\n' + ' '.join(result)
+ sys.stdout.write(helplines)
+ sys.exit(0)
+
+ for name, option in options.items():
+ if 'dflt' in option and name not in args:
+ args.append(name)
+ args.append(option['dflt'])
+
+ try:
+ ip = socket.gethostbyname(host)
+ except UnicodeError:
+ sys.exit(f'mtr: Unknown host: {host}')
+ except socket.gaierror:
+ ip = host
+
+ try:
+ version = ipaddress.ip_address(ip).version
+ except ValueError:
+ sys.exit(f'mtr: Unknown host: {host}')
+
+ command = convert(mtr[version], args)
+ call(f'{command} --curses --displaymode 0 {host}')
diff --git a/src/op_mode/multicast.py b/src/op_mode/multicast.py
new file mode 100644
index 0000000..0666f8a
--- /dev/null
+++ b/src/op_mode/multicast.py
@@ -0,0 +1,72 @@
+#!/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 json
+import sys
+import typing
+
+from tabulate import tabulate
+from vyos.utils.process import cmd
+
+import vyos.opmode
+
+ArgFamily = typing.Literal['inet', 'inet6']
+
+def _get_raw_data(family, interface=None):
+ tmp = 'ip -4'
+ if family == 'inet6':
+ tmp = 'ip -6'
+ tmp = f'{tmp} -j maddr show'
+ if interface:
+ tmp = f'{tmp} dev {interface}'
+ output = cmd(tmp)
+ data = json.loads(output)
+ if not data:
+ return []
+ return data
+
+def _get_formatted_output(raw_data):
+ data_entries = []
+
+ # sort result by interface name
+ for interface in sorted(raw_data, key=lambda x: x['ifname']):
+ for address in interface['maddr']:
+ tmp = []
+ tmp.append(interface['ifname'])
+ tmp.append(address['family'])
+ tmp.append(address['address'])
+
+ data_entries.append(tmp)
+
+ headers = ["Interface", "Family", "Address"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+def show_group(raw: bool, family: ArgFamily, interface: typing.Optional[str]):
+ multicast_data = _get_raw_data(family=family, interface=interface)
+ if raw:
+ return multicast_data
+ else:
+ return _get_formatted_output(multicast_data)
+
+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/nat.py b/src/op_mode/nat.py
new file mode 100644
index 0000000..c6cf477
--- /dev/null
+++ b/src/op_mode/nat.py
@@ -0,0 +1,365 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 jmespath
+import json
+import sys
+import xmltodict
+import typing
+
+from tabulate import tabulate
+
+import vyos.opmode
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search
+
+ArgDirection = typing.Literal['source', 'destination']
+ArgFamily = typing.Literal['inet', 'inet6']
+
+
+def _get_xml_translation(direction, family, address=None):
+ """
+ Get conntrack XML output --src-nat|--dst-nat
+ """
+ if direction == 'source':
+ opt = '--src-nat'
+ if direction == 'destination':
+ opt = '--dst-nat'
+ tmp = f'conntrack --dump --family {family} {opt} --output xml'
+ if address:
+ tmp += f' --src {address}'
+ return cmd(tmp)
+
+
+def _xml_to_dict(xml):
+ """
+ Convert XML to dictionary
+ Return: dictionary
+ """
+ parse = xmltodict.parse(xml, attr_prefix='')
+ # If only one conntrack entry we must change dict
+ if 'meta' in parse['conntrack']['flow']:
+ return dict(conntrack={'flow': [parse['conntrack']['flow']]})
+ return parse
+
+
+def _get_json_data(direction, family):
+ """
+ Get NAT format JSON
+ """
+ if direction == 'source':
+ chain = 'POSTROUTING'
+ if direction == 'destination':
+ chain = 'PREROUTING'
+ family = 'ip6' if family == 'inet6' else 'ip'
+ return cmd(f'nft --json list chain {family} vyos_nat {chain}')
+
+
+def _get_raw_data_rules(direction, family):
+ """Get interested rules
+ :returns dict
+ """
+ data = _get_json_data(direction, family)
+ data_dict = json.loads(data)
+ rules = []
+ for rule in data_dict['nftables']:
+ if 'rule' in rule and 'comment' in rule['rule']:
+ rules.append(rule)
+ return rules
+
+
+def _get_raw_translation(direction, family, address=None):
+ """
+ Return: dictionary
+ """
+ xml = _get_xml_translation(direction, family, address)
+ if len(xml) == 0:
+ output = {'conntrack':
+ {
+ 'error': True,
+ 'reason': 'entries not found'
+ }
+ }
+ return output
+ return _xml_to_dict(xml)
+
+
+def _get_formatted_output_rules(data, direction, family):
+
+
+ def _get_ports_for_output(rules):
+ """
+ Return: string of configured ports
+ """
+ ports = []
+ if 'set' in rules:
+ for index, port in enumerate(rules['set']):
+ if 'range' in str(rules['set'][index]):
+ output = rules['set'][index]['range']
+ output = '-'.join(map(str, output))
+ else:
+ output = str(port)
+ ports.append(output)
+ # When NAT rule contains port range or single port
+ # JSON will not contain keyword 'set'
+ elif 'range' in rules:
+ output = rules['range']
+ output = '-'.join(map(str, output))
+ ports.append(output)
+ else:
+ output = rules['right']
+ ports.append(str(output))
+ result = ','.join(ports)
+ # Handle case where ports in NAT rule are negated
+ if rules['op'] == '!=':
+ result = '!' + result
+ return(result)
+
+ # Add default values before loop
+ sport, dport, proto = 'any', 'any', 'any'
+ saddr = '::/0' if family == 'inet6' else '0.0.0.0/0'
+ daddr = '::/0' if family == 'inet6' else '0.0.0.0/0'
+
+ data_entries = []
+ for rule in data:
+ if 'comment' in rule['rule']:
+ comment = rule.get('rule').get('comment')
+ rule_number = comment.split('-')[-1]
+ rule_number = rule_number.split(' ')[0]
+ if 'expr' in rule['rule']:
+ interface = rule.get('rule').get('expr')[0].get('match').get('right') \
+ if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any'
+ for index, match in enumerate(jmespath.search('rule.expr[*].match', rule)):
+ if 'payload' in match['left']:
+ # Handle NAT rule containing comma-seperated list of ports
+ if (isinstance(match['right'], dict) and
+ ('prefix' in match['right'] or 'set' in match['right'] or
+ 'range' in match['right'])):
+ # Merge dict src/dst l3_l4 parameters
+ my_dict = {**match['left']['payload'], **match['right']}
+ my_dict['op'] = match['op']
+ op = '!' if my_dict.get('op') == '!=' else ''
+ proto = my_dict.get('protocol').upper()
+ if my_dict['field'] == 'saddr':
+ saddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}'
+ elif my_dict['field'] == 'daddr':
+ daddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}'
+ elif my_dict['field'] == 'sport':
+ sport = _get_ports_for_output(my_dict)
+ elif my_dict['field'] == 'dport':
+ dport = _get_ports_for_output(my_dict)
+ # Handle NAT rule containing a single port
+ else:
+ field = jmespath.search('left.payload.field', match)
+ if field == 'saddr':
+ saddr = match.get('right')
+ elif field == 'daddr':
+ daddr = match.get('right')
+ elif field == 'sport':
+ sport = _get_ports_for_output(match)
+ elif field == 'dport':
+ dport = _get_ports_for_output(match)
+ else:
+ saddr = '::/0' if family == 'inet6' else '0.0.0.0/0'
+ daddr = '::/0' if family == 'inet6' else '0.0.0.0/0'
+ sport = 'any'
+ dport = 'any'
+ proto = 'any'
+
+ source = f'''{saddr}
+sport {sport}'''
+ destination = f'''{daddr}
+dport {dport}'''
+
+ if jmespath.search('left.payload.field', match) == 'protocol':
+ field_proto = match.get('right').upper()
+
+ for expr in rule.get('rule').get('expr'):
+ if 'snat' in expr:
+ translation = dict_search('snat.addr', expr)
+ if expr['snat'] and 'port' in expr['snat']:
+ if jmespath.search('snat.port.range', expr):
+ port = dict_search('snat.port.range', expr)
+ port = '-'.join(map(str, port))
+ else:
+ port = expr['snat']['port']
+ translation = f'''{translation}
+port {port}'''
+
+ elif 'masquerade' in expr:
+ translation = 'masquerade'
+ if expr['masquerade'] and 'port' in expr['masquerade']:
+ if jmespath.search('masquerade.port.range', expr):
+ port = dict_search('masquerade.port.range', expr)
+ port = '-'.join(map(str, port))
+ else:
+ port = expr['masquerade']['port']
+
+ translation = f'''{translation}
+port {port}'''
+ elif 'dnat' in expr:
+ translation = dict_search('dnat.addr', expr)
+ if expr['dnat'] and 'port' in expr['dnat']:
+ if jmespath.search('dnat.port.range', expr):
+ port = dict_search('dnat.port.range', expr)
+ port = '-'.join(map(str, port))
+ else:
+ port = expr['dnat']['port']
+ translation = f'''{translation}
+port {port}'''
+ else:
+ translation = 'exclude'
+ # Overwrite match loop 'proto' if specified filed 'protocol' exist
+ if 'protocol' in jmespath.search('rule.expr[*].match.left.payload.field', rule):
+ proto = jmespath.search('rule.expr[0].match.right', rule).upper()
+
+ data_entries.append([rule_number, source, destination, proto, interface, translation])
+
+ interface_header = 'Out-Int' if direction == 'source' else 'In-Int'
+ headers = ["Rule", "Source", "Destination", "Proto", interface_header, "Translation"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def _get_formatted_output_statistics(data, direction):
+ data_entries = []
+ for rule in data:
+ if 'comment' in rule['rule']:
+ comment = rule.get('rule').get('comment')
+ rule_number = comment.split('-')[-1]
+ rule_number = rule_number.split(' ')[0]
+ if 'expr' in rule['rule']:
+ interface = rule.get('rule').get('expr')[0].get('match').get('right') \
+ if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any'
+ packets = jmespath.search('rule.expr[*].counter.packets | [0]', rule)
+ _bytes = jmespath.search('rule.expr[*].counter.bytes | [0]', rule)
+ data_entries.append([rule_number, packets, _bytes, interface])
+ headers = ["Rule", "Packets", "Bytes", "Interface"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def _get_formatted_translation(dict_data, nat_direction, family, verbose):
+ data_entries = []
+ if 'error' in dict_data['conntrack']:
+ return 'Entries not found'
+ for entry in dict_data['conntrack']['flow']:
+ orig_src, orig_dst, orig_sport, orig_dport = {}, {}, {}, {}
+ reply_src, reply_dst, reply_sport, reply_dport = {}, {}, {}, {}
+ proto = {}
+ for meta in entry['meta']:
+ direction = meta['direction']
+ if direction in ['original']:
+ if 'layer3' in meta:
+ orig_src = meta['layer3']['src']
+ orig_dst = meta['layer3']['dst']
+ if 'layer4' in meta:
+ if meta.get('layer4').get('sport'):
+ orig_sport = meta['layer4']['sport']
+ if meta.get('layer4').get('dport'):
+ orig_dport = meta['layer4']['dport']
+ proto = meta['layer4']['protoname']
+ if direction in ['reply']:
+ if 'layer3' in meta:
+ reply_src = meta['layer3']['src']
+ reply_dst = meta['layer3']['dst']
+ if 'layer4' in meta:
+ if meta.get('layer4').get('sport'):
+ reply_sport = meta['layer4']['sport']
+ if meta.get('layer4').get('dport'):
+ reply_dport = meta['layer4']['dport']
+ proto = meta['layer4']['protoname']
+ if direction == 'independent':
+ conn_id = meta['id']
+ timeout = meta.get('timeout', 'n/a')
+ orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src
+ orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst
+ reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src
+ reply_dst = f'{reply_dst}:{reply_dport}' if reply_dport else reply_dst
+ state = meta['state'] if 'state' in meta else ''
+ mark = meta.get('mark', '')
+ zone = meta['zone'] if 'zone' in meta else ''
+ if nat_direction == 'source':
+ tmp = [orig_src, reply_dst, proto, timeout, mark, zone]
+ data_entries.append(tmp)
+ elif nat_direction == 'destination':
+ tmp = [orig_dst, reply_src, proto, timeout, mark, zone]
+ data_entries.append(tmp)
+
+ headers = ["Pre-NAT", "Post-NAT", "Proto", "Timeout", "Mark", "Zone"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def _verify(func):
+ """Decorator checks if NAT config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ base = 'nat66' if 'inet6' in sys.argv[1:] else 'nat'
+ if not config.exists(base):
+ raise vyos.opmode.UnconfiguredSubsystem(f'{base.upper()} is not configured')
+ return func(*args, **kwargs)
+ return _wrapper
+
+
+@_verify
+def show_rules(raw: bool, direction: ArgDirection, family: ArgFamily):
+ nat_rules = _get_raw_data_rules(direction, family)
+ if raw:
+ return nat_rules
+ else:
+ return _get_formatted_output_rules(nat_rules, direction, family)
+
+
+@_verify
+def show_statistics(raw: bool, direction: ArgDirection, family: ArgFamily):
+ nat_statistics = _get_raw_data_rules(direction, family)
+ if raw:
+ return nat_statistics
+ else:
+ return _get_formatted_output_statistics(nat_statistics, direction)
+
+
+@_verify
+def show_translations(raw: bool, direction: ArgDirection,
+ family: ArgFamily,
+ address: typing.Optional[str],
+ verbose: typing.Optional[bool]):
+ family = 'ipv6' if family == 'inet6' else 'ipv4'
+ nat_translation = _get_raw_translation(direction,
+ family=family,
+ address=address)
+
+ if raw:
+ return nat_translation
+ else:
+ return _get_formatted_translation(nat_translation, direction, family,
+ verbose)
+
+
+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/neighbor.py b/src/op_mode/neighbor.py
new file mode 100644
index 0000000..8b3c45c
--- /dev/null
+++ b/src/op_mode/neighbor.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+
+# Sample output of `ip --json neigh list`:
+#
+# [
+# {
+# "dst": "192.168.1.1",
+# "dev": "eth0", # Missing if `dev ...` option is used
+# "lladdr": "00:aa:bb:cc:dd:ee", # May be missing for failed entries
+# "state": [
+# "REACHABLE"
+# ]
+# },
+# ]
+
+import sys
+import typing
+
+import vyos.opmode
+from vyos.utils.network import interface_exists
+
+ArgFamily = typing.Literal['inet', 'inet6']
+ArgState = typing.Literal['reachable', 'stale', 'failed', 'permanent']
+
+def get_raw_data(family, interface=None, state=None):
+ from json import loads
+ from vyos.utils.process import cmd
+
+ if interface:
+ if not interface_exists(interface):
+ raise ValueError(f"Interface '{interface}' does not exist in the system")
+ interface = f"dev {interface}"
+ else:
+ interface = ""
+
+ if state:
+ state = f"nud {state}"
+ else:
+ state = ""
+
+ neigh_cmd = f"ip --family {family} --json neighbor list {interface} {state}"
+
+ data = loads(cmd(neigh_cmd))
+
+ return data
+
+def format_neighbors(neighs, interface=None):
+ from tabulate import tabulate
+
+ def entry_to_list(e, intf=None):
+ dst = e["dst"]
+
+ # State is always a list in the iproute2 output
+ state = ", ".join(e["state"])
+
+ # Link layer address is absent from e.g. FAILED entries
+ if "lladdr" in e:
+ lladdr = e["lladdr"]
+ else:
+ lladdr = None
+
+ # Device field is absent from outputs of `ip neigh list dev ...`
+ if "dev" in e:
+ dev = e["dev"]
+ elif interface:
+ dev = interface
+ else:
+ raise ValueError("interface is not defined")
+
+ return [dst, dev, lladdr, state]
+
+ neighs = map(entry_to_list, neighs)
+
+ headers = ["Address", "Interface", "Link layer address", "State"]
+ return tabulate(neighs, headers)
+
+def show(raw: bool, family: ArgFamily, interface: typing.Optional[str],
+ state: typing.Optional[ArgState]):
+ """ Display neighbor table contents """
+ data = get_raw_data(family, interface, state=state)
+
+ if raw:
+ return data
+ else:
+ return format_neighbors(data, interface)
+
+def reset(family: ArgFamily, interface: typing.Optional[str], address: typing.Optional[str]):
+ from vyos.utils.process import run
+
+ if address and interface:
+ raise ValueError("interface and address parameters are mutually exclusive")
+ elif address:
+ run(f"""ip --family {family} neighbor flush to {address}""")
+ elif interface:
+ run(f"""ip --family {family} neighbor flush dev {interface}""")
+ else:
+ # Flush an entire neighbor table
+ run(f"""ip --family {family} neighbor flush""")
+
+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/nhrp.py b/src/op_mode/nhrp.py
new file mode 100644
index 0000000..e66f330
--- /dev/null
+++ b/src/op_mode/nhrp.py
@@ -0,0 +1,101 @@
+#!/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/ntp.py b/src/op_mode/ntp.py
new file mode 100644
index 0000000..6ec0fed
--- /dev/null
+++ b/src/op_mode/ntp.py
@@ -0,0 +1,177 @@
+#!/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 csv
+import sys
+from itertools import chain
+
+import vyos.opmode
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+
+def _get_raw_data(command: str) -> dict:
+ # Returns returns chronyc output as a dictionary
+
+ # Initialize dictionary keys to align with output of
+ # chrony -c. From some commands, its -c switch outputs
+ # more parameters, make sure to include them all below.
+ # See to chronyc(1) for definition of key variables
+ match command:
+ case "chronyc -c activity":
+ keys: list = [
+ 'sources_online',
+ 'sources_offline',
+ 'sources_doing_burst_return_online',
+ 'sources_doing_burst_return_offline',
+ 'sources_with_unknown_address'
+ ]
+
+ case "chronyc -c sources":
+ keys: list = [
+ 'm',
+ 's',
+ 'name_ip_address',
+ 'stratum',
+ 'poll',
+ 'reach',
+ 'last_rx',
+ 'last_sample_adj_offset',
+ 'last_sample_mes_offset',
+ 'last_sample_est_error'
+ ]
+
+ case "chronyc -c sourcestats":
+ keys: list = [
+ 'name_ip_address',
+ 'np',
+ 'nr',
+ 'span',
+ 'frequency',
+ 'freq_skew',
+ 'offset',
+ 'std_dev'
+ ]
+
+ case "chronyc -c tracking":
+ keys: list = [
+ 'ref_id',
+ 'ref_id_name',
+ 'stratum',
+ 'ref_time',
+ 'system_time',
+ 'last_offset',
+ 'rms_offset',
+ 'frequency',
+ 'residual_freq',
+ 'skew',
+ 'root_delay',
+ 'root_dispersion',
+ 'update_interval',
+ 'leap_status'
+ ]
+
+ case _:
+ raise ValueError(f"Raw mode: of {command} is not implemented")
+
+ # Get -c option command line output, splitlines,
+ # and save comma-separated values as a flat list
+ output = cmd(command).splitlines()
+ values = csv.reader(output)
+ values = list(chain.from_iterable(values))
+
+ # Divide values into chunks of size keys and transpose
+ if len(values) > len(keys):
+ values = _chunk_list(values,keys)
+ values = zip(*values)
+
+ return dict(zip(keys, values))
+
+def _chunk_list(in_list, n):
+ # Yields successive n-sized chunks from in_list
+ for i in range(0, len(in_list), len(n)):
+ yield in_list[i:i + len(n)]
+
+def _is_configured():
+ # Check if ntp is configured
+ config = ConfigTreeQuery()
+ if not config.exists("service ntp"):
+ raise vyos.opmode.UnconfiguredSubsystem("NTP service is not enabled.")
+
+def _extend_command_vrf():
+ config = ConfigTreeQuery()
+ if config.exists('service ntp vrf'):
+ vrf = config.value('service ntp vrf')
+ return f'ip vrf exec {vrf} '
+ return ''
+
+
+def show_activity(raw: bool):
+ _is_configured()
+ command = f'chronyc'
+
+ if raw:
+ command += f" -c activity"
+ return _get_raw_data(command)
+ else:
+ command = _extend_command_vrf() + command
+ command += f" activity"
+ return cmd(command)
+
+def show_sources(raw: bool):
+ _is_configured()
+ command = f'chronyc'
+
+ if raw:
+ command += f" -c sources"
+ return _get_raw_data(command)
+ else:
+ command = _extend_command_vrf() + command
+ command += f" sources -v"
+ return cmd(command)
+
+def show_tracking(raw: bool):
+ _is_configured()
+ command = f'chronyc'
+
+ if raw:
+ command += f" -c tracking"
+ return _get_raw_data(command)
+ else:
+ command = _extend_command_vrf() + command
+ command += f" tracking"
+ return cmd(command)
+
+def show_sourcestats(raw: bool):
+ _is_configured()
+ command = f'chronyc'
+
+ if raw:
+ command += f" -c sourcestats"
+ return _get_raw_data(command)
+ else:
+ command = _extend_command_vrf() + command
+ command += f" sourcestats -v"
+ return cmd(command)
+
+
+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/openconnect-control.py b/src/op_mode/openconnect-control.py
new file mode 100644
index 0000000..b70d4fa
--- /dev/null
+++ b/src/op_mode/openconnect-control.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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 argparse
+import json
+
+from tabulate import tabulate
+
+from vyos.config import Config
+from vyos.utils.process import popen
+from vyos.utils.process import run
+from vyos.utils.process import DEVNULL
+
+occtl = '/usr/bin/occtl'
+occtl_socket = '/run/ocserv/occtl.socket'
+
+def show_sessions():
+ out, code = popen("sudo {0} -j -s {1} show users".format(occtl, occtl_socket),stderr=DEVNULL)
+ if code:
+ sys.exit('Cannot get openconnect users information')
+ else:
+ headers = ["interface", "username", "ip", "remote IP", "RX", "TX", "state", "uptime"]
+ sessions = json.loads(out)
+ ses_list = []
+ for ses in sessions:
+ ses_list.append([ses["Device"], ses["Username"], ses["IPv4"], ses["Remote IP"], ses["_RX"], ses["_TX"], ses["State"], ses["_Connected at"]])
+ if len(ses_list) > 0:
+ print(tabulate(ses_list, headers))
+ else:
+ print("No active openconnect sessions")
+
+def is_ocserv_configured():
+ if not Config().exists_effective('vpn openconnect'):
+ print("vpn openconnect server is not configured")
+ sys.exit(1)
+
+def main():
+ #parese args
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='Control action', required=True)
+ parser.add_argument('--selector', help='Selector username|ifname|sid', required=False)
+ parser.add_argument('--target', help='Target must contain username|ifname|sid', required=False)
+ args = parser.parse_args()
+
+
+ # Check is Openconnect server configured
+ is_ocserv_configured()
+
+ if args.action == "restart":
+ run("sudo systemctl restart ocserv.service")
+ sys.exit(0)
+ elif args.action == "show_sessions":
+ show_sessions()
+
+if __name__ == '__main__':
+ main()
diff --git a/src/op_mode/openconnect.py b/src/op_mode/openconnect.py
new file mode 100644
index 0000000..62c683e
--- /dev/null
+++ b/src/op_mode/openconnect.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 json
+
+from tabulate import tabulate
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import rc_cmd
+
+import vyos.opmode
+
+
+occtl = '/usr/bin/occtl'
+occtl_socket = '/run/ocserv/occtl.socket'
+
+
+def _get_raw_data_sessions():
+ rc, out = rc_cmd(f'sudo {occtl} --json --socket-file {occtl_socket} show users')
+ if rc != 0:
+ raise vyos.opmode.DataUnavailable(out)
+
+ sessions = json.loads(out)
+ return sessions
+
+
+def _get_formatted_sessions(data):
+ headers = ["Interface", "Username", "IP", "Remote IP", "RX", "TX", "State", "Uptime"]
+ ses_list = []
+ for ses in data:
+ ses_list.append([
+ ses.get("Device", '(none)'), ses.get("Username", '(none)'),
+ ses.get("IPv4", '(none)'), ses.get("Remote IP", '(none)'),
+ ses.get("_RX", '(none)'), ses.get("_TX", '(none)'),
+ ses.get("State", '(none)'), ses.get("_Connected at", '(none)')
+ ])
+ if len(ses_list) > 0:
+ output = tabulate(ses_list, headers)
+ else:
+ output = 'No active openconnect sessions'
+ return output
+
+
+def show_sessions(raw: bool):
+ config = ConfigTreeQuery()
+ if not config.exists('vpn openconnect'):
+ raise vyos.opmode.UnconfiguredSubsystem('Openconnect is not configured')
+
+ openconnect_data = _get_raw_data_sessions()
+ if raw:
+ return openconnect_data
+ return _get_formatted_sessions(openconnect_data)
+
+
+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/openvpn.py b/src/op_mode/openvpn.py
new file mode 100644
index 0000000..0928739
--- /dev/null
+++ b/src/op_mode/openvpn.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 json
+import os
+import sys
+import typing
+from tabulate import tabulate
+
+import vyos.opmode
+from vyos.utils.convert import bytes_to_human
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.process import call
+from vyos.utils.process import rc_cmd
+from vyos.config import Config
+
+ArgMode = typing.Literal['client', 'server', 'site_to_site']
+
+def _get_tunnel_address(peer_host, peer_port, status_file):
+ peer = peer_host + ':' + peer_port
+ lst = []
+
+ with open(status_file, 'r') as f:
+ lines = f.readlines()
+ for line in lines:
+ if peer in line:
+ lst.append(line)
+
+ # filter out subnet entries if iroute:
+ # in the case that one sets, say:
+ # [ ..., 'vtun10', 'server', 'client', 'client1', 'subnet','10.10.2.0/25']
+ # the status file will have an entry:
+ # 10.10.2.0/25,client1,...
+ lst = [l for l in lst[1:] if '/' not in l.split(',')[0]]
+
+ if lst:
+ tunnel_ip = lst[0].split(',')[0]
+
+ return tunnel_ip
+
+ return 'n/a'
+
+def _get_interface_status(mode: str, interface: str) -> dict:
+ status_file = f'/run/openvpn/{interface}.status'
+
+ data: dict = {
+ 'mode': mode,
+ 'intf': interface,
+ 'local_host': '',
+ 'local_port': '',
+ 'date': '',
+ 'clients': [],
+ }
+
+ if not os.path.exists(status_file):
+ return data
+
+ with open(status_file, 'r') as f:
+ lines = f.readlines()
+ for line_no, line in enumerate(lines):
+ # remove trailing newline character first
+ line = line.rstrip('\n')
+
+ # check first line header
+ if line_no == 0:
+ if mode == 'server':
+ if not line == 'OpenVPN CLIENT LIST':
+ raise vyos.opmode.InternalError('Expected "OpenVPN CLIENT LIST"')
+ else:
+ if not line == 'OpenVPN STATISTICS':
+ raise vyos.opmode.InternalError('Expected "OpenVPN STATISTICS"')
+
+ continue
+
+ # second line informs us when the status file has been last updated
+ if line_no == 1:
+ data['date'] = line.lstrip('Updated,').rstrip('\n')
+ continue
+
+ if mode == 'server':
+ # for line_no > 1, lines appear as follows:
+ #
+ # Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
+ # client1,172.18.202.10:55904,2880587,2882653,Fri Aug 23 16:25:48 2019
+ # client3,172.18.204.10:41328,2850832,2869729,Fri Aug 23 16:25:43 2019
+ # client2,172.18.203.10:48987,2856153,2871022,Fri Aug 23 16:25:45 2019
+ # ...
+ # ROUTING TABLE
+ # ...
+ if line_no >= 3:
+ # indicator that there are no more clients
+ if line == 'ROUTING TABLE':
+ break
+ # otherwise, get client data
+ remote = (line.split(',')[1]).rsplit(':', maxsplit=1)
+
+ client = {
+ 'name': line.split(',')[0],
+ 'remote_host': remote[0],
+ 'remote_port': remote[1],
+ 'tunnel': 'N/A',
+ 'rx_bytes': bytes_to_human(int(line.split(',')[2]),
+ precision=1),
+ 'tx_bytes': bytes_to_human(int(line.split(',')[3]),
+ precision=1),
+ 'online_since': line.split(',')[4]
+ }
+ client['tunnel'] = _get_tunnel_address(client['remote_host'],
+ client['remote_port'],
+ status_file)
+ data['clients'].append(client)
+ continue
+ else: # mode == 'client' or mode == 'site-to-site'
+ if line_no == 2:
+ client = {
+ 'name': 'N/A',
+ 'remote_host': 'N/A',
+ 'remote_port': 'N/A',
+ 'tunnel': 'N/A',
+ 'rx_bytes': bytes_to_human(int(line.split(',')[1]),
+ precision=1),
+ 'tx_bytes': '',
+ 'online_since': 'N/A'
+ }
+ continue
+
+ if line_no == 3:
+ client['tx_bytes'] = bytes_to_human(int(line.split(',')[1]),
+ precision=1)
+ data['clients'].append(client)
+ break
+
+ return data
+
+
+def _get_interface_state(iface):
+ rc, out = rc_cmd(f'ip --json link show dev {iface}')
+ try:
+ data = json.loads(out)
+ except:
+ return 'DOWN'
+ return data[0].get('operstate', 'DOWN')
+
+
+def _get_interface_description(iface):
+ rc, out = rc_cmd(f'ip --json link show dev {iface}')
+ try:
+ data = json.loads(out)
+ except:
+ return ''
+ return data[0].get('ifalias', '')
+
+
+def _get_raw_data(mode: str) -> list:
+ data: list = []
+ conf = Config()
+ conf_dict = conf.get_config_dict(['interfaces', 'openvpn'],
+ get_first_key=True)
+ if not conf_dict:
+ return data
+
+ interfaces = [x for x in list(conf_dict) if
+ conf_dict[x]['mode'].replace('-', '_') == mode]
+ for intf in interfaces:
+ d = _get_interface_status(mode, intf)
+ d['state'] = _get_interface_state(intf)
+ d['description'] = _get_interface_description(intf)
+ d['local_host'] = conf_dict[intf].get('local-host', '')
+ d['local_port'] = conf_dict[intf].get('local-port', '')
+ if conf.exists(f'interfaces openvpn {intf} server client'):
+ d['configured_clients'] = conf.list_nodes(f'interfaces openvpn {intf} server client')
+ if mode in ['client', 'site_to_site']:
+ for client in d['clients']:
+ if 'shared-secret-key-file' in list(conf_dict[intf]):
+ client['name'] = 'None (PSK)'
+ client['remote_host'] = conf_dict[intf].get('remote-host', [''])[0]
+ client['remote_port'] = conf_dict[intf].get('remote-port', '1194')
+ data.append(d)
+
+ return data
+
+def _format_openvpn(data: list) -> str:
+ if not data:
+ out = 'No OpenVPN interfaces configured'
+ return out
+
+ headers = ['Client CN', 'Remote Host', 'Tunnel IP', 'Local Host',
+ 'TX bytes', 'RX bytes', 'Connected Since']
+
+ out = ''
+ for d in data:
+ data_out = []
+ intf = d['intf']
+ l_host = d['local_host']
+ l_port = d['local_port']
+ out += f'\nOpenVPN status on {intf}\n\n'
+ for client in d['clients']:
+ r_host = client['remote_host']
+ r_port = client['remote_port']
+
+ name = client['name']
+ remote = r_host + ':' + r_port if r_host and r_port else 'N/A'
+ tunnel = client['tunnel']
+ local = l_host + ':' + l_port if l_host and l_port else 'N/A'
+ tx_bytes = client['tx_bytes']
+ rx_bytes = client['rx_bytes']
+ online_since = client['online_since']
+ data_out.append([name, remote, tunnel, local, tx_bytes,
+ rx_bytes, online_since])
+
+ out += tabulate(data_out, headers)
+ out += "\n"
+
+ return out
+
+def show(raw: bool, mode: ArgMode) -> typing.Union[list,str]:
+ openvpn_data = _get_raw_data(mode)
+
+ if raw:
+ return openvpn_data
+
+ return _format_openvpn(openvpn_data)
+
+def reset(interface: str):
+ if os.path.isfile(f'/run/openvpn/{interface}.conf'):
+ if commit_in_progress():
+ raise vyos.opmode.CommitInProgress('Retry OpenVPN reset: commit in progress.')
+ call(f'systemctl restart openvpn@{interface}.service')
+ else:
+ raise vyos.opmode.IncorrectValue(f'OpenVPN interface "{interface}" does not exist!')
+
+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/otp.py b/src/op_mode/otp.py
new file mode 100644
index 0000000..a4ab9b2
--- /dev/null
+++ b/src/op_mode/otp.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+
+# Copyright 2017, 2022 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/>.
+
+
+import sys
+import os
+import vyos.opmode
+from jinja2 import Template
+from vyos.config import Config
+from vyos.utils.process import popen
+
+
+users_otp_template = Template("""
+{% if info == "full" %}
+# You can share it with the user, he just needs to scan the QR in his OTP app
+# username: {{username}}
+# OTP KEY: {{key_base32}}
+# OTP URL: {{otp_url}}
+{{qrcode}}
+# To add this OTP key to configuration, run the following commands:
+set system login user {{username}} authentication otp key '{{key_base32}}'
+{% if rate_limit != "3" %}
+set system login user {{username}} authentication otp rate-limit '{{rate_limit}}'
+{% endif %}
+{% if rate_time != "30" %}
+set system login user {{username}} authentication otp rate-time '{{rate_time}}'
+{% endif %}
+{% if window_size != "3" %}
+set system login user {{username}} authentication otp window-size '{{window_size}}'
+{% endif %}
+{% elif info == "key-b32" %}
+# OTP key in Base32 for system user {{username}}:
+{{key_base32}}
+{% elif info == "qrcode" %}
+# QR code for system user '{{username}}'
+{{qrcode}}
+{% elif info == "uri" %}
+# URI for system user '{{username}}'
+{{otp_url}}
+{% endif %}
+""", trim_blocks=True, lstrip_blocks=True)
+
+
+def _check_uname_otp(username:str):
+ """
+ Check if "username" exists and have an OTP key
+ """
+ config = Config()
+ base_key = ['system', 'login', 'user', username, 'authentication', 'otp', 'key']
+ if not config.exists(base_key):
+ return None
+ return True
+
+def _get_login_otp(username: str, info:str):
+ """
+ Retrieve user settings from configuration and set some defaults
+ """
+ config = Config()
+ base = ['system', 'login', 'user', username]
+ if not config.exists(base):
+ return None
+ user_otp = config.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ result = user_otp['authentication']['otp']
+ # Filling in the system and default options
+ result['info'] = info
+ result['hostname'] = os.uname()[1]
+ result['username'] = username
+ result['key_base32'] = result['key']
+ result['otp_length'] = '6'
+ result['interval'] = '30'
+ result['token_type'] = 'hotp-time'
+ if result['token_type'] == 'hotp-time':
+ token_type_acrn = 'totp'
+ result['otp_url'] = ''.join(["otpauth://",token_type_acrn,"/",username,"@",\
+ result['hostname'],"?secret=",result['key_base32'],"&digits=",\
+ result['otp_length'],"&period=",result['interval']])
+ result['qrcode'],_ = popen('qrencode -t ansiutf8', input=result['otp_url'])
+ return result
+
+def show_login(raw: bool, username: str, info:str):
+ '''
+ Display OTP parameters for <username>
+ '''
+ check_otp = _check_uname_otp(username)
+ if check_otp:
+ user_otp_params = _get_login_otp(username, info)
+ else:
+ print(f'There is no such user ("{username}") with an OTP key configured')
+ print('You can use the following command to generate a key for a user:\n')
+ print(f'generate system login username {username} otp-key hotp-time')
+ sys.exit(0)
+ if raw:
+ return user_otp_params
+ return users_otp_template.render(user_otp_params)
+
+
+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/ping.py b/src/op_mode/ping.py
new file mode 100644
index 0000000..583d879
--- /dev/null
+++ b/src/op_mode/ping.py
@@ -0,0 +1,282 @@
+#! /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 socket
+import ipaddress
+
+from vyos.utils.network import interface_list
+from vyos.utils.network import vrf_list
+from vyos.utils.process import call
+
+options = {
+ 'audible': {
+ 'ping': '{command} -a',
+ 'type': 'noarg',
+ 'help': 'Make a noise on ping'
+ },
+ 'adaptive': {
+ 'ping': '{command} -A',
+ 'type': 'noarg',
+ 'help': 'Adativly set interpacket interval'
+ },
+ 'allow-broadcast': {
+ 'ping': '{command} -b',
+ 'type': 'noarg',
+ 'help': 'Ping broadcast address'
+ },
+ 'bypass-route': {
+ 'ping': '{command} -r',
+ 'type': 'noarg',
+ 'help': 'Bypass normal routing tables'
+ },
+ 'count': {
+ 'ping': '{command} -c {value}',
+ 'type': '<requests>',
+ 'help': 'Number of requests to send'
+ },
+ 'deadline': {
+ 'ping': '{command} -w {value}',
+ 'type': '<seconds>',
+ 'help': 'Number of seconds before ping exits'
+ },
+ 'do-not-fragment': {
+ 'ping': '{command} -M do',
+ 'type': 'noarg',
+ 'help': 'Set DF-bit flag to 1 for no fragmentation'
+ },
+ 'flood': {
+ 'ping': 'sudo {command} -f',
+ 'type': 'noarg',
+ 'help': 'Send 100 requests per second'
+ },
+ 'interface': {
+ 'ping': '{command} -I {value}',
+ 'type': '<interface>',
+ 'helpfunction': interface_list,
+ 'help': 'Source interface'
+ },
+ 'interval': {
+ 'ping': '{command} -i {value}',
+ 'type': '<seconds>',
+ 'help': 'Number of seconds to wait between requests'
+ },
+ 'ipv4': {
+ 'ping': '{command} -4',
+ 'type': 'noarg',
+ 'help': 'Use IPv4 only'
+ },
+ 'ipv6': {
+ 'ping': '{command} -6',
+ 'type': 'noarg',
+ 'help': 'Use IPv6 only'
+ },
+ 'mark': {
+ 'ping': '{command} -m {value}',
+ 'type': '<fwmark>',
+ 'help': 'Mark request for special processing'
+ },
+ 'numeric': {
+ 'ping': '{command} -n',
+ 'type': 'noarg',
+ 'help': 'Do not resolve DNS names'
+ },
+ 'no-loopback': {
+ 'ping': '{command} -L',
+ 'type': 'noarg',
+ 'help': 'Supress loopback of multicast pings'
+ },
+ 'pattern': {
+ 'ping': '{command} -p {value}',
+ 'type': '<pattern>',
+ 'help': 'Pattern to fill out the packet'
+ },
+ 'timestamp': {
+ 'ping': '{command} -D',
+ 'type': 'noarg',
+ 'help': 'Print timestamp of output'
+ },
+ 'tos': {
+ 'ping': '{command} -Q {value}',
+ 'type': '<tos>',
+ 'help': 'Mark packets with specified TOS'
+ },
+ 'quiet': {
+ 'ping': '{command} -q',
+ 'type': 'noarg',
+ 'help': 'Only print summary lines'
+ },
+ 'record-route': {
+ 'ping': '{command} -R',
+ 'type': 'noarg',
+ 'help': 'Record route the packet takes'
+ },
+ 'size': {
+ 'ping': '{command} -s {value}',
+ 'type': '<bytes>',
+ 'help': 'Number of bytes to send'
+ },
+ 'source-address': {
+ 'ping': '{command} -I {value}',
+ 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>',
+ },
+ 'ttl': {
+ 'ping': '{command} -t {value}',
+ 'type': '<ttl>',
+ 'help': 'Maximum packet lifetime'
+ },
+ 'vrf': {
+ 'ping': 'sudo ip vrf exec {value} {command}',
+ 'type': '<vrf>',
+ 'help': 'Use specified VRF table',
+ 'helpfunction': vrf_list,
+ 'dflt': 'default',
+ },
+ 'verbose': {
+ 'ping': '{command} -v',
+ 'type': 'noarg',
+ 'help': 'Verbose output'}
+}
+
+ping = {
+ 4: '/bin/ping',
+ 6: '/bin/ping6',
+}
+
+
+class List(list):
+ def first(self):
+ return self.pop(0) if self else ''
+
+ def last(self):
+ return self.pop() if self else ''
+
+ def prepend(self, value):
+ self.insert(0, value)
+
+
+def completion_failure(option: str) -> None:
+ """
+ Shows failure message after TAB when option is wrong
+ :param option: failure option
+ :type str:
+ """
+ sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option))
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def expension_failure(option, completions):
+ reason = 'Ambiguous' if completions else 'Invalid'
+ sys.stderr.write(
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
+ option))
+ if completions:
+ sys.stderr.write(' Possible completions:\n ')
+ sys.stderr.write('\n '.join(completions))
+ sys.stderr.write('\n')
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def complete(prefix):
+ return [o for o in options if o.startswith(prefix)]
+
+
+def convert(command, args):
+ while args:
+ shortname = args.first()
+ longnames = complete(shortname)
+ if len(longnames) != 1:
+ expension_failure(shortname, longnames)
+ longname = longnames[0]
+ if options[longname]['type'] == 'noarg':
+ command = options[longname]['ping'].format(
+ command=command, value='')
+ elif not args:
+ sys.exit(f'ping: missing argument for {longname} option')
+ else:
+ command = options[longname]['ping'].format(
+ command=command, value=args.first())
+ return command
+
+
+if __name__ == '__main__':
+ args = List(sys.argv[1:])
+ host = args.first()
+
+ if not host:
+ sys.exit("ping: Missing host")
+
+ if host == '--get-options':
+ args.first() # pop ping
+ args.first() # pop IP
+ usedoptionslist = []
+ while args:
+ option = args.first() # pop option
+ matched = complete(option) # get option parameters
+ usedoptionslist.append(option) # list of used options
+ # Select options
+ if not args:
+ # remove from Possible completions used options
+ for o in usedoptionslist:
+ if o in matched:
+ matched.remove(o)
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+
+ if len(matched) > 1:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+ # If option doesn't have value
+ if matched:
+ if options[matched[0]]['type'] == 'noarg':
+ continue
+ else:
+ # Unexpected option
+ completion_failure(option)
+
+ value = args.first() # pop option's value
+ if not args:
+ matched = complete(option)
+ helplines = options[matched[0]]['type']
+ # Run helpfunction to get list of possible values
+ if 'helpfunction' in options[matched[0]]:
+ result = options[matched[0]]['helpfunction']()
+ if result:
+ helplines = '\n' + ' '.join(result)
+ sys.stdout.write(helplines)
+ sys.exit(0)
+
+ for name, option in options.items():
+ if 'dflt' in option and name not in args:
+ args.append(name)
+ args.append(option['dflt'])
+
+ try:
+ ip = socket.gethostbyname(host)
+ except UnicodeError:
+ sys.exit(f'ping: Unknown host: {host}')
+ except socket.gaierror:
+ ip = host
+
+ try:
+ version = ipaddress.ip_address(ip).version
+ except ValueError:
+ sys.exit(f'ping: Unknown host: {host}')
+
+ command = convert(ping[version], args)
+ call(f'{command} {host}')
diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py
new file mode 100644
index 0000000..ab613e5
--- /dev/null
+++ b/src/op_mode/pki.py
@@ -0,0 +1,1111 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+import ipaddress
+import os
+import re
+import sys
+import tabulate
+
+from cryptography import x509
+from cryptography.x509.oid import ExtendedKeyUsageOID
+
+from vyos.config import Config
+from vyos.config import config_dict_mangle_acme
+from vyos.pki import encode_certificate, encode_public_key, encode_private_key, encode_dh_parameters
+from vyos.pki import get_certificate_fingerprint
+from vyos.pki import create_certificate, create_certificate_request, create_certificate_revocation_list
+from vyos.pki import create_private_key
+from vyos.pki import create_dh_parameters
+from vyos.pki import load_certificate, load_certificate_request, load_private_key
+from vyos.pki import load_crl, load_dh_parameters, load_public_key
+from vyos.pki import verify_certificate
+from vyos.utils.io import ask_input
+from vyos.utils.io import ask_yes_no
+from vyos.utils.misc import install_into_config
+from vyos.utils.process import cmd
+
+CERT_REQ_END = '-----END CERTIFICATE REQUEST-----'
+auth_dir = '/config/auth'
+
+# Helper Functions
+conf = Config()
+def get_default_values():
+ # Fetch default x509 values
+ base = ['pki', 'x509', 'default']
+ x509_defaults = conf.get_config_dict(base, key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ return x509_defaults
+
+def get_config_ca_certificate(name=None):
+ # Fetch ca certificates from config
+ base = ['pki', 'ca']
+ if not conf.exists(base):
+ return False
+
+ if name:
+ base = base + [name]
+ if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']):
+ return False
+
+ return conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+def get_config_certificate(name=None):
+ # Get certificates from config
+ base = ['pki', 'certificate']
+ if not conf.exists(base):
+ return False
+
+ if name:
+ base = base + [name]
+ if not conf.exists(base + ['private', 'key']) or not conf.exists(base + ['certificate']):
+ return False
+
+ pki = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ if pki:
+ for certificate in pki:
+ pki[certificate] = config_dict_mangle_acme(certificate, pki[certificate])
+
+ return pki
+
+def get_certificate_ca(cert, ca_certs):
+ # Find CA certificate for given certificate
+ if not ca_certs:
+ return None
+
+ for ca_name, ca_dict in ca_certs.items():
+ if 'certificate' not in ca_dict:
+ continue
+
+ ca_cert = load_certificate(ca_dict['certificate'])
+
+ if not ca_cert:
+ continue
+
+ if verify_certificate(cert, ca_cert):
+ return ca_name
+ return None
+
+def get_config_revoked_certificates():
+ # Fetch revoked certificates from config
+ ca_base = ['pki', 'ca']
+ cert_base = ['pki', 'certificate']
+
+ certs = []
+
+ if conf.exists(ca_base):
+ ca_certificates = conf.get_config_dict(ca_base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ certs.extend(ca_certificates.values())
+
+ if conf.exists(cert_base):
+ certificates = conf.get_config_dict(cert_base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ certs.extend(certificates.values())
+
+ return [cert_dict for cert_dict in certs if 'revoke' in cert_dict]
+
+def get_revoked_by_serial_numbers(serial_numbers=[]):
+ # Return serial numbers of revoked certificates
+ certs_out = []
+ certs = get_config_certificate()
+ ca_certs = get_config_ca_certificate()
+ if certs:
+ for cert_name, cert_dict in certs.items():
+ if 'certificate' not in cert_dict:
+ continue
+
+ cert = load_certificate(cert_dict['certificate'])
+ if cert.serial_number in serial_numbers:
+ certs_out.append(cert_name)
+ if ca_certs:
+ for cert_name, cert_dict in ca_certs.items():
+ if 'certificate' not in cert_dict:
+ continue
+
+ cert = load_certificate(cert_dict['certificate'])
+ if cert.serial_number in serial_numbers:
+ certs_out.append(cert_name)
+ return certs_out
+
+def install_certificate(name, cert='', private_key=None, key_type=None, key_passphrase=None, is_ca=False):
+ # Show/install conf commands for certificate
+ prefix = 'ca' if is_ca else 'certificate'
+
+ base = f"pki {prefix} {name}"
+ config_paths = []
+ if cert:
+ cert_pem = "".join(encode_certificate(cert).strip().split("\n")[1:-1])
+ config_paths.append(f"{base} certificate '{cert_pem}'")
+
+ if private_key:
+ key_pem = "".join(encode_private_key(private_key, passphrase=key_passphrase).strip().split("\n")[1:-1])
+ config_paths.append(f"{base} private key '{key_pem}'")
+ if key_passphrase:
+ config_paths.append(f"{base} private password-protected")
+
+ install_into_config(conf, config_paths)
+
+def install_crl(ca_name, crl):
+ # Show/install conf commands for crl
+ crl_pem = "".join(encode_certificate(crl).strip().split("\n")[1:-1])
+ install_into_config(conf, [f"pki ca {ca_name} crl '{crl_pem}'"])
+
+def install_dh_parameters(name, params):
+ # Show/install conf commands for dh params
+ dh_pem = "".join(encode_dh_parameters(params).strip().split("\n")[1:-1])
+ install_into_config(conf, [f"pki dh {name} parameters '{dh_pem}'"])
+
+def install_ssh_key(name, public_key, private_key, passphrase=None):
+ # Show/install conf commands for ssh key
+ key_openssh = encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH')
+ username = os.getlogin()
+ type_key_split = key_openssh.split(" ")
+
+ base = f"system login user {username} authentication public-keys {name}"
+ install_into_config(conf, [
+ f"{base} key '{type_key_split[1]}'",
+ f"{base} type '{type_key_split[0]}'"
+ ])
+ print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase))
+
+def install_keypair(name, key_type, private_key=None, public_key=None, passphrase=None, prompt=True):
+ # Show/install conf commands for key-pair
+
+ config_paths = []
+
+ if public_key:
+ install_public_key = not prompt or ask_yes_no('Do you want to install the public key?', default=True)
+ public_key_pem = encode_public_key(public_key)
+
+ if install_public_key:
+ install_public_pem = "".join(public_key_pem.strip().split("\n")[1:-1])
+ config_paths.append(f"pki key-pair {name} public key '{install_public_pem}'")
+ else:
+ print("Public key:")
+ print(public_key_pem)
+
+ if private_key:
+ install_private_key = not prompt or ask_yes_no('Do you want to install the private key?', default=True)
+ private_key_pem = encode_private_key(private_key, passphrase=passphrase)
+
+ if install_private_key:
+ install_private_pem = "".join(private_key_pem.strip().split("\n")[1:-1])
+ config_paths.append(f"pki key-pair {name} private key '{install_private_pem}'")
+ if passphrase:
+ config_paths.append(f"pki key-pair {name} private password-protected")
+ else:
+ print("Private key:")
+ print(private_key_pem)
+
+ install_into_config(conf, config_paths)
+
+def install_openvpn_key(name, key_data, key_version='1'):
+ config_paths = [
+ f"pki openvpn shared-secret {name} key '{key_data}'",
+ f"pki openvpn shared-secret {name} version '{key_version}'"
+ ]
+ install_into_config(conf, config_paths)
+
+def install_wireguard_key(interface, private_key, public_key):
+ # Show conf commands for installing wireguard key pairs
+ from vyos.ifconfig import Section
+ if Section.section(interface) != 'wireguard':
+ print(f'"{interface}" is not a WireGuard interface name!')
+ exit(1)
+
+ # Check if we are running in a config session - if yes, we can directly write to the CLI
+ install_into_config(conf, [f"interfaces wireguard {interface} private-key '{private_key}'"])
+
+ print(f"Corresponding public-key to use on peer system is: '{public_key}'")
+
+def install_wireguard_psk(interface, peer, psk):
+ from vyos.ifconfig import Section
+ if Section.section(interface) != 'wireguard':
+ print(f'"{interface}" is not a WireGuard interface name!')
+ exit(1)
+
+ # Check if we are running in a config session - if yes, we can directly write to the CLI
+ install_into_config(conf, [f"interfaces wireguard {interface} peer {peer} preshared-key '{psk}'"])
+
+def ask_passphrase():
+ passphrase = None
+ print("Note: If you plan to use the generated key on this router, do not encrypt the private key.")
+ if ask_yes_no('Do you want to encrypt the private key with a passphrase?'):
+ passphrase = ask_input('Enter passphrase:')
+ return passphrase
+
+def write_file(filename, contents):
+ full_path = os.path.join(auth_dir, filename)
+ directory = os.path.dirname(full_path)
+
+ if not os.path.exists(directory):
+ print('Failed to write file: directory does not exist')
+ return False
+
+ if os.path.exists(full_path) and not ask_yes_no('Do you want to overwrite the existing file?'):
+ return False
+
+ with open(full_path, 'w') as f:
+ f.write(contents)
+
+ print(f'File written to {full_path}')
+
+# Generation functions
+
+def generate_private_key():
+ key_type = ask_input('Enter private key type: [rsa, dsa, ec]', default='rsa', valid_responses=['rsa', 'dsa', 'ec'])
+
+ size_valid = []
+ size_default = 0
+
+ if key_type in ['rsa', 'dsa']:
+ size_default = 2048
+ size_valid = [512, 1024, 2048, 4096]
+ elif key_type == 'ec':
+ size_default = 256
+ size_valid = [224, 256, 384, 521]
+
+ size = ask_input('Enter private key bits:', default=size_default, numeric_only=True, valid_responses=size_valid)
+
+ return create_private_key(key_type, size), key_type
+
+def parse_san_string(san_string):
+ if not san_string:
+ return None
+
+ output = []
+ san_split = san_string.strip().split(",")
+
+ for pair_str in san_split:
+ tag, value = pair_str.strip().split(":", 1)
+ if tag == 'ipv4':
+ output.append(ipaddress.IPv4Address(value))
+ elif tag == 'ipv6':
+ output.append(ipaddress.IPv6Address(value))
+ elif tag == 'dns' or tag == 'rfc822':
+ output.append(value)
+ return output
+
+def generate_certificate_request(private_key=None, key_type=None, return_request=False, name=None, install=False, file=False, ask_san=True):
+ if not private_key:
+ private_key, key_type = generate_private_key()
+
+ default_values = get_default_values()
+ subject = {}
+ while True:
+ country = ask_input('Enter country code:', default=default_values['country'])
+ if len(country) != 2:
+ print("Country name must be a 2 character country code")
+ continue
+ subject['country'] = country
+ break
+ subject['state'] = ask_input('Enter state:', default=default_values['state'])
+ subject['locality'] = ask_input('Enter locality:', default=default_values['locality'])
+ subject['organization'] = ask_input('Enter organization name:', default=default_values['organization'])
+ subject['common_name'] = ask_input('Enter common name:', default='vyos.io')
+ subject_alt_names = None
+
+ if ask_san and ask_yes_no('Do you want to configure Subject Alternative Names?'):
+ print("Enter alternative names in a comma separate list, example: ipv4:1.1.1.1,ipv6:fe80::1,dns:vyos.net,rfc822:user@vyos.net")
+ san_string = ask_input('Enter Subject Alternative Names:')
+ subject_alt_names = parse_san_string(san_string)
+
+ cert_req = create_certificate_request(subject, private_key, subject_alt_names)
+
+ if return_request:
+ return cert_req
+
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_certificate(cert_req))
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ print("Certificate request:")
+ print(encode_certificate(cert_req) + "\n")
+ install_certificate(name, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False)
+
+ if file:
+ write_file(f'{name}.csr', encode_certificate(cert_req))
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False, is_sub_ca=False):
+ valid_days = ask_input('Enter how many days certificate will be valid:', default='365' if not is_ca else '1825', numeric_only=True)
+ cert_type = None
+ if not is_ca:
+ cert_type = ask_input('Enter certificate type: (client, server)', default='server', valid_responses=['client', 'server'])
+ return create_certificate(cert_req, ca_cert, ca_private_key, valid_days, cert_type, is_ca, is_sub_ca)
+
+def generate_ca_certificate(name, install=False, file=False):
+ private_key, key_type = generate_private_key()
+ cert_req = generate_certificate_request(private_key, key_type, return_request=True, ask_san=False)
+ cert = generate_certificate(cert_req, cert_req, private_key, is_ca=True)
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_certificate(cert))
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=True)
+
+ if file:
+ write_file(f'{name}.pem', encode_certificate(cert))
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_ca_certificate_sign(name, ca_name, install=False, file=False):
+ ca_dict = get_config_ca_certificate(ca_name)
+
+ if not ca_dict:
+ print(f"CA certificate or private key for '{ca_name}' not found")
+ return None
+
+ ca_cert = load_certificate(ca_dict['certificate'])
+
+ if not ca_cert:
+ print("Failed to load signing CA certificate, aborting")
+ return None
+
+ ca_private = ca_dict['private']
+ ca_private_passphrase = None
+ if 'password_protected' in ca_private:
+ ca_private_passphrase = ask_input('Enter signing CA private key passphrase:')
+ ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase)
+
+ if not ca_private_key:
+ print("Failed to load signing CA private key, aborting")
+ return None
+
+ private_key = None
+ key_type = None
+
+ cert_req = None
+ if not ask_yes_no('Do you already have a certificate request?'):
+ private_key, key_type = generate_private_key()
+ cert_req = generate_certificate_request(private_key, key_type, return_request=True, ask_san=False)
+ else:
+ print("Paste certificate request and press enter:")
+ lines = []
+ curr_line = ''
+ while True:
+ curr_line = input().strip()
+ if not curr_line or curr_line == CERT_REQ_END:
+ break
+ lines.append(curr_line)
+
+ if not lines:
+ print("Aborted")
+ return None
+
+ wrap = lines[0].find('-----') < 0 # Only base64 pasted, add the CSR tags for parsing
+ cert_req = load_certificate_request("\n".join(lines), wrap)
+
+ if not cert_req:
+ print("Invalid certificate request")
+ return None
+
+ cert = generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=True, is_sub_ca=True)
+
+ passphrase = None
+ if private_key is not None:
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_certificate(cert))
+ if private_key is not None:
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=True)
+
+ if file:
+ write_file(f'{name}.pem', encode_certificate(cert))
+ if private_key is not None:
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_certificate_sign(name, ca_name, install=False, file=False):
+ ca_dict = get_config_ca_certificate(ca_name)
+
+ if not ca_dict:
+ print(f"CA certificate or private key for '{ca_name}' not found")
+ return None
+
+ ca_cert = load_certificate(ca_dict['certificate'])
+
+ if not ca_cert:
+ print("Failed to load CA certificate, aborting")
+ return None
+
+ ca_private = ca_dict['private']
+ ca_private_passphrase = None
+ if 'password_protected' in ca_private:
+ ca_private_passphrase = ask_input('Enter CA private key passphrase:')
+ ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase)
+
+ if not ca_private_key:
+ print("Failed to load CA private key, aborting")
+ return None
+
+ private_key = None
+ key_type = None
+
+ cert_req = None
+ if not ask_yes_no('Do you already have a certificate request?'):
+ private_key, key_type = generate_private_key()
+ cert_req = generate_certificate_request(private_key, key_type, return_request=True)
+ else:
+ print("Paste certificate request and press enter:")
+ lines = []
+ curr_line = ''
+ while True:
+ curr_line = input().strip()
+ if not curr_line or curr_line == CERT_REQ_END:
+ break
+ lines.append(curr_line)
+
+ if not lines:
+ print("Aborted")
+ return None
+
+ wrap = lines[0].find('-----') < 0 # Only base64 pasted, add the CSR tags for parsing
+ cert_req = load_certificate_request("\n".join(lines), wrap)
+
+ if not cert_req:
+ print("Invalid certificate request")
+ return None
+
+ cert = generate_certificate(cert_req, ca_cert, ca_private_key, is_ca=False)
+
+ passphrase = None
+ if private_key is not None:
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_certificate(cert))
+ if private_key is not None:
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ install_certificate(name, cert, private_key, key_type, key_passphrase=passphrase, is_ca=False)
+
+ if file:
+ write_file(f'{name}.pem', encode_certificate(cert))
+ if private_key is not None:
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_certificate_selfsign(name, install=False, file=False):
+ private_key, key_type = generate_private_key()
+ cert_req = generate_certificate_request(private_key, key_type, return_request=True)
+ cert = generate_certificate(cert_req, cert_req, private_key, is_ca=False)
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_certificate(cert))
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ install_certificate(name, cert, private_key=private_key, key_type=key_type, key_passphrase=passphrase, is_ca=False)
+
+ if file:
+ write_file(f'{name}.pem', encode_certificate(cert))
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_certificate_revocation_list(ca_name, install=False, file=False):
+ ca_dict = get_config_ca_certificate(ca_name)
+
+ if not ca_dict:
+ print(f"CA certificate or private key for '{ca_name}' not found")
+ return None
+
+ ca_cert = load_certificate(ca_dict['certificate'])
+
+ if not ca_cert:
+ print("Failed to load CA certificate, aborting")
+ return None
+
+ ca_private = ca_dict['private']
+ ca_private_passphrase = None
+ if 'password_protected' in ca_private:
+ ca_private_passphrase = ask_input('Enter CA private key passphrase:')
+ ca_private_key = load_private_key(ca_private['key'], passphrase=ca_private_passphrase)
+
+ if not ca_private_key:
+ print("Failed to load CA private key, aborting")
+ return None
+
+ revoked_certs = get_config_revoked_certificates()
+ to_revoke = []
+
+ for cert_dict in revoked_certs:
+ if 'certificate' not in cert_dict:
+ continue
+
+ cert_data = cert_dict['certificate']
+
+ try:
+ cert = load_certificate(cert_data)
+
+ if cert.issuer == ca_cert.subject:
+ to_revoke.append(cert.serial_number)
+ except ValueError:
+ continue
+
+ if not to_revoke:
+ print("No revoked certificates to add to the CRL")
+ return None
+
+ crl = create_certificate_revocation_list(ca_cert, ca_private_key, to_revoke)
+
+ if not crl:
+ print("Failed to create CRL")
+ return None
+
+ if not install and not file:
+ print(encode_certificate(crl))
+ return None
+
+ if install:
+ install_crl(ca_name, crl)
+
+ if file:
+ write_file(f'{name}.crl', encode_certificate(crl))
+
+def generate_ssh_keypair(name, install=False, file=False):
+ private_key, key_type = generate_private_key()
+ public_key = private_key.public_key()
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH'))
+ print("")
+ print(encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase))
+ return None
+
+ if install:
+ install_ssh_key(name, public_key, private_key, passphrase)
+
+ if file:
+ write_file(f'{name}.pem', encode_public_key(public_key, encoding='OpenSSH', key_format='OpenSSH'))
+ write_file(f'{name}.key', encode_private_key(private_key, encoding='PEM', key_format='OpenSSH', passphrase=passphrase))
+
+def generate_dh_parameters(name, install=False, file=False):
+ bits = ask_input('Enter DH parameters key size:', default=2048, numeric_only=True)
+
+ print("Generating parameters...")
+
+ dh_params = create_dh_parameters(bits)
+ if not dh_params:
+ print("Failed to create DH parameters")
+ return None
+
+ if not install and not file:
+ print("DH Parameters:")
+ print(encode_dh_parameters(dh_params))
+
+ if install:
+ install_dh_parameters(name, dh_params)
+
+ if file:
+ write_file(f'{name}.pem', encode_dh_parameters(dh_params))
+
+def generate_keypair(name, install=False, file=False):
+ private_key, key_type = generate_private_key()
+ public_key = private_key.public_key()
+ passphrase = ask_passphrase()
+
+ if not install and not file:
+ print(encode_public_key(public_key))
+ print("")
+ print(encode_private_key(private_key, passphrase=passphrase))
+ return None
+
+ if install:
+ install_keypair(name, key_type, private_key, public_key, passphrase)
+
+ if file:
+ write_file(f'{name}.pem', encode_public_key(public_key))
+ write_file(f'{name}.key', encode_private_key(private_key, passphrase=passphrase))
+
+def generate_openvpn_key(name, install=False, file=False):
+ result = cmd('openvpn --genkey secret /dev/stdout | grep -o "^[^#]*"')
+
+ if not result:
+ print("Failed to generate OpenVPN key")
+ return None
+
+ if not install and not file:
+ print(result)
+ return None
+
+ if install:
+ key_lines = result.split("\n")
+ key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings
+ key_version = '1'
+
+ version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', result) # Future-proofing (hopefully)
+ if version_search:
+ key_version = version_search[1]
+
+ install_openvpn_key(name, key_data, key_version)
+
+ if file:
+ write_file(f'{name}.key', result)
+
+def generate_wireguard_key(interface=None, install=False):
+ private_key = cmd('wg genkey')
+ public_key = cmd('wg pubkey', input=private_key)
+
+ if interface and install:
+ install_wireguard_key(interface, private_key, public_key)
+ else:
+ print(f'Private key: {private_key}')
+ print(f'Public key: {public_key}', end='\n\n')
+
+def generate_wireguard_psk(interface=None, peer=None, install=False):
+ psk = cmd('wg genpsk')
+ if interface and peer and install:
+ install_wireguard_psk(interface, peer, psk)
+ else:
+ print(f'Pre-shared key: {psk}')
+
+# Import functions
+def import_ca_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None):
+ if path:
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ cert = None
+
+ with open(path) as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if not cert:
+ print(f'Invalid certificate: {path}')
+ return
+
+ install_certificate(name, cert, is_ca=True)
+
+ if key_path:
+ if not os.path.exists(key_path):
+ print(f'File not found: {key_path}')
+ return
+
+ key = None
+ if not no_prompt:
+ passphrase = ask_input('Enter private key passphrase: ') or None
+
+ with open(key_path) as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)
+
+ if not key:
+ print(f'Invalid private key or passphrase: {key_path}')
+ return
+
+ install_certificate(name, private_key=key, is_ca=True)
+
+def import_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None):
+ if path:
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ cert = None
+
+ with open(path) as f:
+ cert_data = f.read()
+ cert = load_certificate(cert_data, wrap_tags=False)
+
+ if not cert:
+ print(f'Invalid certificate: {path}')
+ return
+
+ install_certificate(name, cert, is_ca=False)
+
+ if key_path:
+ if not os.path.exists(key_path):
+ print(f'File not found: {key_path}')
+ return
+
+ key = None
+ if not no_prompt:
+ passphrase = ask_input('Enter private key passphrase: ') or None
+
+ with open(key_path) as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)
+
+ if not key:
+ print(f'Invalid private key or passphrase: {key_path}')
+ return
+
+ install_certificate(name, private_key=key, is_ca=False)
+
+def import_crl(name, path):
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ crl = None
+
+ with open(path) as f:
+ crl_data = f.read()
+ crl = load_crl(crl_data, wrap_tags=False)
+
+ if not crl:
+ print(f'Invalid certificate: {path}')
+ return
+
+ install_crl(name, crl)
+
+def import_dh_parameters(name, path):
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ dh = None
+
+ with open(path) as f:
+ dh_data = f.read()
+ dh = load_dh_parameters(dh_data, wrap_tags=False)
+
+ if not dh:
+ print(f'Invalid DH parameters: {path}')
+ return
+
+ install_dh_parameters(name, dh)
+
+def import_keypair(name, path=None, key_path=None, no_prompt=False, passphrase=None):
+ if path:
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ key = None
+
+ with open(path) as f:
+ key_data = f.read()
+ key = load_public_key(key_data, wrap_tags=False)
+
+ if not key:
+ print(f'Invalid public key: {path}')
+ return
+
+ install_keypair(name, None, public_key=key, prompt=False)
+
+ if key_path:
+ if not os.path.exists(key_path):
+ print(f'File not found: {key_path}')
+ return
+
+ key = None
+ if not no_prompt:
+ passphrase = ask_input('Enter private key passphrase: ') or None
+
+ with open(key_path) as f:
+ key_data = f.read()
+ key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)
+
+ if not key:
+ print(f'Invalid private key or passphrase: {key_path}')
+ return
+
+ install_keypair(name, None, private_key=key, prompt=False)
+
+def import_openvpn_secret(name, path):
+ if not os.path.exists(path):
+ print(f'File not found: {path}')
+ return
+
+ key_data = None
+ key_version = '1'
+
+ with open(path) as f:
+ key_lines = f.read().strip().split("\n")
+ key_lines = list(filter(lambda line: not line.strip().startswith('#'), key_lines)) # Remove commented lines
+ key_data = "".join(key_lines[1:-1]) # Remove wrapper tags and line endings
+
+ version_search = re.search(r'BEGIN OpenVPN Static key V(\d+)', key_lines[0]) # Future-proofing (hopefully)
+ if version_search:
+ key_version = version_search[1]
+
+ install_openvpn_key(name, key_data, key_version)
+
+# Show functions
+def show_certificate_authority(name=None, pem=False):
+ headers = ['Name', 'Subject', 'Issuer CN', 'Issued', 'Expiry', 'Private Key', 'Parent']
+ data = []
+ certs = get_config_ca_certificate()
+ if certs:
+ for cert_name, cert_dict in certs.items():
+ if name and name != cert_name:
+ continue
+ if 'certificate' not in cert_dict:
+ continue
+
+ cert = load_certificate(cert_dict['certificate'])
+
+ if name and pem:
+ print(encode_certificate(cert))
+ return
+
+ parent_ca_name = get_certificate_ca(cert, certs)
+ cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0]
+
+ if not parent_ca_name or parent_ca_name == cert_name:
+ parent_ca_name = 'N/A'
+
+ if not cert:
+ continue
+
+ have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No'
+ data.append([cert_name, cert.subject.rfc4514_string(), cert_issuer_cn, cert.not_valid_before, cert.not_valid_after, have_private, parent_ca_name])
+
+ print("Certificate Authorities:")
+ print(tabulate.tabulate(data, headers))
+
+def show_certificate(name=None, pem=False, fingerprint_hash=None):
+ headers = ['Name', 'Type', 'Subject CN', 'Issuer CN', 'Issued', 'Expiry', 'Revoked', 'Private Key', 'CA Present']
+ data = []
+ certs = get_config_certificate()
+ if certs:
+ ca_certs = get_config_ca_certificate()
+
+ for cert_name, cert_dict in certs.items():
+ if name and name != cert_name:
+ continue
+ if 'certificate' not in cert_dict:
+ continue
+
+ cert = load_certificate(cert_dict['certificate'])
+
+ if not cert:
+ continue
+
+ if name and pem:
+ print(encode_certificate(cert))
+ return
+ elif name and fingerprint_hash:
+ print(get_certificate_fingerprint(cert, fingerprint_hash))
+ return
+
+ ca_name = get_certificate_ca(cert, ca_certs)
+ cert_subject_cn = cert.subject.rfc4514_string().split(",")[0]
+ cert_issuer_cn = cert.issuer.rfc4514_string().split(",")[0]
+ cert_type = 'Unknown'
+
+ try:
+ ext = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
+ if ext and ExtendedKeyUsageOID.SERVER_AUTH in ext.value:
+ cert_type = 'Server'
+ elif ext and ExtendedKeyUsageOID.CLIENT_AUTH in ext.value:
+ cert_type = 'Client'
+ except:
+ pass
+
+ revoked = 'Yes' if 'revoke' in cert_dict else 'No'
+ have_private = 'Yes' if 'private' in cert_dict and 'key' in cert_dict['private'] else 'No'
+ have_ca = f'Yes ({ca_name})' if ca_name else 'No'
+ data.append([
+ cert_name, cert_type, cert_subject_cn, cert_issuer_cn,
+ cert.not_valid_before, cert.not_valid_after,
+ revoked, have_private, have_ca])
+
+ print("Certificates:")
+ print(tabulate.tabulate(data, headers))
+
+def show_crl(name=None, pem=False):
+ headers = ['CA Name', 'Updated', 'Revokes']
+ data = []
+ certs = get_config_ca_certificate()
+ if certs:
+ for cert_name, cert_dict in certs.items():
+ if name and name != cert_name:
+ continue
+ if 'crl' not in cert_dict:
+ continue
+
+ crls = cert_dict['crl']
+ if isinstance(crls, str):
+ crls = [crls]
+
+ for crl_data in cert_dict['crl']:
+ crl = load_crl(crl_data)
+
+ if not crl:
+ continue
+
+ if name and pem:
+ print(encode_certificate(crl))
+ continue
+
+ certs = get_revoked_by_serial_numbers([revoked.serial_number for revoked in crl])
+ data.append([cert_name, crl.last_update, ", ".join(certs)])
+
+ if name and pem:
+ return
+
+ print("Certificate Revocation Lists:")
+ print(tabulate.tabulate(data, headers))
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='PKI action', required=True)
+
+ # X509
+ parser.add_argument('--ca', help='Certificate Authority', required=False)
+ parser.add_argument('--certificate', help='Certificate', required=False)
+ parser.add_argument('--crl', help='Certificate Revocation List', required=False)
+ parser.add_argument('--sign', help='Sign certificate with specified CA', required=False)
+ parser.add_argument('--self-sign', help='Self-sign the certificate', action='store_true')
+ parser.add_argument('--pem', help='Output using PEM encoding', action='store_true')
+ parser.add_argument('--fingerprint', help='Show fingerprint and exit', action='store')
+
+ # SSH
+ parser.add_argument('--ssh', help='SSH Key', required=False)
+
+ # DH
+ parser.add_argument('--dh', help='DH Parameters', required=False)
+
+ # Key pair
+ parser.add_argument('--keypair', help='Key pair', required=False)
+
+ # OpenVPN
+ parser.add_argument('--openvpn', help='OpenVPN TLS key', required=False)
+
+ # WireGuard
+ parser.add_argument('--wireguard', help='Wireguard', action='store_true')
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument('--key', help='Wireguard key pair', action='store_true', required=False)
+ group.add_argument('--psk', help='Wireguard pre shared key', action='store_true', required=False)
+ parser.add_argument('--interface', help='Install generated keys into running-config for named interface', action='store')
+ parser.add_argument('--peer', help='Install generated keys into running-config for peer', action='store')
+
+ # Global
+ parser.add_argument('--file', help='Write generated keys into specified filename', action='store_true')
+ parser.add_argument('--install', help='Install generated keys into running-config', action='store_true')
+
+ parser.add_argument('--filename', help='Write certificate into specified filename', action='store')
+ parser.add_argument('--key-filename', help='Write key into specified filename', action='store')
+
+ parser.add_argument('--no-prompt', action='store_true', help='Perform action non-interactively')
+ parser.add_argument('--passphrase', help='A passphrase to decrypt the private key')
+
+ args = parser.parse_args()
+
+ try:
+ if args.action == 'generate':
+ if args.ca:
+ if args.sign:
+ generate_ca_certificate_sign(args.ca, args.sign, install=args.install, file=args.file)
+ else:
+ generate_ca_certificate(args.ca, install=args.install, file=args.file)
+ elif args.certificate:
+ if args.sign:
+ generate_certificate_sign(args.certificate, args.sign, install=args.install, file=args.file)
+ elif args.self_sign:
+ generate_certificate_selfsign(args.certificate, install=args.install, file=args.file)
+ else:
+ generate_certificate_request(name=args.certificate, install=args.install, file=args.file)
+
+ elif args.crl:
+ generate_certificate_revocation_list(args.crl, install=args.install, file=args.file)
+
+ elif args.ssh:
+ generate_ssh_keypair(args.ssh, install=args.install, file=args.file)
+
+ elif args.dh:
+ generate_dh_parameters(args.dh, install=args.install, file=args.file)
+
+ elif args.keypair:
+ generate_keypair(args.keypair, install=args.install, file=args.file)
+
+ elif args.openvpn:
+ generate_openvpn_key(args.openvpn, install=args.install, file=args.file)
+
+ elif args.wireguard:
+ # WireGuard supports writing key directly into the CLI, but this
+ # requires the vyos_libexec_dir environment variable to be set
+ os.environ["vyos_libexec_dir"] = "/usr/libexec/vyos"
+
+ if args.key:
+ generate_wireguard_key(args.interface, install=args.install)
+ if args.psk:
+ generate_wireguard_psk(args.interface, peer=args.peer, install=args.install)
+ elif args.action == 'import':
+ if args.ca:
+ import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename,
+ no_prompt=args.no_prompt, passphrase=args.passphrase)
+ elif args.certificate:
+ import_certificate(args.certificate, path=args.filename, key_path=args.key_filename,
+ no_prompt=args.no_prompt, passphrase=args.passphrase)
+ elif args.crl:
+ import_crl(args.crl, args.filename)
+ elif args.dh:
+ import_dh_parameters(args.dh, args.filename)
+ elif args.keypair:
+ import_keypair(args.keypair, path=args.filename, key_path=args.key_filename,
+ no_prompt=args.no_prompt, passphrase=args.passphrase)
+ elif args.openvpn:
+ import_openvpn_secret(args.openvpn, args.filename)
+ elif args.action == 'show':
+ if args.ca:
+ ca_name = None if args.ca == 'all' else args.ca
+ if ca_name:
+ if not conf.exists(['pki', 'ca', ca_name]):
+ print(f'CA "{ca_name}" does not exist!')
+ exit(1)
+ show_certificate_authority(ca_name, args.pem)
+ elif args.certificate:
+ cert_name = None if args.certificate == 'all' else args.certificate
+ if cert_name:
+ if not conf.exists(['pki', 'certificate', cert_name]):
+ print(f'Certificate "{cert_name}" does not exist!')
+ exit(1)
+ if args.fingerprint is None:
+ show_certificate(None if args.certificate == 'all' else args.certificate, args.pem)
+ else:
+ show_certificate(args.certificate, fingerprint_hash=args.fingerprint)
+ elif args.crl:
+ show_crl(None if args.crl == 'all' else args.crl, args.pem)
+ else:
+ show_certificate_authority()
+ print('\n')
+ show_certificate()
+ print('\n')
+ show_crl()
+ except KeyboardInterrupt:
+ print("Aborted")
+ sys.exit(0)
diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py
new file mode 100644
index 0000000..d124650
--- /dev/null
+++ b/src/op_mode/policy_route.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+import re
+import tabulate
+
+from vyos.config import Config
+from vyos.utils.process import cmd
+
+def get_config_policy(conf, name=None, ipv6=False):
+ config_path = ['policy']
+ if name:
+ config_path += ['route6' if ipv6 else 'route', name]
+
+ policy = conf.get_config_dict(config_path, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True)
+
+ return policy
+
+def get_nftables_details(name, ipv6=False):
+ suffix = '6' if ipv6 else ''
+ command = f'sudo nft list chain ip{suffix} mangle VYOS_PBR{suffix}_{name}'
+ try:
+ results = cmd(command)
+ except:
+ return {}
+
+ out = {}
+ for line in results.split('\n'):
+ comment_search = re.search(rf'{name}[\- ](\d+|default-action)', line)
+ if not comment_search:
+ continue
+
+ rule = {}
+ rule_id = comment_search[1]
+ counter_search = re.search(r'counter packets (\d+) bytes (\d+)', line)
+ if counter_search:
+ rule['packets'] = counter_search[1]
+ rule['bytes'] = counter_search[2]
+
+ rule['conditions'] = re.sub(r'(\b(counter packets \d+ bytes \d+|drop|reject|return|log)\b|comment "[\w\-]+")', '', line).strip()
+ out[rule_id] = rule
+ return out
+
+def output_policy_route(name, route_conf, ipv6=False, single_rule_id=None):
+ ip_str = 'IPv6' if ipv6 else 'IPv4'
+ print(f'\n---------------------------------\n{ip_str} Policy Route "{name}"\n')
+
+ if route_conf.get('interface'):
+ print('Active on: {0}\n'.format(" ".join(route_conf['interface'])))
+ else:
+ print('Inactive - Not applied to any interfaces\n')
+
+ details = get_nftables_details(name, ipv6)
+ rows = []
+
+ if 'rule' in route_conf:
+ for rule_id, rule_conf in route_conf['rule'].items():
+ if single_rule_id and rule_id != single_rule_id:
+ continue
+
+ if 'disable' in rule_conf:
+ continue
+
+ action = rule_conf['action'] if 'action' in rule_conf else 'set'
+ protocol = rule_conf['protocol'] if 'protocol' in rule_conf else 'all'
+
+ row = [rule_id, action, protocol]
+ if rule_id in details:
+ rule_details = details[rule_id]
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ row.append(rule_details['conditions'])
+ rows.append(row)
+
+ if 'default_action' in route_conf and not single_rule_id:
+ row = ['default', route_conf['default_action'], 'all']
+ if 'default-action' in details:
+ rule_details = details['default-action']
+ row.append(rule_details.get('packets', 0))
+ row.append(rule_details.get('bytes', 0))
+ rows.append(row)
+
+ if rows:
+ header = ['Rule', 'Action', 'Protocol', 'Packets', 'Bytes', 'Conditions']
+ print(tabulate.tabulate(rows, header) + '\n')
+
+def show_policy(ipv6=False):
+ print('Ruleset Information')
+
+ conf = Config()
+ policy = get_config_policy(conf)
+
+ if not policy:
+ return
+
+ if not ipv6 and 'route' in policy:
+ for route, route_conf in policy['route'].items():
+ output_policy_route(route, route_conf, ipv6=False)
+
+ if ipv6 and 'route6' in policy:
+ for route, route_conf in policy['route6'].items():
+ output_policy_route(route, route_conf, ipv6=True)
+
+def show_policy_name(name, ipv6=False):
+ print('Ruleset Information')
+
+ conf = Config()
+ policy = get_config_policy(conf, name, ipv6)
+ if policy:
+ output_policy_route(name, policy, ipv6)
+
+def show_policy_rule(name, rule_id, ipv6=False):
+ print('Rule Information')
+
+ conf = Config()
+ policy = get_config_policy(conf, name, ipv6)
+ if policy:
+ output_policy_route(name, policy, ipv6, rule_id)
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='Action', required=False)
+ parser.add_argument('--name', help='Policy name', required=False, action='store', nargs='?', default='')
+ parser.add_argument('--rule', help='Policy Rule ID', required=False)
+ parser.add_argument('--ipv6', help='IPv6 toggle', action='store_true')
+
+ args = parser.parse_args()
+
+ if args.action == 'show':
+ if not args.rule:
+ show_policy_name(args.name, args.ipv6)
+ else:
+ show_policy_rule(args.name, args.rule, args.ipv6)
+ elif args.action == 'show_all':
+ show_policy(args.ipv6)
diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py
new file mode 100644
index 0000000..c32a2be
--- /dev/null
+++ b/src/op_mode/powerctrl.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 re
+
+from argparse import ArgumentParser
+from datetime import datetime
+from sys import exit
+from time import time
+
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import call
+from vyos.utils.process import run
+from vyos.utils.process import STDOUT
+
+systemd_sched_file = "/run/systemd/shutdown/scheduled"
+
+def utc2local(datetime):
+ now = time()
+ offs = datetime.fromtimestamp(now) - datetime.utcfromtimestamp(now)
+ return datetime + offs
+
+def parse_time(s):
+ try:
+ if re.match(r'^\d{1,9999}$', s):
+ if (int(s) > 59) and (int(s) < 1440):
+ s = str(int(s)//60) + ":" + str(int(s)%60)
+ return datetime.strptime(s, "%H:%M").time()
+ if (int(s) >= 1440):
+ return s.split()
+ else:
+ return datetime.strptime(s, "%M").time()
+ else:
+ return datetime.strptime(s, "%H:%M").time()
+ except ValueError:
+ return None
+
+
+def parse_date(s):
+ for fmt in ["%d%m%Y", "%d/%m/%Y", "%d.%m.%Y", "%d:%m:%Y", "%Y-%m-%d"]:
+ try:
+ return datetime.strptime(s, fmt).date()
+ except ValueError:
+ continue
+ # If nothing matched...
+ return None
+
+
+def get_shutdown_status():
+ if os.path.exists(systemd_sched_file):
+ # Get scheduled from systemd file
+ with open(systemd_sched_file, 'r') as f:
+ data = f.read().rstrip('\n')
+ r_data = {}
+ for line in data.splitlines():
+ tmp_split = line.split("=")
+ if tmp_split[0] == "USEC":
+ # Convert USEC to human readable format
+ r_data['DATETIME'] = datetime.utcfromtimestamp(
+ int(tmp_split[1])/1000000).strftime('%Y-%m-%d %H:%M:%S')
+ else:
+ r_data[tmp_split[0]] = tmp_split[1]
+ return r_data
+ return None
+
+
+def check_shutdown():
+ output = get_shutdown_status()
+ if output and 'MODE' in output:
+ dt = datetime.strptime(output['DATETIME'], '%Y-%m-%d %H:%M:%S')
+ if output['MODE'] == 'reboot':
+ print("Reboot is scheduled", utc2local(dt))
+ elif output['MODE'] == 'poweroff':
+ print("Poweroff is scheduled", utc2local(dt))
+ else:
+ print("Reboot or poweroff is not scheduled")
+
+
+def cancel_shutdown():
+ output = get_shutdown_status()
+ if output and 'MODE' in output:
+ timenow = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ try:
+ run('/sbin/shutdown -c --no-wall')
+ except OSError as e:
+ exit(f'Could not cancel a reboot or poweroff: {e}')
+
+ mode = output['MODE']
+ message = f'Scheduled {mode} has been cancelled {timenow}'
+ run(f'wall {message} > /dev/null 2>&1')
+ else:
+ print("Reboot or poweroff is not scheduled")
+
+def check_unsaved_config():
+ from vyos.config_mgmt import unsaved_commits
+ from vyos.utils.boot import boot_configuration_success
+
+ if unsaved_commits(allow_missing_config=True) and boot_configuration_success():
+ print("Warning: there are unsaved configuration changes!")
+ print("Run 'save' command if you do not want to lose those changes after reboot/shutdown.")
+ else:
+ pass
+
+def execute_shutdown(time, reboot=True, ask=True):
+ from vyos.utils.process import cmd
+
+ check_unsaved_config()
+
+ host = cmd("hostname --fqdn")
+
+ action = "reboot" if reboot else "poweroff"
+ if not ask:
+ if not ask_yes_no(f"Are you sure you want to {action} this system ({host})?"):
+ exit(0)
+ action_cmd = "-r" if reboot else "-P"
+
+ if len(time) == 0:
+ # T870 legacy reboot job support
+ chk_vyatta_based_reboots()
+ ###
+
+ out = cmd(f'/sbin/shutdown {action_cmd} now', stderr=STDOUT)
+ print(out.split(",", 1)[0])
+ return
+ elif len(time) == 1:
+ # Assume the argument is just time
+ ts = parse_time(time[0])
+ if ts:
+ cmd(f'/sbin/shutdown {action_cmd} {time[0]}', stderr=STDOUT)
+ # Inform all other logged in users about the reboot/shutdown
+ wall_msg = f'System {action} is scheduled {time[0]}'
+ cmd(f'/usr/bin/wall "{wall_msg}"')
+ else:
+ exit(f'Invalid time "{time[0]}". The valid format is HH:MM')
+ elif len(time) == 2:
+ # Assume it's date and time
+ ts = parse_time(time[0])
+ ds = parse_date(time[1])
+ if ts and ds:
+ t = datetime.combine(ds, ts)
+ td = t - datetime.now()
+ t2 = 1 + int(td.total_seconds())//60 # Get total minutes
+
+ cmd(f'/sbin/shutdown {action_cmd} {t2}', stderr=STDOUT)
+ # Inform all other logged in users about the reboot/shutdown
+ wall_msg = f'System {action} is scheduled {time[1]} {time[0]}'
+ cmd(f'/usr/bin/wall "{wall_msg}"')
+ else:
+ if not ts:
+ exit(f'Invalid time "{time[0]}". Uses 24 Hour Clock format')
+ else:
+ exit(f'Invalid date "{time[1]}". A valid format is YYYY-MM-DD [HH:MM]')
+ else:
+ exit('Could not decode date and time. Valids formats are HH:MM or YYYY-MM-DD HH:MM')
+ check_shutdown()
+
+
+def chk_vyatta_based_reboots():
+ # T870 commit-confirm is still using the vyatta code base, once gone, the code below can be removed
+ # legacy scheduled reboot s are using at and store the is as /var/run/<name>.job
+ # name is the node of scheduled the job, commit-confirm checks for that
+
+ f = r'/var/run/confirm.job'
+ if os.path.exists(f):
+ jid = open(f).read().strip()
+ if jid != 0:
+ call(f'sudo atrm {jid}')
+ os.remove(f)
+
+
+def main():
+ parser = ArgumentParser()
+ parser.add_argument("--yes", "-y",
+ help="Do not ask for confirmation",
+ action="store_true",
+ dest="yes")
+ action = parser.add_mutually_exclusive_group(required=True)
+ action.add_argument("--reboot", "-r",
+ help="Reboot the system",
+ nargs="*",
+ metavar="HH:MM")
+
+ action.add_argument("--reboot-in", "-i",
+ help="Reboot the system",
+ nargs="*",
+ metavar="Minutes")
+
+ action.add_argument("--poweroff", "-p",
+ help="Poweroff the system",
+ nargs="*",
+ metavar="Minutes|HH:MM")
+
+ action.add_argument("--cancel", "-c",
+ help="Cancel pending shutdown",
+ action="store_true")
+
+ action.add_argument("--check",
+ help="Check pending shutdown",
+ action="store_true")
+ args = parser.parse_args()
+
+ try:
+ if args.reboot is not None:
+ for r in args.reboot:
+ if ':' not in r and '/' not in r and '.' not in r:
+ print("Incorrect format! Use HH:MM")
+ exit(1)
+ execute_shutdown(args.reboot, reboot=True, ask=args.yes)
+ if args.reboot_in is not None:
+ for i in args.reboot_in:
+ if ':' in i:
+ print("Incorrect format! Use Minutes")
+ exit(1)
+ execute_shutdown(args.reboot_in, reboot=True, ask=args.yes)
+ if args.poweroff is not None:
+ execute_shutdown(args.poweroff, reboot=False, ask=args.yes)
+ if args.cancel:
+ cancel_shutdown()
+ if args.check:
+ check_shutdown()
+ except KeyboardInterrupt:
+ exit("Interrupted")
+
+if __name__ == "__main__":
+ main()
diff --git a/src/op_mode/ppp-server-ctrl.py b/src/op_mode/ppp-server-ctrl.py
new file mode 100644
index 0000000..2bae5b3
--- /dev/null
+++ b/src/op_mode/ppp-server-ctrl.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 argparse
+
+from vyos.config import Config
+from vyos.utils.process import popen
+from vyos.utils.process import DEVNULL
+
+cmd_dict = {
+ 'cmd_base' : '/usr/bin/accel-cmd -p {} ',
+ 'vpn_types' : {
+ 'pppoe' : 2001,
+ 'pptp' : 2003,
+ 'l2tp' : 2004,
+ 'sstp' : 2005
+ },
+ 'conf_proto' : {
+ 'pppoe' : 'service pppoe-server',
+ 'pptp' : 'vpn pptp remote-access',
+ 'l2tp' : 'vpn l2tp remote-access',
+ 'sstp' : 'vpn sstp'
+ }
+}
+
+def is_service_configured(proto):
+ if not Config().exists_effective(cmd_dict['conf_proto'][proto]):
+ print("Service {} is not configured".format(proto))
+ sys.exit(1)
+
+def main():
+ #parese args
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--proto', help='Possible protocols pppoe|pptp|l2tp|sstp', required=True)
+ parser.add_argument('--action', help='Action command', required=True)
+ args = parser.parse_args()
+
+ if args.proto in cmd_dict['vpn_types'] and args.action:
+ # Check is service configured
+ is_service_configured(args.proto)
+
+ if args.action == "show sessions":
+ ses_pattern = " ifname,username,ip,ip6,ip6-dp,calling-sid,rate-limit,state,uptime,rx-bytes,tx-bytes"
+ else:
+ ses_pattern = ""
+
+ output, err = popen(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][args.proto]) + args.action + ses_pattern, stderr=DEVNULL, decode='utf-8')
+ if not err:
+ try:
+ print(f' {output}')
+ except:
+ sys.exit(0)
+ else:
+ print("{} server is not running".format(args.proto))
+
+ else:
+ print("Param --proto and --action required")
+ sys.exit(1)
+
+if __name__ == '__main__':
+ main()
diff --git a/src/op_mode/qos.py b/src/op_mode/qos.py
new file mode 100644
index 0000000..b8ca149
--- /dev/null
+++ b/src/op_mode/qos.py
@@ -0,0 +1,242 @@
+#!/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/>.
+#
+# This script parses output from the 'tc' command and provides table or list output
+import sys
+import typing
+import json
+from tabulate import tabulate
+
+import vyos.opmode
+from vyos.configquery import op_mode_config_dict
+from vyos.utils.process import cmd
+from vyos.utils.network import interface_exists
+
+def detailed_output(dataset, headers):
+ for data in dataset:
+ adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action
+ transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char
+
+ print(tabulate(transformed_rule, tablefmt="presto"))
+ print()
+
+def get_tc_info(interface_dict, interface_name, policy_type):
+ policy_name = interface_dict.get(interface_name, {}).get('egress')
+ if not policy_name:
+ return None, None
+
+ class_dict = op_mode_config_dict(['qos', 'policy', policy_type, policy_name], key_mangling=('-', '_'),
+ get_first_key=True)
+ if not class_dict:
+ return None, None
+
+ return policy_name, class_dict
+
+def format_data_type(num, suffix):
+ if num < 10**3:
+ return f"{num} {suffix}"
+ elif num < 10**6:
+ return f"{num / 10**3:.3f} K{suffix}"
+ elif num < 10**9:
+ return f"{num / 10**6:.3f} M{suffix}"
+ elif num < 10**12:
+ return f"{num / 10**9:.3f} G{suffix}"
+ elif num < 10**15:
+ return f"{num / 10**12:.3f} T{suffix}"
+ elif num < 10**18:
+ return f"{num / 10**15:.3f} P{suffix}"
+ else:
+ return f"{num / 10**18:.3f} E{suffix}"
+
+def show_shaper(raw: bool, ifname: typing.Optional[str], classn: typing.Optional[str], detail: bool):
+ # Scope which interfaces will output data
+ if ifname:
+ if not interface_exists(ifname):
+ raise vyos.opmode.Error(f"{ifname} does not exist!")
+
+ interface_dict = {ifname: op_mode_config_dict(['qos', 'interface', ifname], key_mangling=('-', '_'),
+ get_first_key=True)}
+ if not interface_dict[ifname]:
+ raise vyos.opmode.Error(f"QoS is not applied to {ifname}!")
+
+ else:
+ interface_dict = op_mode_config_dict(['qos', 'interface'], key_mangling=('-', '_'),
+ get_first_key=True)
+ if not interface_dict:
+ raise vyos.opmode.Error(f"QoS is not applied to any interface!")
+
+
+ raw_dict = {'qos': {}}
+ for i in interface_dict.keys():
+ interface_name = i
+ output_list = []
+ output_dict = {'classes': {}}
+ raw_dict['qos'][interface_name] = {}
+
+ # Get configuration node data
+ policy_name, class_dict = get_tc_info(interface_dict, interface_name, 'shaper')
+ if not policy_name:
+ continue
+
+ class_data = json.loads(cmd(f"tc -j -s class show dev {i}"))
+ qdisc_data = json.loads(cmd(f"tc -j qdisc show dev {i}"))
+
+ if class_dict:
+ # Gather qdisc information (e.g. Queue Type)
+ qdisc_dict = {}
+ for qdisc in qdisc_data:
+ if qdisc.get('root'):
+ qdisc_dict['root'] = qdisc
+ continue
+
+ class_id = int(qdisc.get('parent').split(':')[1], 16)
+
+ if class_dict.get('class', {}).get(str(class_id)):
+ qdisc_dict[str(class_id)] = qdisc
+ else:
+ qdisc_dict['default'] = qdisc
+
+ # Gather class information
+ for classes in class_data:
+ if classes.get('rate'):
+ class_id = int(classes.get('handle').split(':')[1], 16)
+
+ # Get name of class
+ if classes.get('root'):
+ class_name = 'root'
+ output_dict['classes'][class_name] = {}
+ elif class_dict.get('class', {}).get(str(class_id)):
+ class_name = str(class_id)
+ output_dict['classes'][class_name] = {}
+ else:
+ class_name = 'default'
+ output_dict['classes'][class_name] = {}
+
+ if classn:
+ if classn != class_name and class_name != 'default' and class_name != 'root':
+ output_dict['classes'].pop(class_name, None)
+ continue
+
+ tmp = output_dict['classes'][class_name]
+
+ tmp['interface_name'] = interface_name
+ tmp['policy_name'] = policy_name
+ tmp['direction'] = 'egress'
+ tmp['class_name'] = class_name
+ tmp['queue_type'] = qdisc_dict.get(class_name, {}).get('kind')
+ tmp['rate'] = str(round(int(classes.get('rate'))*8))
+ tmp['ceil'] = str(round(int(classes.get('ceil'))*8))
+ tmp['bytes'] = classes.get('stats', {}).get('bytes', 0)
+ tmp['packets'] = classes.get('stats', {}).get('packets', 0)
+ tmp['drops'] = classes.get('stats', {}).get('drops', 0)
+ tmp['queued'] = classes.get('stats', {}).get('backlog', 0)
+ tmp['overlimits'] = classes.get('stats', {}).get('overlimits', 0)
+ tmp['requeues'] = classes.get('stats', {}).get('requeues', 0)
+ tmp['lended'] = classes.get('stats', {}).get('lended', 0)
+ tmp['borrowed'] = classes.get('stats', {}).get('borrowed', 0)
+ tmp['giants'] = classes.get('stats', {}).get('giants', 0)
+
+ output_dict['classes'][class_name] = tmp
+ raw_dict['qos'][interface_name][class_name] = tmp
+
+ # Skip printing of values for this interface. All interfaces will be returned in a single dictionary if 'raw' is called
+ if raw:
+ continue
+
+ # Default class may be out of order in original JSON. This moves it to the end
+ move_default = output_dict.get('classes', {}).pop('default', None)
+ if move_default:
+ output_dict.get('classes')['default'] = move_default
+
+ # Create the tables for outputs
+ for output in output_dict.get('classes'):
+ data = output_dict.get('classes').get(output)
+
+ # Add values for detailed (list view) output
+ if detail:
+ output_list.append([data['interface_name'],
+ data['policy_name'],
+ data['direction'],
+ data['class_name'],
+ data['queue_type'],
+ data['rate'],
+ data['ceil'],
+ data['bytes'],
+ data['packets'],
+ data['drops'],
+ data['queued'],
+ data['overlimits'],
+ data['requeues'],
+ data['lended'],
+ data['borrowed'],
+ data['giants']]
+ )
+ # Add values for normal (table view) output
+ else:
+ output_list.append([data['class_name'],
+ data['queue_type'],
+ format_data_type(int(data['rate']), 'b'),
+ format_data_type(int(data['ceil']), 'b'),
+ format_data_type(int(data['bytes']), 'B'),
+ data['packets'],
+ data['drops'],
+ data['queued']]
+ )
+
+ if output_list:
+ if detail:
+ # Headers for detailed (list view) output
+ headers = ['Interface', 'Policy Name', 'Direction', 'Class', 'Type', 'Bandwidth', 'Max. BW', 'Bytes', 'Packets', 'Drops', 'Queued', 'Overlimit', 'Requeue', 'Lended', 'Borrowed', 'Giants']
+
+ print('-' * 35)
+ print(f"Interface: {interface_name}")
+ print(f"Policy Name: {policy_name}\n")
+ detailed_output(output_list, headers)
+ else:
+ # Headers for table output
+ headers = ['Class', 'Type', 'Bandwidth', 'Max. BW', 'Bytes', 'Pkts', 'Drops', 'Queued']
+ align = ('left','left','right','right','right','right','right','right')
+
+ print('-' * 80)
+ print(f"Interface: {interface_name}")
+ print(f"Policy Name: {policy_name}\n")
+ print(tabulate(output_list, headers, colalign=align))
+ print(" \n")
+
+ # Return dictionary with all interfaces if 'raw' is called
+ if raw:
+ return raw_dict
+
+def show_cake(raw: bool, ifname: typing.Optional[str]):
+ if not interface_exists(ifname):
+ raise vyos.opmode.Error(f"{ifname} does not exist!")
+
+ cake_data = json.loads(cmd(f"tc -j -s qdisc show dev {ifname}"))[0]
+ if cake_data:
+ if cake_data.get('kind') == 'cake':
+ if raw:
+ return {'qos': {ifname: cake_data}}
+ else:
+ print(cmd(f"tc -s qdisc show dev {ifname}"))
+
+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/raid.py b/src/op_mode/raid.py
new file mode 100644
index 0000000..fed8ae2
--- /dev/null
+++ b/src/op_mode/raid.py
@@ -0,0 +1,44 @@
+#!/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 vyos.opmode
+from vyos.raid import add_raid_member
+from vyos.raid import delete_raid_member
+
+def add(raid_set_name: str, member: str, by_id: bool = False):
+ try:
+ add_raid_member(raid_set_name, member, by_id)
+ except ValueError as e:
+ raise vyos.opmode.IncorrectValue(str(e))
+
+def delete(raid_set_name: str, member: str, by_id: bool = False):
+ try:
+ delete_raid_member(raid_set_name, member, by_id)
+ except ValueError as e:
+ raise vyos.opmode.IncorrectValue(str(e))
+
+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_openvpn.py b/src/op_mode/reset_openvpn.py
new file mode 100644
index 0000000..cef5299
--- /dev/null
+++ b/src/op_mode/reset_openvpn.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2020 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
+from sys import argv, exit
+from vyos.utils.process import call
+from vyos.utils.commit import commit_in_progress
+
+if __name__ == '__main__':
+ if (len(argv) < 1):
+ print('Must specify OpenVPN interface name!')
+ exit(1)
+
+ interface = argv[1]
+ if os.path.isfile(f'/run/openvpn/{interface}.conf'):
+ if commit_in_progress():
+ print('Cannot restart OpenVPN while a commit is in progress')
+ exit(1)
+ call(f'systemctl restart openvpn@{interface}.service')
+ else:
+ print(f'OpenVPN interface "{interface}" does not exist!')
+ exit(1)
diff --git a/src/op_mode/reset_vpn.py b/src/op_mode/reset_vpn.py
new file mode 100644
index 0000000..61d7c8c
--- /dev/null
+++ b/src/op_mode/reset_vpn.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 typing
+
+from vyos.utils.process import run
+
+import vyos.opmode
+
+cmd_dict = {
+ 'cmd_base': '/usr/bin/accel-cmd -p {} terminate {} {}',
+ 'vpn_types': {
+ 'pptp': 2003,
+ 'l2tp': 2004,
+ 'sstp': 2005
+ }
+}
+
+def reset_conn(protocol: str, username: typing.Optional[str] = None,
+ interface: typing.Optional[str] = None):
+ if protocol in cmd_dict['vpn_types']:
+ # Reset by Interface
+ if interface:
+ run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol],
+ 'if', interface))
+ return
+ # Reset by username
+ if username:
+ run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol],
+ 'username', username))
+ # Reset all
+ else:
+ run(cmd_dict['cmd_base'].format(cmd_dict['vpn_types'][protocol],
+ 'all',
+ ''))
+ else:
+ vyos.opmode.IncorrectValue('Unknown VPN Protocol, aborting')
+
+
+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/restart.py b/src/op_mode/restart.py
new file mode 100644
index 0000000..a83c8b9
--- /dev/null
+++ b/src/op_mode/restart.py
@@ -0,0 +1,149 @@
+#!/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 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.configquery import ConfigTreeQuery
+from vyos.utils.process import call
+from vyos.utils.commit import commit_in_progress
+
+config = ConfigTreeQuery()
+
+service_map = {
+ 'dhcp': {
+ 'systemd_service': 'kea-dhcp4-server',
+ 'path': ['service', 'dhcp-server'],
+ },
+ 'dhcpv6': {
+ 'systemd_service': 'kea-dhcp6-server',
+ 'path': ['service', 'dhcpv6-server'],
+ },
+ 'dns_dynamic': {
+ 'systemd_service': 'ddclient',
+ 'path': ['service', 'dns', 'dynamic'],
+ },
+ 'dns_forwarding': {
+ 'systemd_service': 'pdns-recursor',
+ 'path': ['service', 'dns', 'forwarding'],
+ },
+ 'igmp_proxy': {
+ 'systemd_service': 'igmpproxy',
+ 'path': ['protocols', 'igmp-proxy'],
+ },
+ 'ipsec': {
+ 'systemd_service': 'strongswan',
+ 'path': ['vpn', 'ipsec'],
+ },
+ 'mdns_repeater': {
+ 'systemd_service': 'avahi-daemon',
+ 'path': ['service', 'mdns', 'repeater'],
+ },
+ 'reverse_proxy': {
+ 'systemd_service': 'haproxy',
+ 'path': ['load-balancing', 'reverse-proxy'],
+ },
+ 'router_advert': {
+ 'systemd_service': 'radvd',
+ 'path': ['service', 'router-advert'],
+ },
+ 'snmp': {
+ 'systemd_service': 'snmpd',
+ },
+ 'ssh': {
+ 'systemd_service': 'ssh',
+ },
+ 'suricata': {
+ 'systemd_service': 'suricata',
+ },
+ 'vrrp': {
+ 'systemd_service': 'keepalived',
+ 'path': ['high-availability', 'vrrp'],
+ },
+ 'webproxy': {
+ 'systemd_service': 'squid',
+ },
+}
+services = typing.Literal[
+ 'dhcp',
+ 'dhcpv6',
+ 'dns_dynamic',
+ 'dns_forwarding',
+ 'igmp_proxy',
+ 'ipsec',
+ 'mdns_repeater',
+ 'reverse_proxy',
+ 'router_advert',
+ 'snmp',
+ 'ssh',
+ 'suricata',
+ 'vrrp',
+ 'webproxy',
+]
+
+
+def _verify(func):
+ """Decorator checks if DHCP(v6) config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ name = kwargs.get('name')
+ human_name = name.replace('_', '-')
+
+ if commit_in_progress():
+ print(f'Cannot restart {human_name} service while a commit is in progress')
+ sys.exit(1)
+
+ # Get optional CLI path from service_mapping dict
+ # otherwise use "service name" CLI path
+ path = ['service', name]
+ if 'path' in service_map[name]:
+ path = service_map[name]['path']
+
+ # Check if config does not exist
+ if not config.exists(path):
+ raise vyos.opmode.UnconfiguredSubsystem(
+ f'Service {human_name} is not configured!'
+ )
+ if config.exists(path + ['disable']):
+ raise vyos.opmode.UnconfiguredSubsystem(
+ f'Service {human_name} is disabled!'
+ )
+ return func(*args, **kwargs)
+
+ return _wrapper
+
+
+@_verify
+def restart_service(raw: bool, name: services, vrf: typing.Optional[str]):
+ systemd_service = service_map[name]['systemd_service']
+ if vrf:
+ call(f'systemctl restart "{systemd_service}@{vrf}.service"')
+ else:
+ call(f'systemctl restart "{systemd_service}.service"')
+
+
+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/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py
new file mode 100644
index 0000000..42626ca
--- /dev/null
+++ b/src/op_mode/restart_dhcp_relay.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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/>.
+
+# File: restart_dhcp_relay.py
+# Purpose:
+# Restart IPv4 and IPv6 DHCP relay instances of dhcrelay service
+
+import sys
+import argparse
+
+import vyos.config
+from vyos.utils.process import call
+from vyos.utils.commit import commit_in_progress
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--ipv4", action="store_true", help="Restart IPv4 DHCP relay")
+parser.add_argument("--ipv6", action="store_true", help="Restart IPv6 DHCP relay")
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+ c = vyos.config.Config()
+
+ if args.ipv4:
+ # Do nothing if service is not configured
+ if not c.exists_effective('service dhcp-relay'):
+ print("DHCP relay service not configured")
+ else:
+ if commit_in_progress():
+ print('Cannot restart DHCP relay while a commit is in progress')
+ exit(1)
+ call('systemctl restart isc-dhcp-relay.service')
+
+ sys.exit(0)
+ elif args.ipv6:
+ # Do nothing if service is not configured
+ if not c.exists_effective('service dhcpv6-relay'):
+ print("DHCPv6 relay service not configured")
+ else:
+ if commit_in_progress():
+ print('Cannot restart DHCPv6 relay while commit is in progress')
+ exit(1)
+ call('systemctl restart isc-dhcp-relay6.service')
+
+ sys.exit(0)
+ else:
+ parser.print_help()
+ sys.exit(1)
diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py
new file mode 100644
index 0000000..83146f5
--- /dev/null
+++ b/src/op_mode/restart_frr.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 os
+import argparse
+import logging
+import psutil
+
+from logging.handlers import SysLogHandler
+from shutil import rmtree
+
+from vyos.base import Warning
+from vyos.utils.io import ask_yes_no
+from vyos.utils.file import makedir
+from vyos.utils.process import call
+from vyos.utils.process import process_named_running
+
+# some default values
+watchfrr = '/usr/lib/frr/watchfrr.sh'
+vtysh = '/usr/bin/vtysh'
+frrconfig_tmp = '/tmp/frr_restart'
+
+# configure logging
+logger = logging.getLogger(__name__)
+logs_handler = SysLogHandler('/dev/log')
+logs_handler.setFormatter(logging.Formatter('%(filename)s: %(message)s'))
+logger.addHandler(logs_handler)
+logger.setLevel(logging.INFO)
+
+# check if it is safe to restart FRR
+def _check_safety():
+ try:
+ # print warning
+ if not ask_yes_no('WARNING: This is a potentially unsafe function!\n' \
+ 'You may lose the connection to the router or active configuration after\n' \
+ 'running this command. Use it at your own risk!\n\n'
+ 'Continue?'):
+ return False
+
+ # check if another restart process already running
+ if len([process for process in psutil.process_iter(attrs=['pid', 'name', 'cmdline']) if 'python' in process.info['name'] and 'restart_frr.py' in process.info['cmdline'][1]]) > 1:
+ message = 'Another restart_frr.py process is already running!'
+ logger.error(message)
+ if not ask_yes_no(f'\n{message} It is unsafe to continue.\n\n' \
+ 'Do you want to process anyway?'):
+ return False
+
+ # check if watchfrr.sh is running
+ tmp = os.path.basename(watchfrr)
+ if process_named_running(tmp):
+ message = f'Another {tmp} process is already running.'
+ logger.error(message)
+ if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \
+ 'Do you want to process anyway?'):
+ return False
+
+ # check if vtysh is running
+ if process_named_running('vtysh'):
+ message = 'vtysh process is executed by another task.'
+ logger.error(message)
+ if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \
+ 'Do you want to process anyway?'):
+ return False
+
+ # check if temporary directory exists
+ if os.path.exists(frrconfig_tmp):
+ message = f'Temporary directory "{frrconfig_tmp}" already exists!'
+ logger.error(message)
+ if not ask_yes_no(f'{message} It is unsafe to continue.\n\n' \
+ 'Do you want to process anyway?'):
+ return False
+
+ except:
+ logger.error("Something goes wrong in _check_safety()")
+ return False
+
+ # return True if all check was passed or user confirmed to ignore they results
+ return True
+
+# write active config to file
+def _write_config():
+ # create temporary directory
+ makedir(frrconfig_tmp)
+ # save frr.conf to it
+ command = f'{vtysh} -n -w --config_dir {frrconfig_tmp} 2> /dev/null'
+ return_code = call(command)
+ if return_code != 0:
+ logger.error(f'Failed to save active config: "{command}" returned exit code: {return_code}')
+ return False
+ logger.info(f'Active config saved to {frrconfig_tmp}')
+ return True
+
+# clear and remove temporary directory
+def _cleanup():
+ if os.path.isdir(frrconfig_tmp):
+ rmtree(frrconfig_tmp)
+
+# restart daemon
+def _daemon_restart(daemon):
+ command = f'{watchfrr} restart {daemon}'
+ return_code = call(command)
+ if not return_code == 0:
+ logger.error(f'Failed to restart daemon "{daemon}"!')
+ return False
+
+ # return True if restarted successfully
+ logger.info(f'Daemon "{daemon}" restarted!')
+ return True
+
+# reload old config
+def _reload_config(daemon):
+ if daemon != '':
+ command = f'{vtysh} -n -b --config_dir {frrconfig_tmp} -d {daemon} 2> /dev/null'
+ else:
+ command = f'{vtysh} -n -b --config_dir {frrconfig_tmp} 2> /dev/null'
+
+ return_code = call(command)
+ if not return_code == 0:
+ logger.error('Failed to re-install configuration!')
+ return False
+
+ # return True if restarted successfully
+ logger.info('Configuration re-installed successfully!')
+ return True
+
+# define program arguments
+cmd_args_parser = argparse.ArgumentParser(description='restart frr daemons')
+cmd_args_parser.add_argument('--action', choices=['restart'], required=True, help='action to frr daemons')
+cmd_args_parser.add_argument('--daemon', choices=['zebra', 'staticd', 'bgpd', 'eigrpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd', 'isisd', 'pimd', 'pim6d', 'ldpd', 'babeld', 'bfdd', 'fabricd'], required=False, nargs='*', help='select single or multiple daemons')
+# parse arguments
+cmd_args = cmd_args_parser.parse_args()
+
+# main logic
+# restart daemon
+if cmd_args.action == 'restart':
+ # check if it is safe to restart FRR
+ if not _check_safety():
+ print("\nOne of the safety checks was failed or user aborted command. Exiting.")
+ exit(1)
+
+ if not _write_config():
+ print("Failed to save active config")
+ _cleanup()
+ exit(1)
+
+ # a little trick to make further commands more clear
+ if not cmd_args.daemon:
+ cmd_args.daemon = ['']
+
+ # check all daemons if they are running
+ if cmd_args.daemon != ['']:
+ for daemon in cmd_args.daemon:
+ if not process_named_running(daemon):
+ Warning('some of listed daemons are not running!')
+
+ # run command to restart daemon
+ for daemon in cmd_args.daemon:
+ if not _daemon_restart(daemon):
+ print('Failed to restart daemon: {daemon}')
+ _cleanup()
+ exit(1)
+ # reinstall old configuration
+ _reload_config(daemon)
+
+ # cleanup after all actions
+ _cleanup()
+
+exit(0)
diff --git a/src/op_mode/reverseproxy.py b/src/op_mode/reverseproxy.py
new file mode 100644
index 0000000..1970418
--- /dev/null
+++ b/src/op_mode/reverseproxy.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 json
+import socket
+import sys
+
+from tabulate import tabulate
+from vyos.configquery import ConfigTreeQuery
+
+import vyos.opmode
+
+socket_path = '/run/haproxy/admin.sock'
+timeout = 5
+
+
+def _execute_haproxy_command(command):
+ """Execute a command on the HAProxy UNIX socket and retrieve the response.
+
+ Args:
+ command (str): The command to be executed.
+
+ Returns:
+ str: The response received from the HAProxy UNIX socket.
+
+ Raises:
+ socket.error: If there is an error while connecting or communicating with the socket.
+
+ Finally:
+ Closes the socket connection.
+
+ Notes:
+ - HAProxy expects a newline character at the end of the command.
+ - The socket connection is established using the HAProxy UNIX socket.
+ - The response from the socket is received and decoded.
+
+ Example:
+ response = _execute_haproxy_command('show stat')
+ print(response)
+ """
+ try:
+ # HAProxy expects new line for command
+ command = f'{command}\n'
+
+ # Connect to the HAProxy UNIX socket
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.connect(socket_path)
+
+ # Set the socket timeout
+ sock.settimeout(timeout)
+
+ # Send the command
+ sock.sendall(command.encode())
+
+ # Receive and decode the response
+ response = b''
+ while True:
+ data = sock.recv(4096)
+ if not data:
+ break
+ response += data
+ response = response.decode()
+
+ return (response)
+
+ except socket.error as e:
+ print(f"Error: {e}")
+
+ finally:
+ # Close the socket
+ sock.close()
+
+
+def _convert_seconds(seconds):
+ """Convert seconds to days, hours, minutes, and seconds.
+
+ Args:
+ seconds (int): The number of seconds to convert.
+
+ Returns:
+ tuple: A tuple containing the number of days, hours, minutes, and seconds.
+ """
+ minutes = seconds // 60
+ hours = minutes // 60
+ days = hours // 24
+
+ return days, hours % 24, minutes % 60, seconds % 60
+
+
+def _last_change_format(seconds):
+ """Format the time components into a string representation.
+
+ Args:
+ seconds (int): The total number of seconds.
+
+ Returns:
+ str: The formatted time string with days, hours, minutes, and seconds.
+
+ Examples:
+ >>> _last_change_format(1434)
+ '23m54s'
+ >>> _last_change_format(93734)
+ '1d0h23m54s'
+ >>> _last_change_format(85434)
+ '23h23m54s'
+ """
+ days, hours, minutes, seconds = _convert_seconds(seconds)
+ time_format = ""
+
+ if days:
+ time_format += f"{days}d"
+ if hours:
+ time_format += f"{hours}h"
+ if minutes:
+ time_format += f"{minutes}m"
+ if seconds:
+ time_format += f"{seconds}s"
+
+ return time_format
+
+
+def _get_json_data():
+ """Get haproxy data format JSON"""
+ return _execute_haproxy_command('show stat json')
+
+
+def _get_raw_data():
+ """Retrieve raw data from JSON and organize it into a dictionary.
+
+ Returns:
+ dict: A dictionary containing the organized data categorized
+ into frontend, backend, and server.
+ """
+
+ data = json.loads(_get_json_data())
+ lb_dict = {'frontend': [], 'backend': [], 'server': []}
+
+ for key in data:
+ frontend = []
+ backend = []
+ server = []
+ for entry in key:
+ obj_type = entry['objType'].lower()
+ position = entry['field']['pos']
+ name = entry['field']['name']
+ value = entry['value']['value']
+
+ dict_entry = {'pos': position, 'name': {name: value}}
+
+ if obj_type == 'frontend':
+ frontend.append(dict_entry)
+ elif obj_type == 'backend':
+ backend.append(dict_entry)
+ elif obj_type == 'server':
+ server.append(dict_entry)
+
+ if len(frontend) > 0:
+ lb_dict['frontend'].append(frontend)
+ if len(backend) > 0:
+ lb_dict['backend'].append(backend)
+ if len(server) > 0:
+ lb_dict['server'].append(server)
+
+ return lb_dict
+
+
+def _get_formatted_output(data):
+ """
+ Format the data into a tabulated output.
+
+ Args:
+ data (dict): The data to be formatted.
+
+ Returns:
+ str: The tabulated output representing the formatted data.
+ """
+ table = []
+ headers = [
+ "Proxy name", "Role", "Status", "Req rate", "Resp time", "Last change"
+ ]
+
+ for key in data:
+ for item in data[key]:
+ row = [None] * len(headers)
+
+ for element in item:
+ if 'pxname' in element['name']:
+ row[0] = element['name']['pxname']
+ elif 'svname' in element['name']:
+ row[1] = element['name']['svname']
+ elif 'status' in element['name']:
+ row[2] = element['name']['status']
+ elif 'req_rate' in element['name']:
+ row[3] = element['name']['req_rate']
+ elif 'rtime' in element['name']:
+ row[4] = f"{element['name']['rtime']} ms"
+ elif 'lastchg' in element['name']:
+ row[5] = _last_change_format(element['name']['lastchg'])
+ table.append(row)
+
+ out = tabulate(table, headers, numalign="left")
+ return out
+
+
+def show(raw: bool):
+ config = ConfigTreeQuery()
+ if not config.exists('load-balancing reverse-proxy'):
+ raise vyos.opmode.UnconfiguredSubsystem('Reverse-proxy is not configured')
+
+ data = _get_raw_data()
+ if raw:
+ return data
+ else:
+ return _get_formatted_output(data)
+
+
+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/route.py b/src/op_mode/route.py
new file mode 100644
index 0000000..4aa57db
--- /dev/null
+++ b/src/op_mode/route.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+#
+# Purpose:
+# Displays routing table information.
+# Used by the "run <ip|ipv6> route *" commands.
+
+import re
+import sys
+import typing
+
+from jinja2 import Template
+
+import vyos.opmode
+
+frr_command_template = Template("""
+{% if family == "inet" %}
+ show ip route
+{% else %}
+ show ipv6 route
+{% endif %}
+
+{% if table %}
+ table {{table}}
+{% endif %}
+
+{% if vrf %}
+ vrf {{table}}
+{% endif %}
+
+{% if tag %}
+ tag {{tag}}
+{% elif net %}
+ {{net}}
+{% elif protocol %}
+ {{protocol}}
+{% endif %}
+
+{% if raw %}
+ json
+{% endif %}
+""")
+
+ArgFamily = typing.Literal['inet', 'inet6']
+
+def show_summary(raw: bool, family: ArgFamily, table: typing.Optional[int], vrf: typing.Optional[str]):
+ from vyos.utils.process import cmd
+
+ if family == 'inet':
+ family_cmd = 'ip'
+ elif family == 'inet6':
+ family_cmd = 'ipv6'
+ else:
+ raise ValueError(f"Unsupported address family {family}")
+
+ if (table is not None) and (vrf is not None):
+ raise ValueError("table and vrf options are mutually exclusive")
+
+ # Replace with Jinja if it ever starts growing
+ if table:
+ table_cmd = f"table {table}"
+ else:
+ table_cmd = ""
+
+ if vrf:
+ vrf_cmd = f"vrf {vrf}"
+ else:
+ vrf_cmd = ""
+
+ if raw:
+ from json import loads
+
+ output = cmd(f"vtysh -c 'show {family_cmd} route {vrf_cmd} summary {table_cmd} json'").strip()
+
+ # If there are no routes in a table, its "JSON" output is an empty string,
+ # as of FRR 8.4.1
+ if output:
+ return loads(output)
+ else:
+ return {}
+ else:
+ output = cmd(f"vtysh -c 'show {family_cmd} route {vrf_cmd} summary {table_cmd}'")
+ return output
+
+def show(raw: bool,
+ family: ArgFamily,
+ net: typing.Optional[str],
+ table: typing.Optional[int],
+ protocol: typing.Optional[str],
+ vrf: typing.Optional[str],
+ tag: typing.Optional[str]):
+ if net and protocol:
+ raise ValueError("net and protocol are mutually exclusive")
+ elif table and vrf:
+ raise ValueError("table and vrf are mutually exclusive")
+ elif (family == 'inet6') and (protocol == 'rip'):
+ raise ValueError("rip is not a valid protocol for family inet6")
+ elif (family == 'inet') and (protocol == 'ripng'):
+ raise ValueError("rip is not a valid protocol for family inet6")
+ else:
+ if (family == 'inet6') and (protocol == 'ospf'):
+ protocol = 'ospf6'
+
+ kwargs = dict(locals())
+
+ frr_command = frr_command_template.render(kwargs)
+ frr_command = re.sub(r'\s+', ' ', frr_command)
+
+ from vyos.utils.process import cmd
+ output = cmd(f"vtysh -c '{frr_command}'")
+
+ if raw:
+ from json import loads
+ d = loads(output)
+ collect = []
+ for k,_ in d.items():
+ for l in d[k]:
+ collect.append(l)
+ return collect
+ else:
+ return output
+
+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/secure_boot.py b/src/op_mode/secure_boot.py
new file mode 100644
index 0000000..5f6390a
--- /dev/null
+++ b/src/op_mode/secure_boot.py
@@ -0,0 +1,50 @@
+#!/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 sys
+import vyos.opmode
+
+from vyos.utils.boot import is_uefi_system
+from vyos.utils.system import get_secure_boot_state
+
+def _get_raw_data(name=None):
+ sb_data = {
+ 'state' : get_secure_boot_state(),
+ 'uefi' : is_uefi_system()
+ }
+ return sb_data
+
+def _get_formatted_output(raw_data):
+ if not raw_data['uefi']:
+ print('System run in legacy BIOS mode!')
+ state = 'enabled' if raw_data['state'] else 'disabled'
+ return f'SecureBoot {state}'
+
+def show(raw: bool):
+ sb_data = _get_raw_data()
+ if raw:
+ return sb_data
+ else:
+ return _get_formatted_output(sb_data)
+
+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/serial.py b/src/op_mode/serial.py
new file mode 100644
index 0000000..a586487
--- /dev/null
+++ b/src/op_mode/serial.py
@@ -0,0 +1,38 @@
+#!/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 sys, typing
+
+import vyos.opmode
+from vyos.utils.serial import restart_login_consoles as _restart_login_consoles
+
+def restart_console(device_name: typing.Optional[str]):
+ # Service control moved to vyos.utils.serial to unify checks and prompts.
+ # If users are connected, we want to show an informational message and a prompt
+ # to continue, verifying that the user acknowledges possible interruptions.
+ if device_name:
+ _restart_login_consoles(prompt_user=True, quiet=False, devices=[device_name])
+ else:
+ _restart_login_consoles(prompt_user=True, quiet=False)
+
+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/sflow.py b/src/op_mode/sflow.py
new file mode 100644
index 0000000..0f3feb3
--- /dev/null
+++ b/src/op_mode/sflow.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 dbus
+import sys
+
+from tabulate import tabulate
+
+from vyos.configquery import ConfigTreeQuery
+
+import vyos.opmode
+
+
+def _get_raw_sflow():
+ bus = dbus.SystemBus()
+ config = ConfigTreeQuery()
+
+ interfaces = config.values('system sflow interface')
+ servers = config.list_nodes('system sflow server')
+
+ sflow = bus.get_object('net.sflow.hsflowd', '/net/sflow/hsflowd')
+ sflow_telemetry = dbus.Interface(
+ sflow, dbus_interface='net.sflow.hsflowd.telemetry')
+ agent_address = sflow_telemetry.GetAgent()
+ samples_dropped = int(sflow_telemetry.Get('dropped_samples'))
+ packet_drop_sent = int(sflow_telemetry.Get('event_samples'))
+ samples_packet_sent = int(sflow_telemetry.Get('flow_samples'))
+ samples_counter_sent = int(sflow_telemetry.Get('counter_samples'))
+ datagrams_sent = int(sflow_telemetry.Get('datagrams'))
+ rtmetric_samples = int(sflow_telemetry.Get('rtmetric_samples'))
+ event_samples_suppressed = int(sflow_telemetry.Get('event_samples_suppressed'))
+ samples_suppressed = int(sflow_telemetry.Get('flow_samples_suppressed'))
+ counter_samples_suppressed = int(
+ sflow_telemetry.Get("counter_samples_suppressed"))
+ version = sflow_telemetry.GetVersion()
+
+ sflow_dict = {
+ 'agent_address': agent_address,
+ 'sflow_interfaces': interfaces,
+ 'sflow_servers': servers,
+ 'counter_samples_sent': samples_counter_sent,
+ 'datagrams_sent': datagrams_sent,
+ 'packet_drop_sent': packet_drop_sent,
+ 'packet_samples_dropped': samples_dropped,
+ 'packet_samples_sent': samples_packet_sent,
+ 'rtmetric_samples': rtmetric_samples,
+ 'event_samples_suppressed': event_samples_suppressed,
+ 'flow_samples_suppressed': samples_suppressed,
+ 'counter_samples_suppressed': counter_samples_suppressed,
+ 'hsflowd_version': version
+ }
+ return sflow_dict
+
+
+def _get_formatted_sflow(data):
+ table = [
+ ['Agent address', f'{data.get("agent_address")}'],
+ ['sFlow interfaces', f'{data.get("sflow_interfaces", "n/a")}'],
+ ['sFlow servers', f'{data.get("sflow_servers", "n/a")}'],
+ ['Counter samples sent', f'{data.get("counter_samples_sent")}'],
+ ['Datagrams sent', f'{data.get("datagrams_sent")}'],
+ ['Packet samples sent', f'{data.get("packet_samples_sent")}'],
+ ['Packet samples dropped', f'{data.get("packet_samples_dropped")}'],
+ ['Packet drops sent', f'{data.get("packet_drop_sent")}'],
+ ['Packet drops suppressed', f'{data.get("event_samples_suppressed")}'],
+ ['Flow samples suppressed', f'{data.get("flow_samples_suppressed")}'],
+ ['Counter samples suppressed', f'{data.get("counter_samples_suppressed")}']
+ ]
+
+ return tabulate(table)
+
+
+def show(raw: bool):
+
+ config = ConfigTreeQuery()
+ if not config.exists('system sflow'):
+ raise vyos.opmode.UnconfiguredSubsystem(
+ '"system sflow" is not configured!')
+
+ sflow_data = _get_raw_sflow()
+ if raw:
+ return sflow_data
+ else:
+ return _get_formatted_sflow(sflow_data)
+
+
+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/show-bond.py b/src/op_mode/show-bond.py
new file mode 100644
index 0000000..f676e08
--- /dev/null
+++ b/src/op_mode/show-bond.py
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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 jinja2
+
+from argparse import ArgumentParser
+from vyos.ifconfig import Section
+from vyos.ifconfig import BondIf
+from vyos.utils.file import read_file
+
+from sys import exit
+
+parser = ArgumentParser()
+parser.add_argument("--slaves", action="store_true", help="Show LLDP neighbors on all interfaces")
+parser.add_argument("--interface", action="store", help="Show LLDP neighbors on specific interface")
+
+args = parser.parse_args()
+
+all_bonds = Section.interfaces('bonding')
+# we are not interested in any bond vlan interface
+all_bonds = [x for x in all_bonds if '.' not in x]
+
+TMPL_BRIEF = """Interface Mode State Link Slaves
+{% for interface in data %}
+{{ "%-12s" | format(interface.ifname) }} {{ "%-22s" | format(interface.mode) }} {{ "%-8s" | format(interface.admin_state) }} {{ "%-6s" | format(interface.oper_state) }} {{ interface.members | join(' ') }}
+{% endfor %}
+"""
+
+TMPL_INDIVIDUAL_BOND = """Interface RX: bytes packets TX: bytes packets
+{{ "%-16s" | format(data.ifname) }} {{ "%-10s" | format(data.rx_bytes) }} {{ "%-11s" | format(data.rx_packets) }} {{ "%-10s" | format(data.tx_bytes) }} {{ data.tx_packets }}
+{% for member in data.members if data.members is defined %}
+ {{ "%-12s" | format(member.ifname) }} {{ "%-10s" | format(member.rx_bytes) }} {{ "%-11s" | format(member.rx_packets) }} {{ "%-10s" | format(member.tx_bytes) }} {{ member.tx_packets }}
+{% endfor %}
+"""
+
+if args.slaves and args.interface:
+ exit('Can not use both --slaves and --interfaces option at the same time')
+ parser.print_help()
+
+elif args.slaves:
+ data = []
+ template = TMPL_BRIEF
+ for bond in all_bonds:
+ tmp = BondIf(bond)
+ cfg_dict = {}
+ cfg_dict['ifname'] = bond
+ cfg_dict['mode'] = tmp.get_mode()
+ cfg_dict['admin_state'] = tmp.get_admin_state()
+ cfg_dict['oper_state'] = tmp.operational.get_state()
+ cfg_dict['members'] = tmp.get_slaves()
+ data.append(cfg_dict)
+
+elif args.interface:
+ template = TMPL_INDIVIDUAL_BOND
+ data = {}
+ data['ifname'] = args.interface
+ data['rx_bytes'] = read_file(f'/sys/class/net/{args.interface}/statistics/rx_bytes')
+ data['rx_packets'] = read_file(f'/sys/class/net/{args.interface}/statistics/rx_packets')
+ data['tx_bytes'] = read_file(f'/sys/class/net/{args.interface}/statistics/tx_bytes')
+ data['tx_packets'] = read_file(f'/sys/class/net/{args.interface}/statistics/tx_packets')
+
+ # each bond member interface has its own statistics
+ data['members'] = []
+ for member in BondIf(args.interface).get_slaves():
+ tmp = {}
+ tmp['ifname'] = member
+ tmp['rx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/rx_bytes')
+ tmp['rx_packets'] = read_file(f'/sys/class/net/{member}/statistics/rx_packets')
+ tmp['tx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/tx_bytes')
+ tmp['tx_packets'] = read_file(f'/sys/class/net/{member}/statistics/tx_packets')
+ data['members'].append(tmp)
+
+else:
+ parser.print_help()
+ exit(1)
+
+tmpl = jinja2.Template(template, trim_blocks=True)
+config_text = tmpl.render(data=data)
+print(config_text)
diff --git a/src/op_mode/show_acceleration.py b/src/op_mode/show_acceleration.py
new file mode 100644
index 0000000..1c4831f
--- /dev/null
+++ b/src/op_mode/show_acceleration.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 os
+import re
+import argparse
+
+from vyos.config import Config
+from vyos.utils.process import call
+from vyos.utils.process import popen
+
+def detect_qat_dev():
+ output, err = popen('lspci -nn', decode='utf-8')
+ if not err:
+ data = re.findall('(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)', output)
+ # QAT devices found
+ if data:
+ return
+ print("\t No QAT device found")
+ sys.exit(1)
+
+def show_qat_status():
+ detect_qat_dev()
+
+ # Check QAT service
+ if not os.path.exists('/etc/init.d/qat_service'):
+ print("\t QAT service not installed")
+ sys.exit(1)
+
+ # Show QAT service
+ call('/etc/init.d/qat_service status')
+
+# Return QAT devices
+def get_qat_devices():
+ data_st, err = popen('/etc/init.d/qat_service status', decode='utf-8')
+ if not err:
+ elm_lst = re.findall('qat_dev\d', data_st)
+ print('\n'.join(elm_lst))
+
+# Return QAT path in sysfs
+def get_qat_proc_path(qat_dev):
+ q_type = ""
+ q_bsf = ""
+ output, err = popen('/etc/init.d/qat_service status', decode='utf-8')
+ if not err:
+ # Parse QAT service output
+ data_st = output.split("\n")
+ for elm_str in range(len(data_st)):
+ if re.search(qat_dev, data_st[elm_str]):
+ elm_list = data_st[elm_str].split(", ")
+ for elm in range(len(elm_list)):
+ if re.search('type', elm_list[elm]):
+ q_list = elm_list[elm].split(": ")
+ q_type=q_list[1]
+ elif re.search('bsf', elm_list[elm]):
+ q_list = elm_list[elm].split(": ")
+ q_bsf = q_list[1]
+ return "/sys/kernel/debug/qat_"+q_type+"_"+q_bsf+"/"
+
+# Check if QAT service confgured
+def check_qat_if_conf():
+ if not Config().exists_effective('system acceleration qat'):
+ print("\t system acceleration qat is not configured")
+ sys.exit(1)
+
+parser = argparse.ArgumentParser()
+group = parser.add_mutually_exclusive_group()
+group.add_argument("--hw", action="store_true", help="Show Intel QAT HW")
+group.add_argument("--dev_list", action="store_true", help="Return Intel QAT devices")
+group.add_argument("--flow", action="store_true", help="Show Intel QAT flows")
+group.add_argument("--interrupts", action="store_true", help="Show Intel QAT interrupts")
+group.add_argument("--status", action="store_true", help="Show Intel QAT status")
+group.add_argument("--conf", action="store_true", help="Show Intel QAT configuration")
+
+parser.add_argument("--dev", type=str, help="Selected QAT device")
+
+args = parser.parse_args()
+
+if args.hw:
+ detect_qat_dev()
+ # Show availible Intel QAT devices
+ call('lspci -nn | egrep -e \'8086:37c8|8086:19e2|8086:0435|8086:6f54\'')
+elif args.flow and args.dev:
+ check_qat_if_conf()
+ call('cat '+get_qat_proc_path(args.dev)+"fw_counters")
+elif args.interrupts:
+ check_qat_if_conf()
+ # Delete _dev from args.dev
+ call('cat /proc/interrupts | grep qat')
+elif args.status:
+ check_qat_if_conf()
+ show_qat_status()
+elif args.conf and args.dev:
+ check_qat_if_conf()
+ call('cat '+get_qat_proc_path(args.dev)+"dev_cfg")
+elif args.dev_list:
+ get_qat_devices()
+else:
+ parser.print_help()
+ sys.exit(1)
diff --git a/src/op_mode/show_configuration_files.sh b/src/op_mode/show_configuration_files.sh
new file mode 100644
index 0000000..ad8e074
--- /dev/null
+++ b/src/op_mode/show_configuration_files.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+# Wrapper script for the show configuration files command
+find ${vyatta_sysconfdir}/config/ \
+ -type f \
+ -not -name ".*" \
+ -not -name "config.boot.*" \
+ -printf "%f\t(%Tc)\t%T@\n" \
+ | sort -r -k3 \
+ | awk -F"\t" '{printf ("%-20s\t%s\n", $1,$2) ;}'
diff --git a/src/op_mode/show_configuration_json.py b/src/op_mode/show_configuration_json.py
new file mode 100644
index 0000000..fdece53
--- /dev/null
+++ b/src/op_mode/show_configuration_json.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021 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 json
+
+from vyos.configquery import ConfigTreeQuery
+
+
+config = ConfigTreeQuery()
+c = config.get_config_dict()
+
+parser = argparse.ArgumentParser()
+parser.add_argument("-p", "--pretty", action="store_true", help="Show pretty configuration in JSON format")
+
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+
+ if args.pretty:
+ print(json.dumps(c, indent=4))
+ else:
+ print(json.dumps(c))
diff --git a/src/op_mode/show_current_user.sh b/src/op_mode/show_current_user.sh
new file mode 100644
index 0000000..93e6efa
--- /dev/null
+++ b/src/op_mode/show_current_user.sh
@@ -0,0 +1,18 @@
+#! /bin/bash
+
+echo -n "login : " ; who -m
+
+if [ -n "$VYATTA_USER_LEVEL_DIR" ]
+then
+ echo -n "level : "
+ basename $VYATTA_USER_LEVEL_DIR
+fi
+
+echo -n "user : " ; id -un
+echo -n "groups : " ; id -Gn
+
+if id -Z >/dev/null 2>&1
+then
+ echo -n "context : "
+ id -Z
+fi
diff --git a/src/op_mode/show_disk_format.sh b/src/op_mode/show_disk_format.sh
new file mode 100644
index 0000000..61b15a5
--- /dev/null
+++ b/src/op_mode/show_disk_format.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+disk_dev="/dev/$1"
+if [ ! -b "$disk_dev" ];then
+ echo "$3 is not a disk device"
+ exit 1
+fi
+sudo /sbin/fdisk -l "$disk_dev"
diff --git a/src/op_mode/show_ntp.sh b/src/op_mode/show_ntp.sh
new file mode 100644
index 0000000..4b59b80
--- /dev/null
+++ b/src/op_mode/show_ntp.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+sourcestats=0
+tracking=0
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ --sourcestats) sourcestats=1 ;;
+ --tracking) tracking=1 ;;
+ *) echo "Unknown parameter passed: $1" ;;
+ esac
+ shift
+done
+
+if ! ps -C chronyd &>/dev/null; then
+ echo NTP daemon disabled
+ exit 1
+fi
+
+PID=$(pgrep chronyd | head -n1)
+VRF_NAME=$(ip vrf identify ${PID})
+
+if [ ! -z ${VRF_NAME} ]; then
+ VRF_CMD="sudo ip vrf exec ${VRF_NAME}"
+fi
+
+if [ $sourcestats -eq 1 ]; then
+ $VRF_CMD chronyc sourcestats -v
+elif [ $tracking -eq 1 ]; then
+ $VRF_CMD chronyc tracking -v
+else
+ echo "Unknown option"
+fi
+
diff --git a/src/op_mode/show_openconnect_otp.py b/src/op_mode/show_openconnect_otp.py
new file mode 100644
index 0000000..3771fb3
--- /dev/null
+++ b/src/op_mode/show_openconnect_otp.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+
+# Copyright 2017-2023 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/>.
+
+import argparse
+import os
+from base64 import b32encode
+
+from vyos.config import Config
+from vyos.utils.dict import dict_search_args
+from vyos.utils.process import popen
+
+otp_file = '/run/ocserv/users.oath'
+
+def check_uname_otp(username):
+ """
+ Check if "username" exists and have an OTP key
+ """
+ config = Config()
+ base_key = ['vpn', 'openconnect', 'authentication', 'local-users', 'username', username, 'otp', 'key']
+ if not config.exists(base_key):
+ return False
+ return True
+
+def get_otp_ocserv(username):
+ config = Config()
+ base = ['vpn', 'openconnect']
+ if not config.exists(base):
+ return None
+
+ ocserv = config.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+
+ user_path = ['authentication', 'local_users', 'username']
+ users = dict_search_args(ocserv, *user_path)
+
+ if users is None:
+ return None
+
+ # function is called conditionally, if check_uname_otp true, so username
+ # exists
+ result = users[username]
+
+ return result
+
+def display_otp_ocserv(username, params, info):
+ hostname = os.uname()[1]
+ key_hex = params['otp']['key']
+ otp_length = params['otp']['otp_length']
+ interval = params['otp']['interval']
+ token_type = params['otp']['token_type']
+ if token_type == 'hotp-time':
+ token_type_acrn = 'totp'
+ key_base32 = b32encode(bytes.fromhex(key_hex)).decode()
+ otp_url = ''.join(["otpauth://",token_type_acrn,"/",username,"@",hostname,"?secret=",key_base32,"&digits=",otp_length,"&period=",interval])
+ qrcode,err = popen('qrencode -t ansiutf8', input=otp_url)
+
+ if info == 'full':
+ print("# You can share it with the user, he just needs to scan the QR in his OTP app")
+ print("# username: ", username)
+ print("# OTP KEY: ", key_base32)
+ print("# OTP URL: ", otp_url)
+ print(qrcode)
+ print('# To add this OTP key to configuration, run the following commands:')
+ print(f"set vpn openconnect authentication local-users username {username} otp key '{key_hex}'")
+ if interval != "30":
+ print(f"set vpn openconnect authentication local-users username {username} otp interval '{interval}'")
+ if otp_length != "6":
+ print(f"set vpn openconnect authentication local-users username {username} otp otp-length '{otp_length}'")
+ elif info == 'key-hex':
+ print("# OTP key in hexadecimal: ")
+ print(key_hex)
+ elif info == 'key-b32':
+ print("# OTP key in Base32: ")
+ print(key_base32)
+ elif info == 'qrcode':
+ print(f"# QR code for OpenConnect user '{username}'")
+ print(qrcode)
+ elif info == 'uri':
+ print(f"# URI for OpenConnect user '{username}'")
+ print(otp_url)
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(add_help=False, description='Show OTP authentication information for selected user')
+ parser.add_argument('--user', action="store", type=str, default='', help='Username')
+ parser.add_argument('--info', action="store", type=str, default='full', help='Wich information to display')
+
+ args = parser.parse_args()
+ if check_uname_otp(args.user):
+ user_otp_params = get_otp_ocserv(args.user)
+ display_otp_ocserv(args.user, user_otp_params, args.info)
+ else:
+ print(f'There is no such user ("{args.user}") with an OTP key configured')
diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py
new file mode 100644
index 0000000..6abafc8
--- /dev/null
+++ b/src/op_mode/show_openvpn.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018 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 jinja2
+import argparse
+
+from sys import exit
+from vyos.config import Config
+
+outp_tmpl = """
+{% if clients %}
+OpenVPN status on {{ intf }}
+
+Client CN Remote Host Tunnel IP Local Host TX bytes RX bytes Connected Since
+--------- ----------- --------- ---------- -------- -------- ---------------
+{% for c in clients %}
+{{ "%-15s"|format(c.name) }} {{ "%-21s"|format(c.remote) }} {{ "%-15s"|format(c.tunnel) }} {{ "%-21s"|format(local) }} {{ "%-9s"|format(c.tx_bytes) }} {{ "%-9s"|format(c.rx_bytes) }} {{ c.online_since }}
+{% endfor %}
+{% endif %}
+"""
+
+def bytes2HR(size):
+ # we need to operate in integers
+ size = int(size)
+
+ suff = ['B', 'KB', 'MB', 'GB', 'TB']
+ suffIdx = 0
+
+ while size > 1024:
+ # incr. suffix index
+ suffIdx += 1
+ # divide
+ size = size/1024.0
+
+ output="{0:.1f} {1}".format(size, suff[suffIdx])
+ return output
+
+def get_vpn_tunnel_address(peer, interface):
+ lst = []
+ status_file = '/var/run/openvpn/{}.status'.format(interface)
+
+ with open(status_file, 'r') as f:
+ lines = f.readlines()
+ for line in lines:
+ if peer in line:
+ lst.append(line)
+
+ # filter out subnet entries
+ lst = [l for l in lst[1:] if '/' not in l.split(',')[0]]
+
+ if lst:
+ tunnel_ip = lst[0].split(',')[0]
+ return tunnel_ip
+
+ return 'n/a'
+
+def get_status(mode, interface):
+ status_file = '/var/run/openvpn/{}.status'.format(interface)
+ # this is an empirical value - I assume we have no more then 999999
+ # current OpenVPN connections
+ routing_table_line = 999999
+
+ data = {
+ 'mode': mode,
+ 'intf': interface,
+ 'local': 'N/A',
+ 'date': '',
+ 'clients': [],
+ }
+
+ if not os.path.exists(status_file):
+ return data
+
+ with open(status_file, 'r') as f:
+ lines = f.readlines()
+ for line_no, line in enumerate(lines):
+ # remove trailing newline character first
+ line = line.rstrip('\n')
+
+ # check first line header
+ if line_no == 0:
+ if mode == 'server':
+ if not line == 'OpenVPN CLIENT LIST':
+ raise NameError('Expected "OpenVPN CLIENT LIST"')
+ else:
+ if not line == 'OpenVPN STATISTICS':
+ raise NameError('Expected "OpenVPN STATISTICS"')
+
+ continue
+
+ # second line informs us when the status file has been last updated
+ if line_no == 1:
+ data['date'] = line.lstrip('Updated,').rstrip('\n')
+ continue
+
+ if mode == 'server':
+ # followed by line3 giving output information and the actual output data
+ #
+ # Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
+ # client1,172.18.202.10:55904,2880587,2882653,Fri Aug 23 16:25:48 2019
+ # client3,172.18.204.10:41328,2850832,2869729,Fri Aug 23 16:25:43 2019
+ # client2,172.18.203.10:48987,2856153,2871022,Fri Aug 23 16:25:45 2019
+ if (line_no >= 3) and (line_no < routing_table_line):
+ # indicator that there are no more clients and we will continue with the
+ # routing table
+ if line == 'ROUTING TABLE':
+ routing_table_line = line_no
+ continue
+
+ client = {
+ 'name': line.split(',')[0],
+ 'remote': line.split(',')[1],
+ 'rx_bytes': bytes2HR(line.split(',')[2]),
+ 'tx_bytes': bytes2HR(line.split(',')[3]),
+ 'online_since': line.split(',')[4]
+ }
+ client["tunnel"] = get_vpn_tunnel_address(client['remote'], interface)
+ data['clients'].append(client)
+ continue
+ else:
+ if line_no == 2:
+ client = {
+ 'name': 'N/A',
+ 'remote': 'N/A',
+ 'rx_bytes': bytes2HR(line.split(',')[1]),
+ 'tx_bytes': '',
+ 'online_since': 'N/A'
+ }
+ continue
+
+ if line_no == 3:
+ client['tx_bytes'] = bytes2HR(line.split(',')[1])
+ data['clients'].append(client)
+ break
+
+ return data
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-m', '--mode', help='OpenVPN operation mode (server, client, site-2-site)', required=True)
+
+ args = parser.parse_args()
+
+ # Do nothing if service is not configured
+ config = Config()
+ if len(config.list_effective_nodes('interfaces openvpn')) == 0:
+ print("No OpenVPN interfaces configured")
+ exit(0)
+
+ # search all OpenVPN interfaces and add those with a matching mode to our
+ # interfaces list
+ interfaces = []
+ for intf in config.list_effective_nodes('interfaces openvpn'):
+ # get interface type (server, client, site-to-site)
+ mode = config.return_effective_value('interfaces openvpn {} mode'.format(intf))
+ if args.mode == mode:
+ interfaces.append(intf)
+
+ for intf in interfaces:
+ data = get_status(args.mode, intf)
+ local_host = config.return_effective_value('interfaces openvpn {} local-host'.format(intf))
+ local_port = config.return_effective_value('interfaces openvpn {} local-port'.format(intf))
+ if local_host and local_port:
+ data['local'] = local_host + ':' + local_port
+
+ if args.mode in ['client', 'site-to-site']:
+ for client in data['clients']:
+ if config.exists_effective('interfaces openvpn {} shared-secret-key-file'.format(intf)):
+ client['name'] = "None (PSK)"
+
+ remote_host = config.return_effective_values('interfaces openvpn {} remote-host'.format(intf))
+ remote_port = config.return_effective_value('interfaces openvpn {} remote-port'.format(intf))
+
+ if not remote_port:
+ remote_port = '1194'
+
+ if len(remote_host) >= 1:
+ client['remote'] = str(remote_host[0]) + ':' + remote_port
+
+ client['tunnel'] = 'N/A'
+
+ tmpl = jinja2.Template(outp_tmpl)
+ print(tmpl.render(data))
diff --git a/src/op_mode/show_openvpn_mfa.py b/src/op_mode/show_openvpn_mfa.py
new file mode 100644
index 0000000..100c421
--- /dev/null
+++ b/src/op_mode/show_openvpn_mfa.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+#
+# Copyright 2017-2023 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/>.
+
+import re
+import socket
+import urllib.parse
+import argparse
+
+from vyos.utils.process import popen
+
+otp_file = '/config/auth/openvpn/{interface}-otp-secrets'
+
+def get_mfa_secret(interface, client):
+ try:
+ with open(otp_file.format(interface=interface), "r") as f:
+ users = f.readlines()
+ for user in users:
+ if re.search('^' + client + ' ', user):
+ return user.split(':')[3]
+ except:
+ pass
+
+def get_mfa_uri(client, secret):
+ hostname = socket.gethostname()
+ fqdn = socket.getfqdn()
+ uri = 'otpauth://totp/{hostname}:{client}@{fqdn}?secret={secret}'
+
+ return urllib.parse.quote(uri.format(hostname=hostname, client=client, fqdn=fqdn, secret=secret), safe='/:@?=')
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(add_help=False, description='Show two-factor authentication information')
+ parser.add_argument('--intf', action="store", type=str, default='', help='only show the specified interface')
+ parser.add_argument('--user', action="store", type=str, default='', help='only show the specified users')
+ parser.add_argument('--action', action="store", type=str, default='show', help='action to perform')
+
+ args = parser.parse_args()
+ secret = get_mfa_secret(args.intf, args.user)
+
+ if args.action == "secret" and secret:
+ print(secret)
+
+ if args.action == "uri" and secret:
+ uri = get_mfa_uri(args.user, secret)
+ print(uri)
+
+ if args.action == "qrcode" and secret:
+ uri = get_mfa_uri(args.user, secret)
+ qrcode,err = popen('qrencode -t ansiutf8', input=uri)
+ print(qrcode)
+
diff --git a/src/op_mode/show_raid.sh b/src/op_mode/show_raid.sh
new file mode 100644
index 0000000..ab5d4d5
--- /dev/null
+++ b/src/op_mode/show_raid.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+if [ "$EUID" -ne 0 ]; then
+ # This should work without sudo because we have read
+ # access to the dev, but for some reason mdadm must be
+ # run as root in order to succeed.
+ echo "Please run as root"
+ exit 1
+fi
+
+raid_set_name=$1
+raid_sets=`cat /proc/partitions | grep md | awk '{ print $4 }'`
+valid_set=`echo $raid_sets | grep $raid_set_name`
+if [ -z $valid_set ]; then
+ echo "$raid_set_name is not a RAID set"
+else
+ if [ -r /dev/${raid_set_name} ]; then
+ # This should work without sudo because we have read
+ # access to the dev, but for some reason mdadm must be
+ # run as root in order to succeed.
+ mdadm --detail /dev/${raid_set_name}
+ else
+ echo "Must be administrator or root to display RAID status"
+ fi
+fi
diff --git a/src/op_mode/show_sensors.py b/src/op_mode/show_sensors.py
new file mode 100644
index 0000000..5e3084f
--- /dev/null
+++ b/src/op_mode/show_sensors.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+#
+# Copyright 2017-2023 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/>.
+
+import re
+import sys
+from vyos.utils.process import popen
+from vyos.utils.process import DEVNULL
+
+output,retcode = popen("sensors --no-adapter", stderr=DEVNULL)
+if retcode == 0:
+ print (output)
+ sys.exit(0)
+else:
+ output,retcode = popen("sensors-detect --auto",stderr=DEVNULL)
+ match = re.search(r'#----cut here----(.*)#----cut here----',output, re.DOTALL)
+ if match:
+ for module in match.group(0).split('\n'):
+ if not module.startswith("#"):
+ popen("modprobe {}".format(module.strip()))
+ output,retcode = popen("sensors --no-adapter", stderr=DEVNULL)
+ if retcode == 0:
+ print (output)
+ sys.exit(0)
+
+
+print ("No sensors found")
+sys.exit(1)
diff --git a/src/op_mode/show_techsupport_report.py b/src/op_mode/show_techsupport_report.py
new file mode 100644
index 0000000..32cf677
--- /dev/null
+++ b/src/op_mode/show_techsupport_report.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-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 sys
+from typing import List
+from vyos.ifconfig import Section
+from vyos.ifconfig import Interface
+from vyos.utils.process import rc_cmd
+
+
+def print_header(command: str) -> None:
+ """Prints a command with headers '-'.
+
+ Example:
+
+ % print_header('Example command')
+
+ ---------------
+ Example command
+ ---------------
+ """
+ header_length = len(command) * '-'
+ print(f"\n{header_length}\n{command}\n{header_length}")
+
+
+def execute_command(command: str, header_text: str) -> None:
+ """Executes a command and prints the output with a header.
+
+ Example:
+ % execute_command('uptime', "Uptime of the system")
+
+ --------------------
+ Uptime of the system
+ --------------------
+ 20:21:57 up 9:04, 5 users, load average: 0.00, 0.00, 0.0
+
+ """
+ print_header(header_text)
+ try:
+ rc, output = rc_cmd(command)
+ # Enable unbuffered print param to improve responsiveness of printed
+ # output to end user
+ print(output, flush=True)
+ # Exit gracefully when user interrupts program output
+ # Flush standard streams; redirect remaining output to devnull
+ # Resolves T5633: Bug #1 and 3
+ except (BrokenPipeError, KeyboardInterrupt):
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error executing command: {command}")
+ print(f"Error message: {e}")
+
+
+def op(cmd: str) -> str:
+ """Returns a command with the VyOS operational mode wrapper."""
+ return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}'
+
+
+def get_ethernet_interfaces() -> List[Interface]:
+ """Returns a list of Ethernet interfaces."""
+ return Section.interfaces('ethernet')
+
+
+def show_version() -> None:
+ """Prints the VyOS version and package changes."""
+ execute_command(op('show version'), 'VyOS Version and Package Changes')
+
+
+def show_config_file() -> None:
+ """Prints the contents of a configuration file with a header."""
+ execute_command('cat /opt/vyatta/etc/config/config.boot', 'Configuration file')
+
+
+def show_running_config() -> None:
+ """Prints the running configuration."""
+ execute_command(op('show configuration'), 'Running configuration')
+
+
+def show_package_repository_config() -> None:
+ """Prints the package repository configuration file."""
+ execute_command('cat /etc/apt/sources.list', 'Package Repository Configuration File')
+ execute_command('ls -l /etc/apt/sources.list.d/', 'Repositories')
+
+
+def show_user_startup_scripts() -> None:
+ """Prints the user startup scripts."""
+ execute_command('cat /config/scripts/vyos-preconfig-bootup.script', 'User Startup Scripts (Preconfig)')
+ execute_command('cat /config/scripts/vyos-postconfig-bootup.script', 'User Startup Scripts (Postconfig)')
+
+
+def show_frr_config() -> None:
+ """Prints the FRR configuration."""
+ execute_command('vtysh -c "show run"', 'FRR configuration')
+
+
+def show_interfaces() -> None:
+ """Prints the interfaces."""
+ execute_command(op('show interfaces'), 'Interfaces')
+
+
+def show_interface_statistics() -> None:
+ """Prints the interface statistics."""
+ execute_command('ip -s link show', 'Interface statistics')
+
+
+def show_physical_interface_statistics() -> None:
+ """Prints the physical interface statistics."""
+ execute_command('/usr/bin/true', 'Physical Interface statistics')
+ for iface in get_ethernet_interfaces():
+ # Exclude vlans
+ if '.' in iface:
+ continue
+ execute_command(f'ethtool --driver {iface}', f'ethtool --driver {iface}')
+ execute_command(f'ethtool --statistics {iface}', f'ethtool --statistics {iface}')
+ execute_command(f'ethtool --show-ring {iface}', f'ethtool --show-ring {iface}')
+ execute_command(f'ethtool --show-coalesce {iface}', f'ethtool --show-coalesce {iface}')
+ execute_command(f'ethtool --pause {iface}', f'ethtool --pause {iface}')
+ execute_command(f'ethtool --show-features {iface}', f'ethtool --show-features {iface}')
+ execute_command(f'ethtool --phy-statistics {iface}', f'ethtool --phy-statistics {iface}')
+ execute_command('netstat --interfaces', 'netstat --interfaces')
+ execute_command('netstat --listening', 'netstat --listening')
+ execute_command('cat /proc/net/dev', 'cat /proc/net/dev')
+
+
+def show_bridge() -> None:
+ """Show bridge interfaces."""
+ execute_command(op('show bridge'), 'Show bridge')
+
+
+def show_arp() -> None:
+ """Prints ARP entries."""
+ execute_command(op('show arp'), 'ARP Table (Total entries)')
+ execute_command(op('show ipv6 neighbors'), 'show ipv6 neighbors')
+
+
+def show_route() -> None:
+ """Prints routing information."""
+
+ cmd_list_route = [
+ "show ip route bgp | head -108",
+ "show ip route cache",
+ "show ip route connected",
+ "show ip route forward",
+ "show ip route isis | head -108",
+ "show ip route kernel",
+ "show ip route ospf | head -108",
+ "show ip route rip",
+ "show ip route static",
+ "show ip route summary",
+ "show ip route supernets-only",
+ "show ip route table all",
+ "show ip route vrf all",
+ "show ipv6 route bgp | head -108",
+ "show ipv6 route cache",
+ "show ipv6 route connected",
+ "show ipv6 route forward",
+ "show ipv6 route isis",
+ "show ipv6 route kernel",
+ "show ipv6 route ospfv3",
+ "show ipv6 route rip",
+ "show ipv6 route static",
+ "show ipv6 route summary",
+ "show ipv6 route table all",
+ "show ipv6 route vrf all",
+ ]
+ for command in cmd_list_route:
+ execute_command(op(command), command)
+
+
+def show_firewall() -> None:
+ """Prints firweall information."""
+ execute_command('sudo nft list ruleset', 'nft list ruleset')
+
+
+def show_system() -> None:
+ """Prints system parameters."""
+ execute_command(op('show version'), 'Show System Version')
+ execute_command(op('show system storage'), 'Show System Storage')
+ execute_command(op('show system image details'), 'Show System Image Details')
+
+
+def show_date() -> None:
+ """Print the current date."""
+ execute_command('date', 'Current Time')
+
+
+def show_installed_packages() -> None:
+ """Prints installed packages."""
+ execute_command('dpkg --list', 'Installed Packages')
+
+
+def show_loaded_modules() -> None:
+ """Prints loaded modules /proc/modules"""
+ execute_command('cat /proc/modules', 'Loaded Modules')
+
+
+def show_cpu_statistics() -> None:
+ """Prints CPU statistics."""
+ execute_command('/usr/bin/true', 'CPU')
+ execute_command('lscpu', 'Installed CPU\'s')
+ execute_command('top --iterations 1 --batch-mode --accum-time-toggle', 'Cumulative CPU Time Used by Running Processes')
+ execute_command('cat /proc/loadavg', 'Load Average')
+
+
+def show_system_interrupts() -> None:
+ """Prints system interrupts."""
+ execute_command('cat /proc/interrupts', 'Hardware Interrupt Counters')
+
+
+def show_soft_irqs() -> None:
+ """Prints soft IRQ's."""
+ execute_command('cat /proc/softirqs', 'Soft IRQ\'s')
+
+
+def show_softnet_statistics() -> None:
+ """Prints softnet statistics."""
+ execute_command('cat /proc/net/softnet_stat', 'cat /proc/net/softnet_stat')
+
+
+def show_running_processes() -> None:
+ """Prints current running processes"""
+ execute_command('ps -ef', 'Running Processes')
+
+
+def show_memory_usage() -> None:
+ """Prints memory usage"""
+ execute_command('/usr/bin/true', 'Memory')
+ execute_command('cat /proc/meminfo', 'Installed Memory')
+ execute_command('free', 'Memory Usage')
+
+
+def list_disks():
+ disks = set()
+ with open('/proc/partitions') as partitions_file:
+ for line in partitions_file:
+ fields = line.strip().split()
+ if len(fields) == 4 and fields[3].isalpha() and fields[3] != 'name':
+ disks.add(fields[3])
+ return disks
+
+
+def show_storage() -> None:
+ """Prints storage information."""
+ execute_command('cat /proc/devices', 'Devices')
+ execute_command('cat /proc/partitions', 'Partitions')
+
+ for disk in list_disks():
+ execute_command(f'fdisk --list /dev/{disk}', f'Partitioning for disk {disk}')
+
+
+def main():
+ # Configuration data
+ show_version()
+ show_config_file()
+ show_running_config()
+ show_package_repository_config()
+ show_user_startup_scripts()
+ show_frr_config()
+
+ # Interfaces
+ show_interfaces()
+ show_interface_statistics()
+ show_physical_interface_statistics()
+ show_bridge()
+ show_arp()
+
+ # Routing
+ show_route()
+
+ # Firewall
+ show_firewall()
+
+ # System
+ show_system()
+ show_date()
+ show_installed_packages()
+ show_loaded_modules()
+
+ # CPU
+ show_cpu_statistics()
+ show_system_interrupts()
+ show_soft_irqs()
+ show_softnet_statistics()
+
+ # Memory
+ show_memory_usage()
+
+ # Storage
+ show_storage()
+
+ # Processes
+ show_running_processes()
+
+ # TODO: Get information from clouds
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/op_mode/show_usb_serial.py b/src/op_mode/show_usb_serial.py
new file mode 100644
index 0000000..973bf19
--- /dev/null
+++ b/src/op_mode/show_usb_serial.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018 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
+
+from jinja2 import Template
+from pyudev import Context, Devices
+from sys import exit
+
+OUT_TMPL_SRC = """Device Model Vendor
+------ ------ ------
+{% for d in devices %}
+{{ "%-16s" | format(d.device) }} {{ "%-19s" | format(d.model)}} {{d.vendor}}
+{% endfor %}
+
+"""
+
+data = {
+ 'devices': []
+}
+
+
+base_directory = '/dev/serial/by-bus'
+if not os.path.isdir(base_directory):
+ print("No USB to serial converter connected")
+ exit(0)
+
+context = Context()
+for root, dirs, files in os.walk(base_directory):
+ for basename in files:
+ os.path.join(root, basename)
+ device = Devices.from_device_file(context, os.path.join(root, basename))
+ tmp = {
+ 'device': basename,
+ 'model': device.properties.get('ID_MODEL'),
+ 'vendor': device.properties.get('ID_VENDOR_FROM_DATABASE')
+ }
+ data['devices'].append(tmp)
+
+data['devices'] = sorted(data['devices'], key = lambda i: i['device'])
+tmpl = Template(OUT_TMPL_SRC)
+print(tmpl.render(data))
+
+exit(0)
diff --git a/src/op_mode/show_users.py b/src/op_mode/show_users.py
new file mode 100644
index 0000000..82bd585
--- /dev/null
+++ b/src/op_mode/show_users.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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 argparse
+import pwd
+import struct
+import sys
+from time import ctime
+
+from tabulate import tabulate
+from vyos.config import Config
+
+
+class UserInfo:
+ def __init__(self, uid, name, user_type, is_locked, login_time, tty, host):
+ self.uid = uid
+ self.name = name
+ self.user_type = user_type
+ self.is_locked = is_locked
+ self.login_time = login_time
+ self.tty = tty
+ self.host = host
+
+
+filters = {
+ 'default': lambda user: not user.is_locked, # Default is everything but locked accounts
+ 'vyos': lambda user: user.user_type == 'vyos',
+ 'other': lambda user: user.user_type != 'vyos',
+ 'locked': lambda user: user.is_locked,
+ 'all': lambda user: True
+}
+
+
+def is_locked(user_name: str) -> bool:
+ """Check if a given user has password in shadow db"""
+
+ try:
+ import warnings
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore",category=DeprecationWarning)
+ import spwd
+ encrypted_password = spwd.getspnam(user_name)[1]
+ return encrypted_password == '*' or encrypted_password.startswith('!')
+ except (KeyError, PermissionError):
+ print('Cannot access shadow database, ensure this script is run with sufficient permissions')
+ sys.exit(1)
+
+
+def decode_lastlog(lastlog_file, uid: int):
+ """Decode last login info of a given user uid from the lastlog file"""
+
+ struct_fmt = '=L32s256s'
+ recordsize = struct.calcsize(struct_fmt)
+ lastlog_file.seek(recordsize * uid)
+ buf = lastlog_file.read(recordsize)
+ if len(buf) < recordsize:
+ return None
+ (time, tty, host) = struct.unpack(struct_fmt, buf)
+ time = 'never logged in' if time == 0 else ctime(time)
+ tty = tty.strip(b'\x00')
+ host = host.strip(b'\x00')
+ return time, tty, host
+
+
+def list_users():
+ cfg = Config()
+ vyos_users = cfg.list_effective_nodes('system login user')
+ users = []
+ with open('/var/log/lastlog', 'rb') as lastlog_file:
+ for (name, _, uid, _, _, _, _) in pwd.getpwall():
+ lastlog_info = decode_lastlog(lastlog_file, uid)
+ if lastlog_info is None:
+ continue
+ user_info = UserInfo(
+ uid, name,
+ user_type='vyos' if name in vyos_users else 'other',
+ is_locked=is_locked(name),
+ login_time=lastlog_info[0],
+ tty=lastlog_info[1],
+ host=lastlog_info[2])
+ users.append(user_info)
+ return users
+
+
+def main():
+ parser = argparse.ArgumentParser(prog=sys.argv[0], add_help=False)
+ parser.add_argument('type', nargs='?', choices=['all', 'vyos', 'other', 'locked'])
+ args = parser.parse_args()
+
+ filter_type = args.type if args.type is not None else 'default'
+ filter_expr = filters[filter_type]
+
+ headers = ['Username', 'Type', 'Locked', 'Tty', 'From', 'Last login']
+ table_data = []
+ for user in list_users():
+ if filter_expr(user):
+ table_data.append([user.name, user.user_type, user.is_locked, user.tty, user.host, user.login_time])
+ print(tabulate(table_data, headers, tablefmt='simple'))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/op_mode/show_virtual_server.py b/src/op_mode/show_virtual_server.py
new file mode 100644
index 0000000..7880edc
--- /dev/null
+++ b/src/op_mode/show_virtual_server.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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/>.
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import call
+
+def is_configured():
+ """ Check if high-availability virtual-server is configured """
+ config = ConfigTreeQuery()
+ if not config.exists(['high-availability', 'virtual-server']):
+ return False
+ return True
+
+if __name__ == '__main__':
+
+ if is_configured() == False:
+ print('Virtual server not configured!')
+ exit(0)
+
+ call('sudo ipvsadm --list --numeric')
diff --git a/src/op_mode/show_wwan.py b/src/op_mode/show_wwan.py
new file mode 100644
index 0000000..bd97bb0
--- /dev/null
+++ b/src/op_mode/show_wwan.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+
+from sys import exit
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--model", help="Get module model", action="store_true")
+parser.add_argument("--revision", help="Get module revision", action="store_true")
+parser.add_argument("--capabilities", help="Get module capabilities", action="store_true")
+parser.add_argument("--imei", help="Get module IMEI/ESN/MEID", action="store_true")
+parser.add_argument("--imsi", help="Get module IMSI", action="store_true")
+parser.add_argument("--msisdn", help="Get module MSISDN", action="store_true")
+parser.add_argument("--sim", help="Get SIM card status", action="store_true")
+parser.add_argument("--signal", help="Get current RF signal info", action="store_true")
+parser.add_argument("--firmware", help="Get current RF signal info", action="store_true")
+
+required = parser.add_argument_group('Required arguments')
+required.add_argument("--interface", help="WWAN interface name, e.g. wwan0", required=True)
+
+def qmi_cmd(device, command, silent=False):
+ try:
+ tmp = cmd(f'qmicli --device={device} --device-open-proxy {command}')
+ tmp = tmp.replace(f'[{cdc}] ', '')
+ if not silent:
+ # skip first line as this only holds the info headline
+ for line in tmp.splitlines()[1:]:
+ print(line.lstrip())
+ return tmp
+ except:
+ print('Command not supported by Modem')
+ exit(1)
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+
+ tmp = ConfigTreeQuery()
+ if not tmp.exists(['interfaces', 'wwan', args.interface]):
+ print(f'Interface "{args.interface}" unconfigured!')
+ exit(1)
+
+ # remove the WWAN prefix from the interface, required for the CDC interface
+ if_num = args.interface.replace('wwan','')
+ cdc = f'/dev/cdc-wdm{if_num}'
+
+ if args.model:
+ qmi_cmd(cdc, '--dms-get-model')
+ elif args.capabilities:
+ qmi_cmd(cdc, '--dms-get-capabilities')
+ qmi_cmd(cdc, '--dms-get-band-capabilities')
+ elif args.revision:
+ qmi_cmd(cdc, '--dms-get-revision')
+ elif args.imei:
+ qmi_cmd(cdc, '--dms-get-ids')
+ elif args.imsi:
+ qmi_cmd(cdc, '--dms-uim-get-imsi')
+ elif args.msisdn:
+ qmi_cmd(cdc, '--dms-get-msisdn')
+ elif args.sim:
+ qmi_cmd(cdc, '--uim-get-card-status')
+ elif args.signal:
+ qmi_cmd(cdc, '--nas-get-signal-info')
+ qmi_cmd(cdc, '--nas-get-rf-band-info')
+ elif args.firmware:
+ tmp = qmi_cmd(cdc, '--dms-get-manufacturer', silent=True)
+ if 'Sierra Wireless' in tmp:
+ qmi_cmd(cdc, '--dms-swi-get-current-firmware')
+ else:
+ qmi_cmd(cdc, '--dms-get-software-version')
+ else:
+ parser.print_help()
+ exit(1)
diff --git a/src/op_mode/snmp.py b/src/op_mode/snmp.py
new file mode 100644
index 0000000..3d6cd22
--- /dev/null
+++ b/src/op_mode/snmp.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 sys
+import argparse
+
+from vyos.config import Config
+from vyos.utils.process import call
+
+config_file_daemon = r'/etc/snmp/snmpd.conf'
+
+parser = argparse.ArgumentParser(description='Retrieve infomration from running SNMP daemon')
+parser.add_argument('--allowed', action="store_true", help='Show available SNMP communities')
+parser.add_argument('--community', action="store", help='Show status of given SNMP community', type=str)
+parser.add_argument('--host', action="store", help='SNMP host to connect to', type=str, default='localhost')
+
+config = {
+ 'communities': [],
+}
+
+def read_config():
+ with open(config_file_daemon, 'r') as f:
+ for line in f:
+ # Only get configured SNMP communitie
+ if line.startswith('rocommunity') or line.startswith('rwcommunity'):
+ string = line.split(' ')
+ # append community to the output list only once
+ c = string[1]
+ if c not in config['communities']:
+ config['communities'].append(c)
+
+def show_all():
+ if len(config['communities']) > 0:
+ print(' '.join(config['communities']))
+
+def show_community(c, h):
+ print('Status of SNMP community {0} on {1}'.format(c, h), flush=True)
+ call('/usr/bin/snmpstatus -t1 -v1 -c {0} {1}'.format(c, h))
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+
+ # Do nothing if service is not configured
+ c = Config()
+ if not c.exists_effective('service snmp'):
+ print("SNMP service is not configured")
+ sys.exit(0)
+
+ read_config()
+
+ if args.allowed:
+ show_all()
+ sys.exit(1)
+ elif args.community:
+ show_community(args.community, args.host)
+ sys.exit(1)
+ else:
+ parser.print_help()
+ sys.exit(1)
diff --git a/src/op_mode/snmp_ifmib.py b/src/op_mode/snmp_ifmib.py
new file mode 100644
index 0000000..c71feba
--- /dev/null
+++ b/src/op_mode/snmp_ifmib.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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/>.
+#
+# File: snmp_ifmib.py
+# Purpose:
+# Show SNMP MIB information
+# Used by the "run show snmp mib" commands.
+
+import sys
+import argparse
+import netifaces
+
+from vyos.config import Config
+from vyos.utils.process import popen
+
+parser = argparse.ArgumentParser(description='Retrieve SNMP interfaces information')
+parser.add_argument('--ifindex', action='store', nargs='?', const='all', help='Show interface index')
+parser.add_argument('--ifalias', action='store', nargs='?', const='all', help='Show interface aliase')
+parser.add_argument('--ifdescr', action='store', nargs='?', const='all', help='Show interface description')
+
+def show_ifindex(intf):
+ out, err = popen(f'/bin/ip link show {intf}', decode='utf-8')
+ index = 'ifIndex = ' + out.split(':')[0]
+ return index.replace('\n', '')
+
+def show_ifalias(intf):
+ out, err = popen(f'/bin/ip link show {intf}', decode='utf-8')
+ alias = out.split('alias')[1].lstrip() if 'alias' in out else intf
+ return 'ifAlias = ' + alias.replace('\n', '')
+
+def show_ifdescr(i):
+ ven_id = ''
+ dev_id = ''
+
+ try:
+ with open(r'/sys/class/net/' + i + '/device/vendor', 'r') as f:
+ ven_id = f.read().replace('\n', '')
+ except FileNotFoundError:
+ pass
+
+ try:
+ with open(r'/sys/class/net/' + i + '/device/device', 'r') as f:
+ dev_id = f.read().replace('\n', '')
+ except FileNotFoundError:
+ pass
+
+ if ven_id == '' and dev_id == '':
+ ret = 'ifDescr = {0}'.format(i)
+ return ret
+
+ device = str(ven_id) + ':' + str(dev_id)
+ out, err = popen(f'/usr/bin/lspci -mm -d {device}', decode='utf-8')
+
+ vendor = ""
+ device = ""
+
+ # convert output to string
+ string = out.split('"')
+ if len(string) > 3:
+ vendor = string[3]
+
+ if len(string) > 5:
+ device = string[5]
+
+ ret = 'ifDescr = {0} {1}'.format(vendor, device)
+ return ret.replace('\n', '')
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+
+ # Do nothing if service is not configured
+ c = Config()
+ if not c.exists_effective('service snmp'):
+ print("SNMP service is not configured")
+ sys.exit(0)
+
+ if args.ifindex:
+ if args.ifindex == 'all':
+ for i in netifaces.interfaces():
+ print('{0}: {1}'.format(i, show_ifindex(i)))
+ else:
+ print('{0}: {1}'.format(args.ifindex, show_ifindex(args.ifindex)))
+
+ elif args.ifalias:
+ if args.ifalias == 'all':
+ for i in netifaces.interfaces():
+ print('{0}: {1}'.format(i, show_ifalias(i)))
+ else:
+ print('{0}: {1}'.format(args.ifalias, show_ifalias(args.ifalias)))
+
+ elif args.ifdescr:
+ if args.ifdescr == 'all':
+ for i in netifaces.interfaces():
+ print('{0}: {1}'.format(i, show_ifdescr(i)))
+ else:
+ print('{0}: {1}'.format(args.ifdescr, show_ifdescr(args.ifdescr)))
+
+ else:
+ #eth0: ifIndex = 2
+ # ifAlias = NET-MYBLL-MUCI-BACKBONE
+ # ifDescr = VMware VMXNET3 Ethernet Controller
+ #lo: ifIndex = 1
+ for i in netifaces.interfaces():
+ print('{0}:\t{1}'.format(i, show_ifindex(i)))
+ print('\t{0}'.format(show_ifalias(i)))
+ print('\t{0}'.format(show_ifdescr(i)))
+
+ sys.exit(1)
diff --git a/src/op_mode/snmp_v3.py b/src/op_mode/snmp_v3.py
new file mode 100644
index 0000000..abeb524
--- /dev/null
+++ b/src/op_mode/snmp_v3.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018 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/>.
+#
+# File: snmp_v3.py
+# Purpose:
+# Show SNMP v3 information
+# Used by the "run show snmp v3" commands.
+
+import sys
+import jinja2
+import argparse
+
+from vyos.config import Config
+
+parser = argparse.ArgumentParser(description='Retrieve SNMP v3 information')
+parser.add_argument('--all', action="store_true", help='Show all available information')
+parser.add_argument('--group', action="store_true", help='Show the list of configured groups')
+parser.add_argument('--trap', action="store_true", help='Show the list of configured targets')
+parser.add_argument('--user', action="store_true", help='Show the list of configured users')
+parser.add_argument('--view', action="store_true", help='Show the list of configured views')
+
+GROUP_OUTP_TMPL_SRC = """
+SNMPv3 Groups:
+
+ Group View
+ ----- ----
+ {% if group %}{% for g in group %}
+ {{ "%-20s" | format(g.name) }}{{ g.view }}({{ g.mode }})
+ {% endfor %}{% endif %}
+"""
+
+TRAPTGT_OUTP_TMPL_SRC = """
+SNMPv3 Trap-targets:
+
+ Tpap-target Port Protocol Auth Priv Type EngineID User
+ ----------- ---- -------- ---- ---- ---- -------- ----
+ {% if trap %}{% for t in trap %}
+ {{ "%-20s" | format(t.name) }} {{ t.port }} {{ t.proto }} {{ t.auth }} {{ t.priv }} {{ t.type }} {{ "%-32s" | format(t.engID) }} {{ t.user }}
+ {% endfor %}{% endif %}
+"""
+
+USER_OUTP_TMPL_SRC = """
+SNMPv3 Users:
+
+ User Auth Priv Mode Group
+ ---- ---- ---- ---- -----
+ {% if user %}{% for u in user %}
+ {{ "%-20s" | format(u.name) }}{{ u.auth }} {{ u.priv }} {{ u.mode }} {{ u.group }}
+ {% endfor %}{% endif %}
+"""
+
+VIEW_OUTP_TMPL_SRC = """
+SNMPv3 Views:
+ {% if view %}{% for v in view %}
+ View : {{ v.name }}
+ OIDs : .{{ v.oids | join("\n .")}}
+ {% endfor %}{% endif %}
+"""
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+
+ # Do nothing if service is not configured
+ c = Config()
+ if not c.exists_effective('service snmp v3'):
+ print("SNMP v3 is not configured")
+ sys.exit(0)
+
+ data = {
+ 'group': [],
+ 'trap': [],
+ 'user': [],
+ 'view': []
+ }
+
+ if c.exists_effective('service snmp v3 group'):
+ for g in c.list_effective_nodes('service snmp v3 group'):
+ group = {
+ 'name': g,
+ 'mode': '',
+ 'view': ''
+ }
+ group['mode'] = c.return_effective_value('service snmp v3 group {0} mode'.format(g))
+ group['view'] = c.return_effective_value('service snmp v3 group {0} view'.format(g))
+
+ data['group'].append(group)
+
+ if c.exists_effective('service snmp v3 user'):
+ for u in c.list_effective_nodes('service snmp v3 user'):
+ user = {
+ 'name' : u,
+ 'mode' : '',
+ 'auth' : '',
+ 'priv' : '',
+ 'group': ''
+ }
+ user['mode'] = c.return_effective_value('service snmp v3 user {0} mode'.format(u))
+ user['auth'] = c.return_effective_value('service snmp v3 user {0} auth type'.format(u))
+ user['priv'] = c.return_effective_value('service snmp v3 user {0} privacy type'.format(u))
+ user['group'] = c.return_effective_value('service snmp v3 user {0} group'.format(u))
+
+ data['user'].append(user)
+
+ if c.exists_effective('service snmp v3 view'):
+ for v in c.list_effective_nodes('service snmp v3 view'):
+ view = {
+ 'name': v,
+ 'oids': []
+ }
+ view['oids'] = c.list_effective_nodes('service snmp v3 view {0} oid'.format(v))
+
+ data['view'].append(view)
+
+ if c.exists_effective('service snmp v3 trap-target'):
+ for t in c.list_effective_nodes('service snmp v3 trap-target'):
+ trap = {
+ 'name' : t,
+ 'port' : '',
+ 'proto': '',
+ 'auth' : '',
+ 'priv' : '',
+ 'type' : '',
+ 'engID': '',
+ 'user' : ''
+ }
+ trap['port'] = c.return_effective_value('service snmp v3 trap-target {0} port'.format(t))
+ trap['proto'] = c.return_effective_value('service snmp v3 trap-target {0} protocol'.format(t))
+ trap['auth'] = c.return_effective_value('service snmp v3 trap-target {0} auth type'.format(t))
+ trap['priv'] = c.return_effective_value('service snmp v3 trap-target {0} privacy type'.format(t))
+ trap['type'] = c.return_effective_value('service snmp v3 trap-target {0} type'.format(t))
+ trap['engID'] = c.return_effective_value('service snmp v3 trap-target {0} engineid'.format(t))
+ trap['user'] = c.return_effective_value('service snmp v3 trap-target {0} user'.format(t))
+
+ data['trap'].append(trap)
+
+ if args.all:
+ # Special case, print all templates !
+ tmpl = jinja2.Template(GROUP_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+ tmpl = jinja2.Template(TRAPTGT_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+ tmpl = jinja2.Template(USER_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+ tmpl = jinja2.Template(VIEW_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+
+ elif args.group:
+ tmpl = jinja2.Template(GROUP_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+
+ elif args.trap:
+ tmpl = jinja2.Template(TRAPTGT_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+
+ elif args.user:
+ tmpl = jinja2.Template(USER_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+
+ elif args.view:
+ tmpl = jinja2.Template(VIEW_OUTP_TMPL_SRC)
+ print(tmpl.render(data))
+
+ else:
+ parser.print_help()
+
+ sys.exit(1)
diff --git a/src/op_mode/snmp_v3_showcerts.sh b/src/op_mode/snmp_v3_showcerts.sh
new file mode 100644
index 0000000..015b2e6
--- /dev/null
+++ b/src/op_mode/snmp_v3_showcerts.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+files=`sudo ls /etc/snmp/tls/certs/ 2> /dev/null`;
+if [ -n "$files" ]; then
+ sudo /usr/bin/net-snmp-cert showcerts --subject --fingerprint
+else
+ echo "You don't have any certificates. Put it in '/etc/snmp/tls/certs/' folder."
+fi
diff --git a/src/op_mode/ssh.py b/src/op_mode/ssh.py
new file mode 100644
index 0000000..0c51576
--- /dev/null
+++ b/src/op_mode/ssh.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+#
+# Copyright 2017-2023 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/>.
+
+import json
+import sys
+import glob
+import vyos.opmode
+from vyos.utils.process import cmd
+from vyos.configquery import ConfigTreeQuery
+from tabulate import tabulate
+
+def show_fingerprints(raw: bool, ascii: bool):
+ config = ConfigTreeQuery()
+ if not config.exists("service ssh"):
+ raise vyos.opmode.UnconfiguredSubsystem("SSH server is not enabled.")
+
+ publickeys = glob.glob("/etc/ssh/*.pub")
+
+ if publickeys:
+ keys = []
+ for keyfile in publickeys:
+ try:
+ if ascii:
+ keydata = cmd("ssh-keygen -l -v -E sha256 -f " + keyfile).splitlines()
+ else:
+ keydata = cmd("ssh-keygen -l -E sha256 -f " + keyfile).splitlines()
+ type = keydata[0].split(None)[-1].strip("()")
+ key_size = keydata[0].split(None)[0]
+ fingerprint = keydata[0].split(None)[1]
+ comment = keydata[0].split(None)[2:-1][0]
+ if ascii:
+ ascii_art = "\n".join(keydata[1:])
+ keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment, "ascii_art": ascii_art})
+ else:
+ keys.append({"type": type, "key_size": key_size, "fingerprint": fingerprint, "comment": comment})
+ except:
+ # Ignore invalid public keys
+ pass
+ if raw:
+ return keys
+ else:
+ headers = {"type": "Type", "key_size": "Key Size", "fingerprint": "Fingerprint", "comment": "Comment", "ascii_art": "ASCII Art"}
+ output = "SSH server public key fingerprints:\n\n" + tabulate(keys, headers=headers, tablefmt="simple")
+ return output
+ else:
+ if raw:
+ return []
+ else:
+ return "No SSH server public keys are found."
+
+def show_dynamic_protection(raw: bool):
+ config = ConfigTreeQuery()
+ if not config.exists(['service', 'ssh', 'dynamic-protection']):
+ raise vyos.opmode.UnconfiguredObject("SSH server dynamic-protection is not enabled.")
+
+ attackers = []
+ try:
+ # IPv4
+ attackers = attackers + json.loads(cmd("nft -j list set ip sshguard attackers"))["nftables"][1]["set"]["elem"]
+ except:
+ pass
+ try:
+ # IPv6
+ attackers = attackers + json.loads(cmd("nft -j list set ip6 sshguard attackers"))["nftables"][1]["set"]["elem"]
+ except:
+ pass
+ if attackers:
+ if raw:
+ return attackers
+ else:
+ output = "Blocked attackers:\n" + "\n".join(attackers)
+ return output
+ else:
+ if raw:
+ return []
+ else:
+ return "No blocked attackers."
+
+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/storage.py b/src/op_mode/storage.py
new file mode 100644
index 0000000..8fd2ffe
--- /dev/null
+++ b/src/op_mode/storage.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 sys
+
+import vyos.opmode
+
+from jinja2 import Template
+
+output_tmpl = """
+Filesystem: {{filesystem}}
+Size: {{size}}
+Used: {{used}} ({{use_percentage}}%)
+Available: {{avail}} ({{avail_percentage}}%)
+"""
+
+def _get_formatted_output():
+ return _get_system_storage()
+
+def show(raw: bool):
+ from vyos.utils.disk import get_persistent_storage_stats
+
+ if raw:
+ res = get_persistent_storage_stats(human_units=False)
+ if res is None:
+ raise vyos.opmode.DataUnavailable("Storage statistics are not available")
+ else:
+ return res
+ else:
+ data = get_persistent_storage_stats(human_units=True)
+ if data is None:
+ return "Storage statistics are not available"
+ else:
+ data["avail_percentage"] = 100 - int(data["use_percentage"])
+ tmpl = Template(output_tmpl)
+ return tmpl.render(data).strip()
+
+ return output
+
+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/system.py b/src/op_mode/system.py
new file mode 100644
index 0000000..854b4b6
--- /dev/null
+++ b/src/op_mode/system.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 jmespath
+import sys
+
+from vyos.configquery import ConfigTreeQuery
+
+import vyos.opmode
+import vyos.version
+
+config = ConfigTreeQuery()
+base = ['system', 'update-check']
+
+
+def _compare_version_raw():
+ url = config.value(base + ['url'])
+ local_data = vyos.version.get_full_version_data()
+ remote_data = vyos.version.get_remote_version(url)
+ if not remote_data:
+ return {"error": True,
+ "reason": "Unable to get remote version"}
+ if local_data.get('version') and remote_data:
+ local_version = local_data.get('version')
+ remote_version = jmespath.search('[0].version', remote_data)
+ image_url = jmespath.search('[0].url', remote_data)
+ if local_data.get('version') != remote_version:
+ return {"error": False,
+ "update_available": True,
+ "local_version": local_version,
+ "remote_version": remote_version,
+ "url": image_url}
+ return {"update_available": False,
+ "local_version": local_version,
+ "remote_version": remote_version}
+
+
+def _formatted_compare_version(data):
+ local_version = data.get('local_version')
+ remote_version = data.get('remote_version')
+ url = data.get('url')
+ if {'update_available','local_version', 'remote_version', 'url'} <= set(data):
+ return f'Current version: {local_version}\n\nUpdate available: {remote_version}\nUpdate URL: {url}'
+ elif local_version == remote_version and remote_version is not None:
+ return f'No available updates for your system \n' \
+ f'current version: {local_version}\nremote version: {remote_version}'
+ else:
+ return 'Update not found'
+
+
+def _verify():
+ if not config.exists(base):
+ return False
+ return True
+
+
+def show_update(raw: bool):
+ if not _verify():
+ raise vyos.opmode.UnconfiguredSubsystem("system update-check not configured")
+ data = _compare_version_raw()
+ if raw:
+ return data
+ else:
+ return _formatted_compare_version(data)
+
+
+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/tcpdump.py b/src/op_mode/tcpdump.py
new file mode 100644
index 0000000..607b596
--- /dev/null
+++ b/src/op_mode/tcpdump.py
@@ -0,0 +1,165 @@
+#! /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 sys
+
+from vyos.utils.process import call
+
+options = {
+ 'dump': {
+ 'cmd': '{command} -A',
+ 'type': 'noarg',
+ 'help': 'Print each packet (minus its link level header) in ASCII.'
+ },
+ 'hexdump': {
+ 'cmd': '{command} -X',
+ 'type': 'noarg',
+ 'help': 'Print each packet (minus its link level header) in both hex and ASCII.'
+ },
+ 'filter': {
+ 'cmd': '{command} \'{value}\'',
+ 'type': '<pcap-filter>',
+ 'help': 'Match traffic for capture and display with a pcap-filter expression.'
+ },
+ 'numeric': {
+ 'cmd': '{command} -nn',
+ 'type': 'noarg',
+ 'help': 'Do not attempt to resolve addresses, protocols or services to names.'
+ },
+ 'save': {
+ 'cmd': '{command} -w {value}',
+ 'type': '<file>',
+ 'help': 'Write captured raw packets to <file> rather than parsing or printing them out.'
+ },
+ 'verbose': {
+ 'cmd': '{command} -vvv -ne',
+ 'type': 'noarg',
+ 'help': 'Parse packets with increased detail output, including link-level headers and extended decoding protocol sanity checks.'
+ },
+}
+
+tcpdump = 'sudo /usr/bin/tcpdump'
+
+class List(list):
+ def first(self):
+ return self.pop(0) if self else ''
+
+ def last(self):
+ return self.pop() if self else ''
+
+ def prepend(self, value):
+ self.insert(0, value)
+
+
+def completion_failure(option: str) -> None:
+ """
+ Shows failure message after TAB when option is wrong
+ :param option: failure option
+ :type str:
+ """
+ sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option))
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def expansion_failure(option, completions):
+ reason = 'Ambiguous' if completions else 'Invalid'
+ sys.stderr.write(
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
+ option))
+ if completions:
+ sys.stderr.write(' Possible completions:\n ')
+ sys.stderr.write('\n '.join(completions))
+ sys.stderr.write('\n')
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def complete(prefix):
+ return [o for o in options if o.startswith(prefix)]
+
+
+def convert(command, args):
+ while args:
+ shortname = args.first()
+ longnames = complete(shortname)
+ if len(longnames) != 1:
+ expansion_failure(shortname, longnames)
+ longname = longnames[0]
+ if options[longname]['type'] == 'noarg':
+ command = options[longname]['cmd'].format(
+ command=command, value='')
+ elif not args:
+ sys.exit(f'monitor traffic: missing argument for {longname} option')
+ else:
+ command = options[longname]['cmd'].format(
+ command=command, value=args.first())
+ return command
+
+
+if __name__ == '__main__':
+ args = List(sys.argv[1:])
+ ifname = args.first()
+
+ # Slightly simplified & tweaked version of the code from mtr.py - it may be
+ # worthwhile to combine and centralise this in a common module.
+ if ifname == '--get-options-nested':
+ args.first() # pop monitor
+ args.first() # pop traffic
+ args.first() # pop interface
+ args.first() # pop <ifname>
+ usedoptionslist = []
+ while args:
+ option = args.first() # pop option
+ matched = complete(option) # get option parameters
+ usedoptionslist.append(option) # list of used options
+ # Select options
+ if not args:
+ # remove from Possible completions used options
+ for o in usedoptionslist:
+ if o in matched:
+ matched.remove(o)
+ if not matched:
+ sys.stdout.write('<nocomps>')
+ else:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+
+ if len(matched) > 1:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+ # If option doesn't have value
+ if matched:
+ if options[matched[0]]['type'] == 'noarg':
+ continue
+ else:
+ # Unexpected option
+ completion_failure(option)
+
+ value = args.first() # pop option's value
+ if not args:
+ matched = complete(option)
+ helplines = options[matched[0]]['type']
+ # Run helpfunction to get list of possible values
+ if 'helpfunction' in options[matched[0]]:
+ result = options[matched[0]]['helpfunction']()
+ if result:
+ helplines = '\n' + ' '.join(result)
+ sys.stdout.write(helplines)
+ sys.exit(0)
+
+ command = convert(tcpdump, args)
+ call(f'{command} -i {ifname}')
diff --git a/src/op_mode/tech_support.py b/src/op_mode/tech_support.py
new file mode 100644
index 0000000..f60bb87
--- /dev/null
+++ b/src/op_mode/tech_support.py
@@ -0,0 +1,394 @@
+#!/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 sys
+import json
+
+import vyos.opmode
+
+from vyos.utils.process import cmd
+
+def _get_version_data():
+ from vyos.version import get_version_data
+ return get_version_data()
+
+def _get_uptime():
+ from vyos.utils.system import get_uptime_seconds
+
+ return get_uptime_seconds()
+
+def _get_load_average():
+ from vyos.utils.system import get_load_averages
+
+ return get_load_averages()
+
+def _get_cpus():
+ from vyos.utils.cpu import get_cpus
+
+ return get_cpus()
+
+def _get_process_stats():
+ return cmd('top --iterations 1 --batch-mode --accum-time-toggle')
+
+def _get_storage():
+ from vyos.utils.disk import get_persistent_storage_stats
+
+ return get_persistent_storage_stats()
+
+def _get_devices():
+ devices = {}
+ devices["pci"] = cmd("lspci")
+ devices["usb"] = cmd("lsusb")
+
+ return devices
+
+def _get_memory():
+ from vyos.utils.file import read_file
+
+ return read_file("/proc/meminfo")
+
+def _get_processes():
+ res = cmd("ps aux")
+
+ return res
+
+def _get_interrupts():
+ from vyos.utils.file import read_file
+
+ interrupts = read_file("/proc/interrupts")
+ softirqs = read_file("/proc/softirqs")
+
+ return (interrupts, softirqs)
+
+def _get_partitions():
+ # XXX: as of parted 3.5, --json is completely broken
+ # and cannot be used (outputs malformed JSON syntax)
+ res = cmd(f"parted --list")
+
+ return res
+
+def _get_running_config():
+ from os import getpid
+ from vyos.configsession import ConfigSession
+ from vyos.utils.strip_config import strip_config_source
+
+ c = ConfigSession(getpid())
+ return strip_config_source(c.show_config([]))
+
+def _get_boot_config():
+ from vyos.utils.file import read_file
+ from vyos.utils.strip_config import strip_config_source
+
+ config = read_file('/opt/vyatta/etc/config.boot.default')
+
+ return strip_config_source(config)
+
+def _get_config_scripts():
+ from os import listdir
+ from os.path import join
+ from vyos.utils.file import read_file
+
+ scripts = []
+
+ dir = '/config/scripts'
+ for f in listdir(dir):
+ script = {}
+ path = join(dir, f)
+ data = read_file(path)
+ script["path"] = path
+ script["data"] = data
+
+ scripts.append(script)
+
+ return scripts
+
+def _get_nic_data():
+ from vyos.utils.process import ip_cmd
+ link_data = ip_cmd("link show")
+ addr_data = ip_cmd("address show")
+
+ return link_data, addr_data
+
+def _get_routes(proto):
+ from json import loads
+ from vyos.utils.process import ip_cmd
+
+ # Only include complete routing tables if they are not too large
+ # At the moment "too large" is arbitrarily set to 1000
+ MAX_ROUTES = 1000
+
+ data = {}
+
+ summary = cmd(f"vtysh -c 'show {proto} route summary json'")
+ summary = loads(summary)
+
+ data["summary"] = summary
+
+ if summary["routesTotal"] < MAX_ROUTES:
+ rib_routes = cmd(f"vtysh -c 'show {proto} route json'")
+ data["routes"] = loads(rib_routes)
+
+ if summary["routesTotalFib"] < MAX_ROUTES:
+ ip_proto = "-4" if proto == "ip" else "-6"
+ fib_routes = ip_cmd(f"{ip_proto} route show")
+ data["fib_routes"] = fib_routes
+
+ return data
+
+def _get_ip_routes():
+ return _get_routes("ip")
+
+def _get_ipv6_routes():
+ return _get_routes("ipv6")
+
+def _get_ospfv2():
+ # XXX: OSPF output when it's not configured is an empty string,
+ # which is not a valid JSON
+ output = cmd("vtysh -c 'show ip ospf json'")
+ if output:
+ return json.loads(output)
+ else:
+ return {}
+
+def _get_ospfv3():
+ output = cmd("vtysh -c 'show ipv6 ospf6 json'")
+ if output:
+ return json.loads(output)
+ else:
+ return {}
+
+def _get_bgp_summary():
+ output = cmd("vtysh -c 'show bgp summary json'")
+ return json.loads(output)
+
+def _get_isis():
+ output = cmd("vtysh -c 'show isis summary json'")
+ if output:
+ return json.loads(output)
+ else:
+ return {}
+
+def _get_arp_table():
+ from json import loads
+ from vyos.utils.process import cmd
+
+ arp_table = cmd("ip --json -4 neighbor show")
+ return loads(arp_table)
+
+def _get_ndp_table():
+ from json import loads
+
+ arp_table = cmd("ip --json -6 neighbor show")
+ return loads(arp_table)
+
+def _get_nftables_rules():
+ nft_rules = cmd("nft list ruleset")
+ return nft_rules
+
+def _get_connections():
+ from vyos.utils.process import cmd
+
+ return cmd("ss -apO")
+
+def _get_system_packages():
+ from re import split
+ from vyos.utils.process import cmd
+
+ dpkg_out = cmd(''' dpkg-query -W -f='${Package} ${Version} ${Architecture} ${db:Status-Abbrev}\n' ''')
+ pkg_lines = split(r'\n+', dpkg_out)
+
+ # Discard the header, it's five lines long
+ pkg_lines = pkg_lines[5:]
+
+ pkgs = []
+
+ for pl in pkg_lines:
+ parts = split(r'\s+', pl)
+ pkg = {}
+ pkg["name"] = parts[0]
+ pkg["version"] = parts[1]
+ pkg["architecture"] = parts[2]
+ pkg["status"] = parts[3]
+
+ pkgs.append(pkg)
+
+ return pkgs
+
+def _get_image_info():
+ from vyos.system.image import get_images_details
+
+ return get_images_details()
+
+def _get_kernel_modules():
+ from vyos.utils.kernel import lsmod
+
+ return lsmod()
+
+def _get_last_logs(max):
+ from systemd import journal
+
+ r = journal.Reader()
+
+ # Set the reader to use logs from the current boot
+ r.this_boot()
+
+ # Jump to the last logs
+ r.seek_tail()
+
+ # Only get logs of INFO level or more urgent
+ r.log_level(journal.LOG_INFO)
+
+ # Retrieve the entries
+ entries = []
+
+ # I couldn't find a way to just get last/first N entries,
+ # so we'll use the cursor directly.
+ num = max
+ while num >= 0:
+ je = r.get_previous()
+ entry = {}
+
+ # Extract the most useful and serializable fields
+ entry["timestamp"] = je.get("SYSLOG_TIMESTAMP")
+ entry["pid"] = je.get("SYSLOG_PID")
+ entry["identifier"] = je.get("SYSLOG_IDENTIFIER")
+ entry["facility"] = je.get("SYSLOG_FACILITY")
+ entry["systemd_unit"] = je.get("_SYSTEMD_UNIT")
+ entry["message"] = je.get("MESSAGE")
+
+ entries.append(entry)
+
+ num = num - 1
+
+ return entries
+
+
+def _get_raw_data():
+ data = {}
+
+ # VyOS-specific information
+ data["vyos"] = {}
+
+ ## The equivalent of "show version"
+ from vyos.version import get_version_data
+ data["vyos"]["version"] = _get_version_data()
+
+ ## Installed images
+ data["vyos"]["images"] = _get_image_info()
+
+ # System information
+ data["system"] = {}
+
+ ## Uptime and load averages
+ data["system"]["uptime"] = _get_uptime()
+ data["system"]["load_average"] = _get_load_average()
+ data["system"]["process_stats"] = _get_process_stats()
+
+ ## Debian packages
+ data["system"]["packages"] = _get_system_packages()
+
+ ## Kernel modules
+ data["system"]["kernel"] = {}
+ data["system"]["kernel"]["modules"] = _get_kernel_modules()
+
+ ## Processes
+ data["system"]["processes"] = _get_processes()
+
+ ## Interrupts
+ interrupts, softirqs = _get_interrupts()
+ data["system"]["interrupts"] = interrupts
+ data["system"]["softirqs"] = softirqs
+
+ # Hardware
+ data["hardware"] = {}
+ data["hardware"]["cpu"] = _get_cpus()
+ data["hardware"]["storage"] = _get_storage()
+ data["hardware"]["partitions"] = _get_partitions()
+ data["hardware"]["devices"] = _get_devices()
+ data["hardware"]["memory"] = _get_memory()
+
+ # Configuration data
+ data["vyos"]["config"] = {}
+
+ ## Running config text
+ ## We do not encode it so that it's possible to
+ ## see exactly what the user sees and detect any syntax/rendering anomalies —
+ ## exporting the config to JSON could obscure them
+ data["vyos"]["config"]["running"] = _get_running_config()
+
+ ## Default boot config, exactly as in /config/config.boot
+ ## It may be different from the running config
+ ## _and_ may have its own syntax quirks that may point at bugs
+ data["vyos"]["config"]["boot"] = _get_boot_config()
+
+ ## Config scripts
+ data["vyos"]["config"]["scripts"] = _get_config_scripts()
+
+ # Network interfaces
+ data["network_interfaces"] = {}
+
+ # Interface data from iproute2
+ link_data, addr_data = _get_nic_data()
+ data["network_interfaces"]["links"] = link_data
+ data["network_interfaces"]["addresses"] = addr_data
+
+ # Routing table data
+ data["routing"] = {}
+ data["routing"]["ip"] = _get_ip_routes()
+ data["routing"]["ipv6"] = _get_ipv6_routes()
+
+ # Routing protocols
+ data["routing"]["ip"]["ospf"] = _get_ospfv2()
+ data["routing"]["ipv6"]["ospfv3"] = _get_ospfv3()
+
+ data["routing"]["bgp"] = {}
+ data["routing"]["bgp"]["summary"] = _get_bgp_summary()
+
+ data["routing"]["isis"] = _get_isis()
+
+ # ARP and NDP neighbor tables
+ data["neighbor_tables"] = {}
+ data["neighbor_tables"]["arp"] = _get_arp_table()
+ data["neighbor_tables"]["ndp"] = _get_ndp_table()
+
+ # nftables config
+ data["nftables_rules"] = _get_nftables_rules()
+
+ # All connections
+ data["connections"] = _get_connections()
+
+ # Logs
+ data["last_logs"] = _get_last_logs(1000)
+
+ return data
+
+def show(raw: bool):
+ data = _get_raw_data()
+ if raw:
+ return data
+ else:
+ raise vyos.opmode.UnsupportedOperation("Formatted output is not implemented yet")
+
+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)
+ except (KeyboardInterrupt, BrokenPipeError):
+ sys.exit(1)
diff --git a/src/op_mode/toggle_help_binding.sh b/src/op_mode/toggle_help_binding.sh
new file mode 100644
index 0000000..a8708f3
--- /dev/null
+++ b/src/op_mode/toggle_help_binding.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+#
+# Copyright (C) 2019 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/>.
+
+# Script for [un-]binding the question mark key for getting help
+if [ "$1" == 'disable' ]; then
+ sed -i "/^bind '\"?\": .* # vyatta key binding$/d" $HOME/.bashrc
+ echo "bind '\"?\": self-insert' # vyatta key binding" >> $HOME/.bashrc
+ bind '"?": self-insert'
+else
+ sed -i "/^bind '\"?\": .* # vyatta key binding$/d" $HOME/.bashrc
+ bind '"?": possible-completions'
+fi
diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py
new file mode 100644
index 0000000..d2bac3f
--- /dev/null
+++ b/src/op_mode/traceroute.py
@@ -0,0 +1,238 @@
+#! /usr/bin/env python3
+
+# Copyright (C) 2022 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 socket
+import ipaddress
+
+from vyos.utils.network import interface_list
+from vyos.utils.network import vrf_list
+from vyos.utils.process import call
+
+options = {
+ 'backward-hops': {
+ 'traceroute': '{command} --back',
+ 'type': 'noarg',
+ 'help': 'Display number of backward hops when they different from the forwarded path'
+ },
+ 'bypass': {
+ 'traceroute': '{command} -r',
+ 'type': 'noarg',
+ 'help': 'Bypass the normal routing tables and send directly to a host on an attached network'
+ },
+ 'do-not-fragment': {
+ 'traceroute': '{command} -F',
+ 'type': 'noarg',
+ 'help': 'Do not fragment probe packets.'
+ },
+ 'first-ttl': {
+ 'traceroute': '{command} -f {value}',
+ 'type': '<ttl>',
+ 'help': 'Specifies with what TTL to start. Defaults to 1.'
+ },
+ 'icmp': {
+ 'traceroute': '{command} -I',
+ 'type': 'noarg',
+ 'help': 'Use ICMP ECHO for tracerouting'
+ },
+ 'interface': {
+ 'traceroute': '{command} -i {value}',
+ 'type': '<interface>',
+ 'helpfunction': interface_list,
+ 'help': 'Source interface'
+ },
+ 'lookup-as': {
+ 'traceroute': '{command} -A',
+ 'type': 'noarg',
+ 'help': 'Perform AS path lookups'
+ },
+ 'mark': {
+ 'traceroute': '{command} --fwmark={value}',
+ 'type': '<fwmark>',
+ 'help': 'Set the firewall mark for outgoing packets'
+ },
+ 'no-resolve': {
+ 'traceroute': '{command} -n',
+ 'type': 'noarg',
+ 'help': 'Do not resolve hostnames'
+ },
+ 'port': {
+ 'traceroute': '{command} -p {value}',
+ 'type': '<port>',
+ 'help': 'Destination port'
+ },
+ 'source-address': {
+ 'traceroute': '{command} -s {value}',
+ 'type': '<x.x.x.x> <h:h:h:h:h:h:h:h>',
+ 'help': 'Specify source IP v4/v6 address'
+ },
+ 'tcp': {
+ 'traceroute': '{command} -T',
+ 'type': 'noarg',
+ 'help': 'Use TCP SYN for tracerouting (default port is 80)'
+ },
+ 'tos': {
+ 'traceroute': '{commad} -t {value}',
+ 'type': '<tos>',
+ 'help': 'Mark packets with specified TOS'
+ },
+ 'ttl': {
+ 'traceroute': '{command} -m {value}',
+ 'type': '<ttl>',
+ 'help': 'Maximum number of hops'
+ },
+ 'udp': {
+ 'traceroute': '{command} -U',
+ 'type': 'noarg',
+ 'help': 'Use UDP to particular port for tracerouting (default port is 53)'
+ },
+ 'vrf': {
+ 'traceroute': 'sudo ip vrf exec {value} {command}',
+ 'type': '<vrf>',
+ 'help': 'Use specified VRF table',
+ 'helpfunction': vrf_list,
+ 'dflt': 'default'}
+}
+
+traceroute = {
+ 4: '/bin/traceroute -4',
+ 6: '/bin/traceroute -6',
+}
+
+
+class List(list):
+ def first(self):
+ return self.pop(0) if self else ''
+
+ def last(self):
+ return self.pop() if self else ''
+
+ def prepend(self, value):
+ self.insert(0, value)
+
+
+def completion_failure(option: str) -> None:
+ """
+ Shows failure message after TAB when option is wrong
+ :param option: failure option
+ :type str:
+ """
+ sys.stderr.write('\n\n Invalid option: {}\n\n'.format(option))
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def expension_failure(option, completions):
+ reason = 'Ambiguous' if completions else 'Invalid'
+ sys.stderr.write(
+ '\n\n {} command: {} [{}]\n\n'.format(reason, ' '.join(sys.argv),
+ option))
+ if completions:
+ sys.stderr.write(' Possible completions:\n ')
+ sys.stderr.write('\n '.join(completions))
+ sys.stderr.write('\n')
+ sys.stdout.write('<nocomps>')
+ sys.exit(1)
+
+
+def complete(prefix):
+ return [o for o in options if o.startswith(prefix)]
+
+
+def convert(command, args):
+ while args:
+ shortname = args.first()
+ longnames = complete(shortname)
+ if len(longnames) != 1:
+ expension_failure(shortname, longnames)
+ longname = longnames[0]
+ if options[longname]['type'] == 'noarg':
+ command = options[longname]['traceroute'].format(
+ command=command, value='')
+ elif not args:
+ sys.exit(f'traceroute: missing argument for {longname} option')
+ else:
+ command = options[longname]['traceroute'].format(
+ command=command, value=args.first())
+ return command
+
+
+if __name__ == '__main__':
+ args = List(sys.argv[1:])
+ host = args.first()
+
+ if not host:
+ sys.exit("traceroute: Missing host")
+
+ if host == '--get-options':
+ args.first() # pop ping
+ args.first() # pop IP
+ usedoptionslist = []
+ while args:
+ option = args.first() # pop option
+ matched = complete(option) # get option parameters
+ usedoptionslist.append(option) # list of used options
+ # Select options
+ if not args:
+ # remove from Possible completions used options
+ for o in usedoptionslist:
+ if o in matched:
+ matched.remove(o)
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+
+ if len(matched) > 1:
+ sys.stdout.write(' '.join(matched))
+ sys.exit(0)
+ # If option doesn't have value
+ if matched:
+ if options[matched[0]]['type'] == 'noarg':
+ continue
+ else:
+ # Unexpected option
+ completion_failure(option)
+
+ value = args.first() # pop option's value
+ if not args:
+ matched = complete(option)
+ helplines = options[matched[0]]['type']
+ # Run helpfunction to get list of possible values
+ if 'helpfunction' in options[matched[0]]:
+ result = options[matched[0]]['helpfunction']()
+ if result:
+ helplines = '\n' + ' '.join(result)
+ sys.stdout.write(helplines)
+ sys.exit(0)
+
+ for name, option in options.items():
+ if 'dflt' in option and name not in args:
+ args.append(name)
+ args.append(option['dflt'])
+
+ try:
+ ip = socket.gethostbyname(host)
+ except UnicodeError:
+ sys.exit(f'tracroute: Unknown host: {host}')
+ except socket.gaierror:
+ ip = host
+
+ try:
+ version = ipaddress.ip_address(ip).version
+ except ValueError:
+ sys.exit(f'traceroute: Unknown host: {host}')
+
+ command = convert(traceroute[version], args)
+ call(f'{command} {host}')
diff --git a/src/op_mode/uptime.py b/src/op_mode/uptime.py
new file mode 100644
index 0000000..1c1a149
--- /dev/null
+++ b/src/op_mode/uptime.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 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 vyos.opmode
+
+def _get_raw_data():
+ from vyos.utils.system import get_uptime_seconds, get_load_averages
+ from vyos.utils.convert import seconds_to_human
+
+ res = {}
+ uptime_seconds = get_uptime_seconds()
+ res["uptime"] = seconds_to_human(uptime_seconds, separator=' ')
+ res["load_average"] = get_load_averages()
+
+ return res
+
+def _get_formatted_output(data):
+ out = "Uptime: {}\n\n".format(data["uptime"])
+ avgs = data["load_average"]
+ out += "Load averages:\n"
+ out += "1 minute: {:.01f}%\n".format(avgs[1]*100)
+ out += "5 minutes: {:.01f}%\n".format(avgs[5]*100)
+ out += "15 minutes: {:.01f}%\n".format(avgs[15]*100)
+
+ return out
+
+def show(raw: bool):
+ uptime_data = _get_raw_data()
+
+ if raw:
+ return uptime_data
+ else:
+ return _get_formatted_output(uptime_data)
+
+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/version.py b/src/op_mode/version.py
new file mode 100644
index 0000000..71a40dd
--- /dev/null
+++ b/src/op_mode/version.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2016-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/>.
+#
+# Purpose:
+# Displays image version and system information.
+# Used by the "run show version" command.
+
+import sys
+import typing
+
+import vyos.opmode
+import vyos.version
+import vyos.limericks
+
+from vyos.utils.boot import is_uefi_system
+from vyos.utils.system import get_secure_boot_state
+
+from jinja2 import Template
+
+version_output_tmpl = """
+Version: VyOS {{version}}
+Release train: {{release_train}}
+Release flavor: {{flavor}}
+
+Built by: {{built_by}}
+Built on: {{built_on}}
+Build UUID: {{build_uuid}}
+Build commit ID: {{build_git}}
+{%- if build_comment %}
+Build comment: {{build_comment}}
+{% endif %}
+
+Architecture: {{system_arch}}
+Boot via: {{boot_via}}
+System type: {{system_type}}
+Secure Boot: {{secure_boot}}
+
+Hardware vendor: {{hardware_vendor}}
+Hardware model: {{hardware_model}}
+Hardware S/N: {{hardware_serial}}
+Hardware UUID: {{hardware_uuid}}
+
+Copyright: VyOS maintainers and contributors
+{%- if limerick %}
+{{limerick}}
+{% endif -%}
+"""
+
+def _get_raw_data(funny=False):
+ version_data = vyos.version.get_full_version_data()
+ version_data["secure_boot"] = "n/a (BIOS)"
+ if is_uefi_system():
+ version_data["secure_boot"] = "disabled"
+ if get_secure_boot_state():
+ version_data["secure_boot"] = "enabled"
+
+ if funny:
+ version_data["limerick"] = vyos.limericks.get_random()
+
+ return version_data
+
+def _get_formatted_output(version_data):
+ tmpl = Template(version_output_tmpl)
+ return tmpl.render(version_data).strip()
+
+def show(raw: bool, funny: typing.Optional[bool]):
+ """ Display neighbor table contents """
+ version_data = _get_raw_data(funny=funny)
+
+ if raw:
+ return version_data
+ else:
+ return _get_formatted_output(version_data)
+
+
+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/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py
new file mode 100644
index 0000000..9385bcd
--- /dev/null
+++ b/src/op_mode/vpn_ike_sa.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+import sys
+import vici
+
+from vyos.utils.process import process_named_running
+
+ike_sa_peer_prefix = """\
+Peer ID / IP Local ID / IP
+------------ -------------"""
+
+ike_sa_tunnel_prefix = """
+
+ State IKEVer Encrypt Hash D-H Group NAT-T A-Time L-Time
+ ----- ------ ------- ---- --------- ----- ------ ------"""
+
+def s(byte_string):
+ return str(byte_string, 'utf-8')
+
+def ike_sa(peer, nat):
+ session = vici.Session()
+ sas = session.list_sas()
+ peers = []
+ for conn in sas:
+ for name, sa in conn.items():
+ if peer and s(sa['remote-host']) != peer:
+ continue
+ if name.startswith('peer_') and name in peers:
+ continue
+ if nat and 'nat-local' not in sa:
+ continue
+ peers.append(name)
+ remote_str = f'{s(sa["remote-host"])} {s(sa["remote-id"])}' if s(sa['remote-id']) != '%any' else s(sa["remote-host"])
+ local_str = f'{s(sa["local-host"])} {s(sa["local-id"])}' if s(sa['local-id']) != '%any' else s(sa["local-host"])
+ print(ike_sa_peer_prefix)
+ print('%-39s %-39s' % (remote_str, local_str))
+ state = 'up' if 'state' in sa and s(sa['state']) == 'ESTABLISHED' else 'down'
+ version = 'IKEv' + s(sa['version'])
+ encryption = f'{s(sa["encr-alg"])}' if 'encr-alg' in sa else 'n/a'
+ if 'encr-keysize' in sa:
+ encryption += '_' + s(sa["encr-keysize"])
+ integrity = s(sa['integ-alg']) if 'integ-alg' in sa else 'n/a'
+ dh_group = s(sa['dh-group']) if 'dh-group' in sa else 'n/a'
+ natt = 'yes' if 'nat-local' in sa and s(sa['nat-local']) == 'yes' else 'no'
+ atime = s(sa['established']) if 'established' in sa else '0'
+ ltime = s(sa['rekey-time']) if 'rekey-time' in sa else '0'
+ print(ike_sa_tunnel_prefix)
+ print(' %-6s %-6s %-12s %-13s %-14s %-6s %-7s %-7s\n' % (state, version, encryption, integrity, dh_group, natt, atime, ltime))
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--peer', help='Peer name', required=False)
+ parser.add_argument('--nat', help='NAT Traversal', required=False)
+
+ args = parser.parse_args()
+
+ if not process_named_running('charon-systemd'):
+ print("IPsec Process NOT Running")
+ sys.exit(0)
+
+ ike_sa(args.peer, args.nat)
diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py
new file mode 100644
index 0000000..ef89e60
--- /dev/null
+++ b/src/op_mode/vpn_ipsec.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-2022 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 re
+import argparse
+
+from vyos.utils.process import call
+
+SWANCTL_CONF = '/etc/swanctl/swanctl.conf'
+
+
+def get_peer_connections(peer, tunnel, return_all = False):
+ search = rf'^[\s]*(peer_{peer}_(tunnel_[\d]+|vti)).*'
+ matches = []
+ with open(SWANCTL_CONF, 'r') as f:
+ for line in f.readlines():
+ result = re.match(search, line)
+ if result:
+ suffix = f'tunnel_{tunnel}' if tunnel.isnumeric() else tunnel
+ if return_all or (result[2] == suffix):
+ matches.append(result[1])
+ return matches
+
+
+def debug_peer(peer, tunnel):
+ peer = peer.replace(':', '-')
+ if not peer or peer == "all":
+ debug_commands = [
+ "ipsec statusall",
+ "swanctl -L",
+ "swanctl -l",
+ "swanctl -P",
+ "ip x sa show",
+ "ip x policy show",
+ "ip tunnel show",
+ "ip address",
+ "ip rule show",
+ "ip route | head -100",
+ "ip route show table 220"
+ ]
+ for debug_cmd in debug_commands:
+ print(f'\n### {debug_cmd} ###')
+ call(debug_cmd)
+ return
+
+ if not tunnel or tunnel == 'all':
+ tunnel = ''
+
+ conns = get_peer_connections(peer, tunnel, return_all = (tunnel == '' or tunnel == 'all'))
+
+ if not conns:
+ print('Peer not found, aborting')
+ return
+
+ for conn in conns:
+ call(f'/usr/sbin/ipsec statusall | grep {conn}')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--action', help='Control action', required=True)
+ parser.add_argument('--name', help='Name for peer reset', required=False)
+ parser.add_argument('--tunnel', help='Specific tunnel of peer', required=False)
+
+ args = parser.parse_args()
+
+
+ if args.action == "vpn-debug":
+ debug_peer(args.name, args.tunnel)
diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py
new file mode 100644
index 0000000..51032a4
--- /dev/null
+++ b/src/op_mode/vrf.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 json
+import jmespath
+import sys
+import typing
+
+from tabulate import tabulate
+from vyos.utils.network import get_vrf_members
+from vyos.utils.process import cmd
+
+import vyos.opmode
+
+def _get_raw_data(name=None):
+ """
+ If vrf name is not set - get all VRFs
+ If vrf name is set - get only this name data
+ If vrf name set and not found - return []
+ """
+ output = cmd('ip --json --brief link show type vrf')
+ data = json.loads(output)
+ if not data:
+ return []
+ if name:
+ is_vrf_exists = True if [vrf for vrf in data if vrf.get('ifname') == name] else False
+ if is_vrf_exists:
+ output = cmd(f'ip --json --brief link show dev {name}')
+ data = json.loads(output)
+ return data
+ return []
+ return data
+
+
+def _get_formatted_output(raw_data):
+ data_entries = []
+ for vrf in raw_data:
+ name = vrf.get('ifname')
+ state = vrf.get('operstate').lower()
+ hw_address = vrf.get('address')
+ flags = ','.join(vrf.get('flags')).lower()
+ tmp = get_vrf_members(name)
+ if tmp: members = ','.join(get_vrf_members(name))
+ else: members = 'n/a'
+ data_entries.append([name, state, hw_address, flags, members])
+
+ headers = ["Name", "State", "MAC address", "Flags", "Interfaces"]
+ output = tabulate(data_entries, headers, numalign="left")
+ return output
+
+
+def show(raw: bool, name: typing.Optional[str]):
+ vrf_data = _get_raw_data(name=name)
+ if not jmespath.search('[*].ifname', vrf_data):
+ return "VRF is not configured"
+ if raw:
+ return vrf_data
+ else:
+ return _get_formatted_output(vrf_data)
+
+
+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/vrrp.py b/src/op_mode/vrrp.py
new file mode 100644
index 0000000..60be860
--- /dev/null
+++ b/src/op_mode/vrrp.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-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 sys
+import argparse
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.ifconfig.vrrp import VRRP
+from vyos.ifconfig.vrrp import VRRPNoData
+
+parser = argparse.ArgumentParser()
+group = parser.add_mutually_exclusive_group()
+group.add_argument("-s", "--summary", action="store_true", help="Print VRRP summary")
+group.add_argument("-t", "--statistics", action="store_true", help="Print VRRP statistics")
+group.add_argument("-d", "--data", action="store_true", help="Print detailed VRRP data")
+
+args = parser.parse_args()
+
+def is_configured():
+ """ Check if VRRP is configured """
+ config = ConfigTreeQuery()
+ if not config.exists(['high-availability', 'vrrp', 'group']):
+ return False
+ return True
+
+# Exit early if VRRP is dead or not configured
+if is_configured() == False:
+ print('VRRP not configured!')
+ exit(0)
+if not VRRP.is_running():
+ print('VRRP is not running')
+ sys.exit(0)
+
+try:
+ if args.summary:
+ print(VRRP.format(VRRP.collect('json')))
+ elif args.statistics:
+ print(VRRP.collect('stats'))
+ elif args.data:
+ print(VRRP.collect('state'))
+ else:
+ parser.print_help()
+ sys.exit(1)
+except VRRPNoData as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/op_mode/vtysh_wrapper.sh b/src/op_mode/vtysh_wrapper.sh
new file mode 100644
index 0000000..25d09ce
--- /dev/null
+++ b/src/op_mode/vtysh_wrapper.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+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/")
+vtysh -c "$tmp"
diff --git a/src/op_mode/vyos-op-cmd-wrapper.sh b/src/op_mode/vyos-op-cmd-wrapper.sh
new file mode 100644
index 0000000..a89211b
--- /dev/null
+++ b/src/op_mode/vyos-op-cmd-wrapper.sh
@@ -0,0 +1,6 @@
+#!/bin/vbash
+shopt -s expand_aliases
+source /etc/default/vyatta
+source /etc/bash_completion.d/vyatta-op
+_vyatta_op_init
+_vyatta_op_run "$@"
diff --git a/src/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh
new file mode 100644
index 0000000..05ea86f
--- /dev/null
+++ b/src/op_mode/webproxy_update_blacklist.sh
@@ -0,0 +1,138 @@
+#!/bin/sh
+#
+# Copyright (C) 2020 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/>.
+
+blacklist_url='ftp://ftp.univ-tlse1.fr/pub/reseau/cache/squidguard_contrib/blacklists.tar.gz'
+data_dir="/opt/vyatta/etc/config/url-filtering"
+archive="${data_dir}/squidguard/archive"
+db_dir="${data_dir}/squidguard/db"
+conf_file="/etc/squidguard/squidGuard.conf"
+tmp_conf_file="/tmp/sg_update_db.conf"
+
+#$1-category
+#$2-type
+#$3-list
+create_sg_db ()
+{
+ FILE=$db_dir/$1/$2
+ if test -f "$FILE"; then
+ rm -f ${tmp_conf_file}
+ printf "dbhome $db_dir\ndest $1 {\n $3 $1/$2\n}\nacl {\n default {\n pass any\n }\n}" >> ${tmp_conf_file}
+ /usr/bin/squidGuard -b -c ${tmp_conf_file} -C $FILE
+ rm -f ${tmp_conf_file}
+ fi
+
+}
+
+while [ $# -gt 0 ]
+do
+ case $1 in
+ --update-blacklist)
+ update="yes"
+ ;;
+ --auto-update-blacklist)
+ auto="yes"
+ ;;
+ --vrf)
+ vrf="yes"
+ ;;
+ (-*) echo "$0: error - unrecognized option $1" 1>&2; exit 1;;
+ (*) break;;
+ esac
+ shift
+done
+
+if [ ! -d ${db_dir} ]; then
+ mkdir -p ${db_dir}
+ getent passwd proxy 2> /dev/null
+ if [ $? -ne 0 ]; then
+ echo "proxy system user does not exist"
+ exit 1
+ fi
+ getent group proxy 2> /dev/null
+ if [ $? -ne 0 ]; then
+ echo "proxy system group does not exist"
+ exit 1
+ fi
+ chown proxy:proxy ${db_dir}
+fi
+
+free_space=$(expr $(df ${db_dir} | grep -v Filesystem | awk '{print $4}') \* 1024)
+mb_size="100"
+required_space=$(expr $mb_size \* 1024 \* 1024) # 100 MB
+if [ ${free_space} -le ${required_space} ]; then
+ echo "Error: not enough disk space, required ${mb_size} MiB"
+ exit 1
+fi
+
+if [[ -n $update ]] && [[ $update -eq "yes" ]]; then
+ tmp_blacklists='/tmp/blacklists.gz'
+ if [[ -n $vrf ]] && [[ $vrf -eq "yes" ]]; then
+ sudo ip vrf exec $1 curl -o $tmp_blacklists $blacklist_url
+ else
+ curl -o $tmp_blacklists $blacklist_url
+ fi
+ if [ $? -ne 0 ]; then
+ echo "Unable to download [$blacklist_url]!"
+ exit 1
+ fi
+ echo "Uncompressing blacklist..."
+ tar --directory /tmp -xf $tmp_blacklists
+ if [ $? -ne 0 ]; then
+ echo "Unable to uncompress [$blacklist_url]!"
+ fi
+
+ if [ ! -d ${archive} ]; then
+ mkdir -p ${archive}
+ fi
+
+ rm -rf ${archive}/*
+ count_before=$(find ${db_dir} -type f \( -name domains -o -name urls \) | xargs wc -l | tail -n 1 | awk '{print $1}')
+ mv ${db_dir}/* ${archive} 2> /dev/null
+ mv /tmp/blacklists/* ${db_dir}
+ if [ $? -ne 0 ]; then
+ echo "Unable to install [$blacklist_url]"
+ exit 1
+ fi
+ mv ${archive}/local-* ${db_dir} 2> /dev/null
+ rm -rf /tmp/blacklists $tmp_blacklists 2> /dev/null
+ count_after=$(find ${db_dir} -type f \( -name domains -o -name urls \) | xargs wc -l | tail -n 1 | awk '{print $1}')
+
+ # fix permissions
+ chown -R proxy:proxy ${db_dir}
+
+ #create db
+ category_list=(`find $db_dir -type d -exec basename {} \; `)
+ for category in ${category_list[@]}
+ do
+ create_sg_db $category "domains" "domainlist"
+ create_sg_db $category "urls" "urllist"
+ create_sg_db $category "expressions" "expressionlist"
+ done
+ chown -R proxy:proxy ${db_dir}
+ chmod 755 ${db_dir}
+
+ logger --priority WARNING "webproxy blacklist entries updated (${count_before}/${count_after})"
+
+else
+ echo "SquidGuard blacklist updater"
+ echo ""
+ echo "Usage:"
+ echo "--update-blacklist Download latest version of the SquidGuard blacklist"
+ echo "--auto-update-blacklist Automatically update"
+ echo ""
+ exit 1
+fi
+
diff --git a/src/op_mode/wireguard_client.py b/src/op_mode/wireguard_client.py
new file mode 100644
index 0000000..04d8ce2
--- /dev/null
+++ b/src/op_mode/wireguard_client.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2021-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 argparse
+import os
+
+from jinja2 import Template
+from ipaddress import ip_interface
+
+from vyos.ifconfig import Section
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+from vyos.utils.process import cmd
+from vyos.utils.process import popen
+
+if os.geteuid() != 0:
+ exit("You need to have root privileges to run this script.\nPlease try again, this time using 'sudo'. Exiting.")
+
+server_config = """WireGuard client configuration for interface: {{ interface }}
+
+To enable this configuration on a VyOS router you can use the following commands:
+
+=== VyOS (server) configurtation ===
+
+{% for addr in address if address is defined %}
+set interfaces wireguard {{ interface }} peer {{ name }} allowed-ips '{{ addr }}'
+{% endfor %}
+set interfaces wireguard {{ interface }} peer {{ name }} public-key '{{ pubkey }}'
+
+=== RoadWarrior (client) configuration ===
+"""
+
+client_config = """
+
+[Interface]
+PrivateKey = {{ privkey }}
+{% if address is defined and address|length > 0 %}
+Address = {{ address | join(', ')}}
+{% endif %}
+DNS = 1.1.1.1
+
+[Peer]
+PublicKey = {{ system_pubkey }}
+Endpoint = {{ server }}:{{ port }}
+AllowedIPs = 0.0.0.0/0, ::/0
+
+"""
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-n", "--name", type=str, help='WireGuard peer name', required=True)
+ parser.add_argument("-i", "--interface", type=str, help='WireGuard interface the client is connecting to', required=True)
+ parser.add_argument("-s", "--server", type=str, help='WireGuard server IPv4/IPv6 address or FQDN', required=True)
+ parser.add_argument("-a", "--address", type=str, help='WireGuard client IPv4/IPv6 address', action='append')
+ args = parser.parse_args()
+
+ interface = args.interface
+ if interface not in Section.interfaces('wireguard'):
+ exit(f'WireGuard interface "{interface}" does not exist!')
+
+ wg_pubkey = cmd(f'wg show {interface} | grep "public key"').split(':')[-1].lstrip()
+ wg_port = cmd(f'wg show {interface} | grep "listening port"').split(':')[-1].lstrip()
+
+ # Generate WireGuard private key
+ privkey,_ = popen('wg genkey')
+ # Generate public key portion from given private key
+ pubkey,_ = popen('wg pubkey', input=privkey)
+
+ config = {
+ 'name' : args.name,
+ 'interface' : interface,
+ 'system_pubkey' : wg_pubkey,
+ 'privkey': privkey,
+ 'pubkey' : pubkey,
+ 'server' : args.server,
+ 'port' : wg_port,
+ 'address' : [],
+ }
+
+ if args.address:
+ v4_addr = 0
+ v6_addr = 0
+ for tmp in args.address:
+ try:
+ ip = str(ip_interface(tmp).ip)
+ if is_ipv4(tmp):
+ config['address'].append(f'{ip}/32')
+ v4_addr += 1
+ elif is_ipv6(tmp):
+ config['address'].append(f'{ip}/128')
+ v6_addr += 1
+ except:
+ print(tmp)
+ exit('Client IP address invalid!')
+
+ if (v4_addr > 1) or (v6_addr > 1):
+ exit('Client can only have one IPv4 and one IPv6 address.')
+
+ # Clear out terminal first
+ print('\x1b[2J\x1b[H')
+ server = Template(server_config, trim_blocks=True).render(config)
+ print(server)
+ client = Template(client_config, trim_blocks=True).render(config)
+ print(client)
+ qrcode,err = popen('qrencode -t ansiutf8', input=client)
+ print(qrcode)
diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py
new file mode 100644
index 0000000..49fecdf
--- /dev/null
+++ b/src/op_mode/zone.py
@@ -0,0 +1,215 @@
+#!/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 typing
+import sys
+import vyos.opmode
+
+import tabulate
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_search
+
+
+def get_config_zone(conf, name=None):
+ config_path = ['firewall', 'zone']
+ if name:
+ config_path += [name]
+
+ zone_policy = conf.get_config_dict(config_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ return zone_policy
+
+
+def _convert_one_zone_data(zone: str, zone_config: dict) -> dict:
+ """
+ Convert config dictionary of one zone to API dictionary
+ :param zone: Zone name
+ :type zone: str
+ :param zone_config: config dictionary
+ :type zone_config: dict
+ :return: AP dictionary
+ :rtype: dict
+ """
+ list_of_rules = []
+ intrazone_dict = {}
+ if dict_search('from', zone_config):
+ for from_zone, from_zone_config in zone_config['from'].items():
+ from_zone_dict = {'name': from_zone}
+ if dict_search('firewall.name', from_zone_config):
+ from_zone_dict['firewall'] = dict_search('firewall.name',
+ from_zone_config)
+ if dict_search('firewall.ipv6_name', from_zone_config):
+ from_zone_dict['firewall_v6'] = dict_search(
+ 'firewall.ipv6_name', from_zone_config)
+ list_of_rules.append(from_zone_dict)
+
+ zone_dict = {
+ 'name': zone,
+ 'interface': dict_search('interface', zone_config),
+ 'type': 'LOCAL' if dict_search('local_zone',
+ zone_config) is not None else None,
+ }
+ if list_of_rules:
+ zone_dict['from'] = list_of_rules
+ if dict_search('intra_zone_filtering.firewall.name', zone_config):
+ intrazone_dict['firewall'] = dict_search(
+ 'intra_zone_filtering.firewall.name', zone_config)
+ if dict_search('intra_zone_filtering.firewall.ipv6_name', zone_config):
+ intrazone_dict['firewall_v6'] = dict_search(
+ 'intra_zone_filtering.firewall.ipv6_name', zone_config)
+ if intrazone_dict:
+ zone_dict['intrazone'] = intrazone_dict
+ return zone_dict
+
+
+def _convert_zones_data(zone_policies: dict) -> list:
+ """
+ Convert all config dictionary to API list of zone dictionaries
+ :param zone_policies: config dictionary
+ :type zone_policies: dict
+ :return: API list
+ :rtype: list
+ """
+ zone_list = []
+ for zone, zone_config in zone_policies.items():
+ zone_list.append(_convert_one_zone_data(zone, zone_config))
+ return zone_list
+
+
+def _convert_config(zones_config: dict, zone: str = None) -> list:
+ """
+ convert config to API list
+ :param zones_config: zones config
+ :type zones_config:
+ :param zone: zone name
+ :type zone: str
+ :return: API list
+ :rtype: list
+ """
+ if zone:
+ if zones_config:
+ output = [_convert_one_zone_data(zone, zones_config)]
+ else:
+ raise vyos.opmode.UnconfiguredObject(f'Zone {zone} not found')
+ else:
+ if zones_config:
+ output = _convert_zones_data(zones_config)
+ else:
+ raise vyos.opmode.UnconfiguredSubsystem(
+ 'Zone entries are not configured')
+ return output
+
+
+def output_zone_list(zone_conf: dict) -> list:
+ """
+ Format one zone row
+ :param zone_conf: zone config
+ :type zone_conf: dict
+ :return: formatted list of zones
+ :rtype: list
+ """
+ zone_info = [zone_conf['name']]
+ if zone_conf['type'] == 'LOCAL':
+ zone_info.append('LOCAL')
+ else:
+ zone_info.append("\n".join(zone_conf['interface']))
+
+ from_zone = []
+ firewall = []
+ firewall_v6 = []
+ if 'intrazone' in zone_conf:
+ from_zone.append(zone_conf['name'])
+
+ v4_name = dict_search_args(zone_conf['intrazone'], 'firewall')
+ v6_name = dict_search_args(zone_conf['intrazone'], 'firewall_v6')
+ if v4_name:
+ firewall.append(v4_name)
+ else:
+ firewall.append('')
+ if v6_name:
+ firewall_v6.append(v6_name)
+ else:
+ firewall_v6.append('')
+
+ if 'from' in zone_conf:
+ for from_conf in zone_conf['from']:
+ from_zone.append(from_conf['name'])
+
+ v4_name = dict_search_args(from_conf, 'firewall')
+ v6_name = dict_search_args(from_conf, 'firewall_v6')
+ if v4_name:
+ firewall.append(v4_name)
+ else:
+ firewall.append('')
+ if v6_name:
+ firewall_v6.append(v6_name)
+ else:
+ firewall_v6.append('')
+
+ zone_info.append("\n".join(from_zone))
+ zone_info.append("\n".join(firewall))
+ zone_info.append("\n".join(firewall_v6))
+ return zone_info
+
+
+def get_formatted_output(zone_policy: list) -> str:
+ """
+ Formatted output of all zones
+ :param zone_policy: list of zones
+ :type zone_policy: list
+ :return: formatted table with zones
+ :rtype: str
+ """
+ headers = ["Zone",
+ "Interfaces",
+ "From Zone",
+ "Firewall IPv4",
+ "Firewall IPv6"
+ ]
+ formatted_list = []
+ for zone_conf in zone_policy:
+ formatted_list.append(output_zone_list(zone_conf))
+ tabulate.PRESERVE_WHITESPACE = True
+ output = tabulate.tabulate(formatted_list, headers, numalign="left")
+ return output
+
+
+def show(raw: bool, zone: typing.Optional[str]):
+ """
+ Show zone-policy command
+ :param raw: if API
+ :type raw: bool
+ :param zone: zone name
+ :type zone: str
+ """
+ conf: ConfigTreeQuery = ConfigTreeQuery()
+ zones_config: dict = get_config_zone(conf, zone)
+ zone_policy_api: list = _convert_config(zones_config, zone)
+ if raw:
+ return zone_policy_api
+ else:
+ return get_formatted_output(zone_policy_api)
+
+
+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/opt/vyatta/bin/restricted-shell b/src/opt/vyatta/bin/restricted-shell
new file mode 100644
index 0000000..ffcbb53
--- /dev/null
+++ b/src/opt/vyatta/bin/restricted-shell
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+if [ $# != 0 ]; then
+ echo "Remote command execution is not allowed for operator level users"
+ args=($@)
+ args_str=$(IFS=" " ; echo "${args[*]}")
+ logger "Operator level user $USER attempted remote command execution: $args_str"
+ exit 1
+fi
+
+exec vbash
diff --git a/src/opt/vyatta/bin/vyatta-op-cmd-wrapper b/src/opt/vyatta/bin/vyatta-op-cmd-wrapper
new file mode 100644
index 0000000..a89211b
--- /dev/null
+++ b/src/opt/vyatta/bin/vyatta-op-cmd-wrapper
@@ -0,0 +1,6 @@
+#!/bin/vbash
+shopt -s expand_aliases
+source /etc/default/vyatta
+source /etc/bash_completion.d/vyatta-op
+_vyatta_op_init
+_vyatta_op_run "$@"
diff --git a/src/opt/vyatta/etc/LICENSE b/src/opt/vyatta/etc/LICENSE
new file mode 100644
index 0000000..6d45519
--- /dev/null
+++ b/src/opt/vyatta/etc/LICENSE
@@ -0,0 +1,340 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+ 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Library General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ 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, write to the Free Software
+ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Library General
+Public License instead of this License.
diff --git a/src/opt/vyatta/etc/shell/level/users/allowed-op b/src/opt/vyatta/etc/shell/level/users/allowed-op
new file mode 100644
index 0000000..381fd26
--- /dev/null
+++ b/src/opt/vyatta/etc/shell/level/users/allowed-op
@@ -0,0 +1,21 @@
+c
+cl
+cle
+clea
+clear
+connect
+delete
+disconnect
+execute
+exit
+force
+monitor
+ping
+reset
+release
+renew
+set
+show
+telnet
+traceroute
+update
diff --git a/src/opt/vyatta/etc/shell/level/users/allowed-op.in b/src/opt/vyatta/etc/shell/level/users/allowed-op.in
new file mode 100644
index 0000000..9752f99
--- /dev/null
+++ b/src/opt/vyatta/etc/shell/level/users/allowed-op.in
@@ -0,0 +1,17 @@
+clear
+connect
+delete
+disconnect
+execute
+exit
+force
+monitor
+ping
+reset
+release
+renew
+set
+show
+telnet
+traceroute
+update
diff --git a/src/opt/vyatta/sbin/if-mib-alias b/src/opt/vyatta/sbin/if-mib-alias
new file mode 100644
index 0000000..bc86f99
--- /dev/null
+++ b/src/opt/vyatta/sbin/if-mib-alias
@@ -0,0 +1,130 @@
+#! /usr/bin/perl
+
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# Author: Stephen Hemminger
+# Date: October 2010
+# Description: script is run as net-snmp extension to read interface alias
+#
+# **** End License ****
+
+use strict;
+use warnings;
+use feature "switch";
+no warnings 'experimental::smartmatch';
+
+# Collect interface all alias values
+sub get_alias {
+ my @interfaces;
+
+ open (my $ip, '-|', 'ip li')
+ or die "Can't run ip command\n";
+ my $index;
+ while(<$ip>) {
+ if (/^(\d+): ([^:]*): /) {
+ $index = $1;
+ $interfaces[$index] = $2;
+ } elsif (/^ +alias (.*)$/) {
+ $interfaces[$index] = $1;
+ }
+ }
+ close $ip;
+ return @interfaces;
+}
+
+sub get_oid {
+ my $oid = shift;
+ die "Not a valid Object ID: $oid"
+ unless ($oid =~ /.(\d+)$/);
+
+ my $ifindex = $1;
+ my @interfaces = get_alias();
+
+ my $ifalias = $interfaces[$ifindex];
+ print "$oid\nstring\n$ifalias\n" if $ifalias;
+}
+
+# OID of ifAlias [RFC2863]
+my $BASE = '.1.3.6.1.2.1.31.1.1.1.18';
+
+sub get_next {
+ my $oid = shift;
+
+ return get_next("$BASE.0")
+ if ($oid eq $BASE);
+
+ die "Not a valid Object ID: $oid"
+ unless ($oid =~ /^(\S*)\.(\d+)$/);
+
+ my $base = $1;
+ my $ifindex = $2;
+ my @interfaces = get_alias();
+
+ while (++$ifindex <= $#interfaces) {
+ my $ifalias = $interfaces[$ifindex];
+ if ($ifalias) {
+ print "$base.$ifindex\nstring\n$ifalias\n";
+ last;
+ }
+ }
+}
+
+sub ifindextoname {
+ my $ifindex = shift;
+
+ open (my $ip, '-|', 'ip li')
+ or die "Can't run ip command\n";
+ my $index;
+ while(<$ip>) {
+ next unless (/^(\d+): ([^:]*): /);
+ return $2 if ($1 == $ifindex);
+ }
+ return;
+}
+
+sub set_oid {
+ my ($oid, $target, $value) = @_;
+ die "Not a valid Object ID: $oid"
+ unless ($oid =~ /\.(\d+)$/);
+ my $ifindex = $1;
+ unless ($target eq 'string') {
+ print "wrong-type\n";
+ return;
+ }
+
+ my $ifname = ifindextoname($ifindex);
+ if ($ifname) {
+ system("ip li set $ifname alias '$value' >/dev/null 2>&1");
+ print "not-writeable\n" if ($? != 0);
+ }
+}
+
+sub usage {
+ warn "Usage: $0 {-g|-n} OID\n";
+ warn " $0 -s OID TARGET VALUE\n";
+ exit 1;
+}
+
+usage unless $#ARGV >= 1;
+
+given ($ARGV[0]) {
+ when ('-g') { get_oid ($ARGV[1]); }
+ when ('-n') { get_next ($ARGV[1]); }
+ when ('-s') { set_oid ($ARGV[1], $ARGV[2], $ARGV[3]); }
+ default {
+ warn "$ARGV[0] unknown flag\n";
+ usage;
+ }
+}
diff --git a/src/opt/vyatta/sbin/vyos-persistpath b/src/opt/vyatta/sbin/vyos-persistpath
new file mode 100644
index 0000000..d7199b0
--- /dev/null
+++ b/src/opt/vyatta/sbin/vyos-persistpath
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+if grep -q -e '^overlay.*/filesystem.squashfs' /proc/mounts; then
+ # Live CD boot
+ exit 2
+
+elif grep -q 'upperdir=/live/persistence/' /proc/mounts && egrep -q 'overlay / overlay ' /proc/mounts; then
+ # union boot
+
+ boot_device=`grep -o 'upperdir=/live/persistence/[^/]*/boot' /proc/mounts | cut -d / -f 4`
+ persist_path="/lib/live/mount/persistence/$boot_device"
+
+ echo $persist_path
+ exit 0
+else
+ # old style boot
+
+ exit 1
+fi \ No newline at end of file
diff --git a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-common b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-common
new file mode 100644
index 0000000..e749f02
--- /dev/null
+++ b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-common
@@ -0,0 +1,82 @@
+# vyatta bash completion common functions
+
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# A copy of the GNU General Public License is available as
+# `/usr/share/common-licenses/GPL' in the Debian GNU/Linux distribution
+# or on the World Wide Web at `http://www.gnu.org/copyleft/gpl.html'.
+# You can also obtain it by writing to the Free Software Foundation,
+# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
+# MA 02110-1301, USA.
+#
+# Author: Vyatta
+# Description: bash completion common functions
+#
+# **** End License ****
+
+get_prefix_filtered_list ()
+{
+ # $1: prefix
+ # $2: \@list
+ # $3: \@filtered
+ declare -a olist
+ local pfx=$1
+ pfx=${pfx#\"}
+ eval "olist=( \"\${$2[@]}\" )"
+ local idx=0
+ for elem in "${olist[@]}"; do
+ local sub="${elem#$pfx}"
+ if [[ "$elem" == "$sub" ]] && [[ -n "$pfx" ]]; then
+ continue
+ fi
+ eval "$3[$idx]=\$elem"
+ (( idx++ ))
+ done
+}
+
+get_prefix_filtered_list2 ()
+{
+ # $1: prefix
+ # $2: \@list
+ # $3: \@filtered
+ # $4: \@list2
+ # $5: \@filtered2
+ declare -a olist
+ local pfx=$1
+ pfx=${pfx#\"}
+ eval "olist=( \"\${$2[@]}\" )"
+ eval "local orig_len=\${#$2[@]}"
+ local orig_idx=0
+ local idx=0
+ for (( orig_idx = 0; orig_idx < orig_len; orig_idx++ )); do
+ eval "local elem=\${$2[$orig_idx]}"
+ eval "local elem2=\${$4[$orig_idx]}"
+ local sub="${elem#$pfx}"
+ if [[ "$elem" == "$sub" ]] && [[ -n "$pfx" ]]; then
+ continue
+ fi
+ eval "$3[$idx]=\$elem"
+ eval "$5[$idx]=\$elem2"
+ (( idx++ ))
+ done
+}
+
+is_elem_of () {
+ local elem="$1"
+ local -a olist
+ eval "olist=( \"\${$2[@]}\" )"
+ for e in "${olist[@]}"; do
+ if [[ "$e" == "$elem" ]]; then
+ return 0
+ fi
+ done
+ return 1
+}
diff --git a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run
new file mode 100644
index 0000000..f0479ae
--- /dev/null
+++ b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run
@@ -0,0 +1,240 @@
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2006, 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# Author: Tom Grennan
+# Date: 2007
+# Description: setup bash completion for Vyatta operational commands
+#
+# **** End License ****
+
+_vyatta_op_init ()
+{
+ # empty and default line compeletion
+ complete -E -F _vyatta_op_expand
+ complete -D -F _vyatta_op_default_expand
+
+ # create the top level aliases for the unambiguous portions of the commands
+ # this is the only place we need an entire enumerated list of the subcommands
+ for cmd in $( ls /opt/vyatta/share/vyatta-op/templates/ ); do
+ for pos in $(seq 1 ${#cmd}); do
+ case ${cmd:0:$pos} in
+ for|do|done|if|fi|case|while|tr )
+ continue ;;
+ *) ;;
+ esac
+ complete -F _vyatta_op_expand ${cmd:0:$pos}
+ eval alias ${cmd:0:$pos}=\'_vyatta_op_run ${cmd:0:$pos}\'
+ done
+ done
+
+ shopt -s histverify
+}
+
+_vyatta_op_get_node_def_field ()
+{
+ local file=$1 field=$2
+
+ sed -n '/^'"$field"':/,$ {
+# strip field name and hold rest of line
+ s/[a-z]*: *//
+ h
+ :b
+# at EOF, print hold buffer and quit
+ $ { x; p; q }
+# input next line
+ n
+# if start of another field def, print hold buf and quit
+ /^[a-z]*:/ { x; p; q }
+# add to hold buf and branch to input next line
+ H
+ bb
+ }' $file
+}
+
+_vyatta_op_conv_node_path ()
+{
+ # is the node ok, ambiguous, or invalid
+ local node_path
+ local node
+ local -a ARR
+ node_path=$1
+ node=$2
+ ARR=( $(compgen -d $node_path/$node) )
+ if [[ "${#ARR[@]}" == "1" ]]; then
+ echo ${ARR[0]##*/}
+ elif [[ "${#ARR[@]}" == "0" ]]; then
+ if [[ -d "${node_path}/node.tag" ]]; then
+ echo "$node tag"
+ else
+ echo "$node invalid"
+ fi
+ elif [[ -d "$node_path/$node" ]]; then
+ echo $node
+ elif [[ "$VYATTA_USER_LEVEL_DIR" != "/opt/vyatta/etc/shell/level/admin" ]];then
+ # special handling for unprivledged completions.
+ # Since top level commands are different for unprivledged users
+ # we need a handler to expand them properly.
+ local -a filtered_cmds=()
+ local -a allowed=( $(cat $VYATTA_USER_LEVEL_DIR/allowed-op.in) )
+ get_prefix_filtered_list $node allowed filtered_cmds
+ if [[ "${#filtered_cmds[@]}" == "1" ]];then
+ echo ${filtered_cmds[0]}
+ else
+ echo "${node} ambiguous"
+ fi
+ else
+ echo "$node ambiguous"
+ fi
+}
+
+_vyatta_op_conv_run_cmd ()
+{
+ # Substitue bash positional variables
+ # for the same value in the expanded array
+ local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; )
+ shopt -s extglob
+ shopt -u nullglob
+ local run_cmd="$1"
+ local line outline
+ local -i inquote=0;
+ local outcmd='';
+ local OIFS=$IFS
+ local re="([^']*')(.*)"
+
+ toggle_inquote()
+ {
+ if [[ $inquote == 0 ]]; then
+ inquote=1
+ else
+ inquote=0
+ fi
+ }
+
+ process_subline()
+ {
+ if [[ $inquote == 1 ]]; then
+ outline+="$1"
+ else
+ outline+=$(sed -e 's/\$\([0-9]\)/\$\{args\[\1\]\}/g' <<<"$1")
+ fi
+ }
+
+ run_cmd="${run_cmd/\"\$\@\"/${args[*]}}"
+ run_cmd="${run_cmd/\$\*/${args[*]}}"
+ run_cmd="${run_cmd//\\/\\\\}"
+ IFS=$'\n'
+ for line in ${run_cmd[@]}; do
+ outline=''
+ while [[ -n "$line" ]]; do
+ if [[ "$line" =~ $re ]]; then
+ process_subline "${BASH_REMATCH[1]}"
+ toggle_inquote
+ else
+ process_subline "$line"
+ fi
+ line="${BASH_REMATCH[2]}"
+ done
+ outcmd+="$outline\n"
+ done
+ IFS=$OIFS
+ eval "$restore_shopts"
+ echo -ne "$outcmd"
+}
+
+_vyatta_op_run ()
+{
+ # if run with bash builtin "set -/+*" run set and return
+ # this happens when a different completion script runs eval "set ..."
+ # (VyOS T1604)
+ if [[ "$1" == "set" && "$2" =~ ^(-|\+).* ]]; then
+ set "${@:2}"
+ return
+ fi
+
+ local -i estat
+ local tpath=$vyatta_op_templates
+ local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; )
+ shopt -s extglob nullglob
+
+ _vyatta_op_last_comp=${_vyatta_op_last_comp_init}
+ false; estat=$?
+ stty echo 2> /dev/null # turn echo on, this is a workaround for bug 7570
+ # not a fix we need to look at why the readline library
+ # is getting confused on paged help text.
+
+ i=1
+ declare -a args # array of expanded arguments
+ for arg in "$@"; do
+ local orig_arg=$arg
+ if [[ $arg == "*" ]]; then
+ arg="*" #leave user defined wildcards alone
+ else
+ arg=( $(_vyatta_op_conv_node_path $tpath $arg) ) # expand the arguments
+ fi
+ # output proper error message based on the above expansion
+ if [[ "${arg[1]}" == "ambiguous" ]]; then
+ echo -ne "\n Ambiguous command: ${args[@]} [$arg]\n" >&2
+ local -a cmds=( $(compgen -d $tpath/$arg) )
+ _vyatta_op_node_path=$tpath
+ local comps=$(_vyatta_op_help $arg ${cmds[@]##*/})
+ echo -e "$comps\n" | sed -e 's/^P/ P/'
+ eval $restore_shopts
+ return 1
+ elif [[ "${arg[1]}" == "invalid" ]]; then
+ echo -ne "\n Invalid command: ${args[@]} [$arg]\n\n" >&2
+ eval $restore_shopts
+ return 1
+ fi
+
+ if [ -f "$tpath/$arg/node.def" ] ; then
+ tpath+=/$arg
+ elif [ -f $tpath/node.tag/node.def ] ; then
+ tpath+=/node.tag
+ else
+ echo -ne "\n Invalid command: ${args[@]} [$arg]\n\n" >&2
+ eval $restore_shopts
+ return 1
+ fi
+ if [[ "$arg" == "node.tag" ]]; then
+ args[$i]=$orig_arg
+ else
+ args[$i]=$arg
+ fi
+ let "i+=1"
+ done
+
+ local run_cmd=$(_vyatta_op_get_node_def_field $tpath/node.def run)
+ run_cmd=$(_vyatta_op_conv_run_cmd "$run_cmd") # convert the positional parameters
+ local ret=0
+ # Exception for the `show file` command
+ local file_cmd='\$\{vyos_op_scripts_dir\}\/file\.py'
+ local cmd_regex="^(LESSOPEN=|less|pager|tail|(sudo )?$file_cmd).*"
+ if [ -n "$run_cmd" ]; then
+ eval $restore_shopts
+ if [[ -t 1 && "${args[1]}" == "show" && ! $run_cmd =~ $cmd_regex ]] ; then
+ eval "($run_cmd) | ${VYATTA_PAGER:-cat}"
+ else
+ eval "$run_cmd"
+ fi
+ else
+ echo -ne "\n Incomplete command: ${args[@]}\n\n" >&2
+ eval $restore_shopts
+ ret=1
+ fi
+ return $ret
+}
+
+### Local Variables:
+### mode: shell-script
+### End:
diff --git a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv
new file mode 100644
index 0000000..1507f4f
--- /dev/null
+++ b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv
@@ -0,0 +1,97 @@
+#!/bin/bash
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2006, 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# **** End License ****
+
+source /opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-common
+
+declare -a op_allowed
+declare -a toplevel
+
+op_allowed=( $(cat /opt/vyatta/etc/shell/level/users/allowed-op.in) )
+toplevel=( $(ls /opt/vyatta/share/vyatta-op/templates/) )
+
+vyatta_unpriv_ambiguous ()
+{
+ local -a filtered_cmds=()
+ get_prefix_filtered_list $1 op_allowed filtered_cmds
+ _vyatta_op_node_path=${vyatta_op_templates}
+ comps=$(_vyatta_op_help $1 ${filtered_cmds[@]})
+ echo -ne "\n Ambiguous command: [$1]\n"
+ echo -e "$comps\n" | sed -e 's/^P/ P/'
+}
+
+vyatta_unpriv_init ()
+{
+ # empty and default line compeletion
+ complete -E -F _vyatta_op_expand
+ complete -D -F _vyatta_op_default_expand
+
+ for cmd in "${op_allowed[@]}"; do
+ if is_elem_of ${cmd} toplevel; then
+ for pos in $(seq 1 ${#cmd}); do
+ case ${cmd:0:$pos} in
+ for|do|done|if|fi|case|while|tr )
+ continue ;;
+ *) ;;
+ esac
+ local -a filtered_cmds=()
+ get_prefix_filtered_list ${cmd:0:$pos} op_allowed filtered_cmds
+ local found
+ is_elem_of "${cmd:0:$pos}" op_allowed
+ found=$?
+ if [[ "${#filtered_cmds[@]}" == "1" || "${cmd:0:$pos}" == "$cmd" || "$found" == "0" ]]; then
+ local fcmd
+ if [[ "${#filtered_cmds[@]}" == "1" ]]; then
+ fcmd=${filtered_cmds[0]}
+ elif is_elem_of "${cmd:0:$pos}" op_allowed; then
+ fcmd=${cmd:0:$pos}
+ else
+ fcmd=$cmd
+ fi
+ eval alias ${cmd:0:$pos}=\'_vyatta_op_run $fcmd\'
+ else
+ eval alias ${cmd:0:$pos}=\'vyatta_unpriv_ambiguous ${cmd:0:$pos}\'
+ fi
+ complete -F _vyatta_op_expand ${cmd:0:$pos}
+ done
+ fi
+ done
+ if [[ "$VYATTA_USER_LEVEL_DIR" == "/opt/vyatta/etc/shell/level/users" ]]; then
+ PS1='\u@\h> '
+ fi
+}
+
+vyatta_unpriv_gen_allowed () {
+ local -a allowed_cmds=()
+ rm -rf /opt/vyatta/etc/shell/level/users/allowed-op
+ for cmd in "${op_allowed[@]}"; do
+ if is_elem_of ${cmd} toplevel; then
+ for pos in $(seq 1 ${#cmd}); do
+ case ${cmd:0:$pos} in
+ for|do|done|if|fi|case|while|tr )
+ continue ;;
+ *) ;;
+ esac
+ if ! is_elem_of ${cmd:0:$pos} allowed_cmds; then
+ allowed_cmds+=( ${cmd:0:$pos} )
+ echo ${cmd:0:$pos} >> /opt/vyatta/etc/shell/level/users/allowed-op
+ fi
+ done
+ else
+ echo ${cmd} >> /opt/vyatta/etc/shell/level/users/allowed-op
+ fi
+ done
+}
diff --git a/src/pam-configs/mfa-google-authenticator b/src/pam-configs/mfa-google-authenticator
new file mode 100644
index 0000000..9e49e5e
--- /dev/null
+++ b/src/pam-configs/mfa-google-authenticator
@@ -0,0 +1,8 @@
+Name: Google Authenticator PAM module (2FA/MFA)
+Default: no
+Priority: 384
+
+Auth-Type: Primary
+Auth:
+ [default=ignore success=ok auth_err=die] pam_google_authenticator.so nullok forward_pass
+
diff --git a/src/pam-configs/radius-mandatory b/src/pam-configs/radius-mandatory
new file mode 100644
index 0000000..3368fe7
--- /dev/null
+++ b/src/pam-configs/radius-mandatory
@@ -0,0 +1,19 @@
+Name: RADIUS authentication (mandatory mode)
+Default: no
+Priority: 576
+
+Auth-Type: Primary
+Auth-Initial:
+ [default=ignore success=end auth_err=die perm_denied=die user_unknown=die] pam_radius_auth.so
+Auth:
+ [default=ignore success=end auth_err=die perm_denied=die user_unknown=die] pam_radius_auth.so use_first_pass
+
+Account-Type: Primary
+Account:
+ [default=ignore success=1] pam_succeed_if.so user notingroup radius quiet
+ [default=ignore success=end] pam_radius_auth.so
+
+Session-Type: Additional
+Session:
+ [default=ignore success=1] pam_succeed_if.so user notingroup radius quiet
+ [default=bad success=ok] pam_radius_auth.so
diff --git a/src/pam-configs/radius-optional b/src/pam-configs/radius-optional
new file mode 100644
index 0000000..7308506
--- /dev/null
+++ b/src/pam-configs/radius-optional
@@ -0,0 +1,19 @@
+Name: RADIUS authentication (optional mode)
+Default: no
+Priority: 576
+
+Auth-Type: Primary
+Auth-Initial:
+ [default=ignore success=end] pam_radius_auth.so
+Auth:
+ [default=ignore success=end] pam_radius_auth.so use_first_pass
+
+Account-Type: Primary
+Account:
+ [default=ignore success=1] pam_succeed_if.so user notingroup radius quiet
+ [default=ignore success=end] pam_radius_auth.so
+
+Session-Type: Additional
+Session:
+ [default=ignore success=1] pam_succeed_if.so user notingroup radius quiet
+ [default=ignore success=ok perm_denied=bad user_unknown=bad] pam_radius_auth.so
diff --git a/src/pam-configs/tacplus-mandatory b/src/pam-configs/tacplus-mandatory
new file mode 100644
index 0000000..ffccece
--- /dev/null
+++ b/src/pam-configs/tacplus-mandatory
@@ -0,0 +1,17 @@
+Name: TACACS+ authentication (mandatory mode)
+Default: no
+Priority: 576
+
+Auth-Type: Primary
+Auth:
+ [default=ignore success=end auth_err=die perm_denied=die user_unknown=die] pam_tacplus.so include=/etc/tacplus_servers login=login
+
+Account-Type: Primary
+Account:
+ [default=ignore success=1] pam_succeed_if.so user notingroup tacacs quiet
+ [default=bad success=end] pam_tacplus.so include=/etc/tacplus_servers login=login
+
+Session-Type: Additional
+Session:
+ [default=ignore success=1] pam_succeed_if.so user notingroup tacacs quiet
+ [default=bad success=ok] pam_tacplus.so include=/etc/tacplus_servers login=login
diff --git a/src/pam-configs/tacplus-optional b/src/pam-configs/tacplus-optional
new file mode 100644
index 0000000..095c3a1
--- /dev/null
+++ b/src/pam-configs/tacplus-optional
@@ -0,0 +1,17 @@
+Name: TACACS+ authentication (optional mode)
+Default: no
+Priority: 576
+
+Auth-Type: Primary
+Auth:
+ [default=ignore success=end] pam_tacplus.so include=/etc/tacplus_servers login=login
+
+Account-Type: Primary
+Account:
+ [default=ignore success=1] pam_succeed_if.so user notingroup tacacs quiet
+ [default=ignore success=end auth_err=bad perm_denied=bad user_unknown=bad] pam_tacplus.so include=/etc/tacplus_servers login=login
+
+Session-Type: Additional
+Session:
+ [default=ignore success=1] pam_succeed_if.so user notingroup tacacs quiet
+ [default=ignore success=ok session_err=bad user_unknown=bad] pam_tacplus.so include=/etc/tacplus_servers login=login
diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql
new file mode 100644
index 0000000..1133d79
--- /dev/null
+++ b/src/services/api/graphql/README.graphql
@@ -0,0 +1,218 @@
+
+The following examples are in the form as entered in the GraphQL
+'playground', which is found at:
+
+https://{{ host_address }}/graphql
+
+Example using GraphQL mutations to configure a DHCP server:
+
+All examples assume that the http-api is running:
+
+'set service https api'
+
+One can configure an address on an interface, and configure the DHCP server
+to run with that address as default router by requesting these 'mutations'
+in the GraphQL playground:
+
+mutation {
+ CreateInterfaceEthernet (data: {interface: "eth1",
+ address: "192.168.0.1/24",
+ description: "BOB"}) {
+ success
+ errors
+ data {
+ address
+ }
+ }
+}
+
+mutation {
+ CreateDhcpServer(data: {sharedNetworkName: "BOB",
+ subnet: "192.168.0.0/24",
+ defaultRouter: "192.168.0.1",
+ nameServer: "192.168.0.1",
+ domainName: "vyos.net",
+ lease: 86400,
+ range: 0,
+ start: "192.168.0.9",
+ stop: "192.168.0.254",
+ dnsForwardingAllowFrom: "192.168.0.0/24",
+ dnsForwardingCacheSize: 0,
+ dnsForwardingListenAddress: "192.168.0.1"}) {
+ success
+ errors
+ data {
+ defaultRouter
+ }
+ }
+}
+
+To save the configuration, use the following mutation:
+
+mutation {
+ SaveConfigFile(data: {fileName: "/config/config.boot"}) {
+ success
+ errors
+ data {
+ fileName
+ }
+ }
+}
+
+N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to
+save to /config/config.boot; to save to an alternative path, specify
+fileName.
+
+Similarly, using an analogous 'endpoint' (meaning the form of the request
+and resolver; the actual enpoint for all GraphQL requests is
+https://hostname/graphql), one can load an arbitrary config file from a
+path.
+
+mutation {
+ LoadConfigFile(data: {fileName: "/home/vyos/config.boot"}) {
+ success
+ errors
+ data {
+ fileName
+ }
+ }
+}
+
+Op-mode 'show' commands may be requested by path, e.g.:
+
+query {
+ Show (data: {path: ["interfaces", "ethernet", "detail"]}) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
+
+N.B. to see the output the 'data' field 'result' must be present in the
+request.
+
+Mutations to manipulate firewall address groups:
+
+mutation {
+ CreateFirewallAddressGroup (data: {name: "ADDR-GRP", address: "10.0.0.1"}) {
+ success
+ errors
+ }
+}
+
+mutation {
+ UpdateFirewallAddressGroupMembers (data: {name: "ADDR-GRP",
+ address: ["10.0.0.1-10.0.0.8", "192.168.0.1"]}) {
+ success
+ errors
+ }
+}
+
+mutation {
+ RemoveFirewallAddressGroupMembers (data: {name: "ADDR-GRP",
+ address: "192.168.0.1"}) {
+ success
+ errors
+ }
+}
+
+N.B. The schema for the above specify that 'address' be of the form 'list of
+strings' (SDL type [String!]! for UpdateFirewallAddressGroupMembers, where
+the ! indicates that the input is required; SDL type [String] in
+CreateFirewallAddressGroup, since a group may be created without any
+addresses). However, notice that a single string may be passed without being
+a member of a list, in which case the specification allows for 'input
+coercion':
+
+http://spec.graphql.org/October2021/#sec-Scalars.Input-Coercion
+
+Similarly, IPv6 versions of the above:
+
+CreateFirewallAddressIpv6Group
+UpdateFirewallAddressIpv6GroupMembers
+RemoveFirewallAddressIpv6GroupMembers
+
+
+Instead of using the GraphQL playground, an equivalent curl command to the
+first example above would be:
+
+curl -k 'https://192.168.100.168/graphql' -H 'Content-Type: application/json' --data-binary '{"query": "mutation {createInterfaceEthernet (data: {interface: \"eth1\", address: \"192.168.0.1/24\", description: \"BOB\"}) {success errors data {address}}}"}'
+
+Note that the 'mutation' term is prefaced by 'query' in the curl command.
+
+Curl equivalents may be read from within the GraphQL playground at the 'copy
+curl' button.
+
+What's here:
+
+services
+├── api
+│   └── graphql
+│   ├── bindings.py
+│   ├── graphql
+│   │   ├── directives.py
+│   │   ├── __init__.py
+│   │   ├── mutations.py
+│   │   └── schema
+│   │   ├── config_file.graphql
+│   │   ├── dhcp_server.graphql
+│   │   ├── firewall_group.graphql
+│   │   ├── interface_ethernet.graphql
+│   │   ├── schema.graphql
+│   │   ├── show_config.graphql
+│   │   └── show.graphql
+│   ├── README.graphql
+│   ├── recipes
+│   │   ├── __init__.py
+│   │   ├── remove_firewall_address_group_members.py
+│   │   ├── session.py
+│   │   └── templates
+│   │   ├── create_dhcp_server.tmpl
+│   │   ├── create_firewall_address_group.tmpl
+│   │   ├── create_interface_ethernet.tmpl
+│   │   ├── remove_firewall_address_group_members.tmpl
+│   │   └── update_firewall_address_group_members.tmpl
+│   └── state.py
+├── vyos-configd
+├── vyos-hostsd
+└── vyos-http-api-server
+
+The GraphQL library that we are using, Ariadne, advertises itself as a
+'schema-first' implementation: define the schema; define resolvers
+(handlers) for declared Query and Mutation types (Subscription types are not
+currently used).
+
+In the current approach to a high-level API, we consider the
+Jinja2-templated collection of configuration mode 'set'/'delete' commands as
+the Ur-data; the GraphQL schema is produced from those files, located in
+'api/graphql/recipes/templates'.
+
+Resolvers for the schema Mutation fields are dynamically generated using a
+'directive' added to the respective schema field. The directive,
+'@configure', is handled by the class 'ConfigureDirective' in
+'api/graphql/graphql/directives.py', which calls the
+'make_configure_resolver' function in 'api/graphql/graphql/mutations.py';
+the produced resolver calls the appropriate wrapper in
+'api/graphql/recipes', with base class doing the (overridable) configuration
+steps of calling all defined 'set'/'delete' commands.
+
+Integrating the above with vyos-http-api-server is 4 lines of code.
+
+What needs to be done:
+
+• automate generation of schema and wrappers from templated configuration
+commands
+
+• investigate whether the subclassing provided by the named wrappers in
+'api/graphql/recipes' is sufficient for use cases which need to modify data
+
+• encapsulate the manipulation of 'canonical names' which transforms the
+prefixed camel-case schema names to various snake-case file/function names
+
+• consider mechanism for migration of templates: offline vs. on-the-fly
+
+• define the naming convention for those schema fields that refer to
+configuration mode parameters: e.g. how much of the path is needed as prefix
+to uniquely define the term
diff --git a/src/services/api/graphql/__init__.py b/src/services/api/graphql/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/services/api/graphql/__init__.py
diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py
new file mode 100644
index 0000000..ef49664
--- /dev/null
+++ b/src/services/api/graphql/bindings.py
@@ -0,0 +1,36 @@
+# Copyright 2021 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/>.
+
+import vyos.defaults
+from . graphql.queries import query
+from . graphql.mutations import mutation
+from . graphql.directives import directives_dict
+from . graphql.errors import op_mode_error
+from . graphql.auth_token_mutation import auth_token_mutation
+from . libs.token_auth import init_secret
+from . import state
+from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers
+
+def generate_schema():
+ api_schema_dir = vyos.defaults.directories['api_schema']
+
+ if state.settings['app'].state.vyos_auth_type == 'token':
+ init_secret()
+
+ type_defs = load_schema_from_path(api_schema_dir)
+
+ schema = make_executable_schema(type_defs, query, op_mode_error, mutation, auth_token_mutation, snake_case_fallback_resolvers, directives=directives_dict)
+
+ return schema
diff --git a/src/services/api/graphql/generate/composite_function.py b/src/services/api/graphql/generate/composite_function.py
new file mode 100644
index 0000000..d6626fd
--- /dev/null
+++ b/src/services/api/graphql/generate/composite_function.py
@@ -0,0 +1,7 @@
+# typing information for composite functions: those that invoke several
+# elementary requests, and return the result as a single dict
+def system_status():
+ pass
+
+queries = {'system_status': system_status}
+mutations = {}
diff --git a/src/services/api/graphql/generate/config_session_function.py b/src/services/api/graphql/generate/config_session_function.py
new file mode 100644
index 0000000..4ebb47a
--- /dev/null
+++ b/src/services/api/graphql/generate/config_session_function.py
@@ -0,0 +1,30 @@
+# typing information for native configsession functions; used to generate
+# schema definition files
+import typing
+
+def show_config(path: list[str], configFormat: typing.Optional[str]):
+ pass
+
+def show(path: list[str]):
+ pass
+
+def show_user_info(user: str):
+ pass
+
+queries = {'show_config': show_config,
+ 'show': show,
+ 'show_user_info': show_user_info}
+
+def save_config_file(fileName: typing.Optional[str]):
+ pass
+def load_config_file(fileName: str):
+ pass
+def add_system_image(location: str):
+ pass
+def delete_system_image(name: str):
+ pass
+
+mutations = {'save_config_file': save_config_file,
+ 'load_config_file': load_config_file,
+ 'add_system_image': add_system_image,
+ 'delete_system_image': delete_system_image}
diff --git a/src/services/api/graphql/generate/generate_schema.py b/src/services/api/graphql/generate/generate_schema.py
new file mode 100644
index 0000000..dd5e7ea
--- /dev/null
+++ b/src/services/api/graphql/generate/generate_schema.py
@@ -0,0 +1,26 @@
+#!/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/>.
+#
+#
+
+from schema_from_op_mode import generate_op_mode_definitions
+from schema_from_config_session import generate_config_session_definitions
+from schema_from_composite import generate_composite_definitions
+
+if __name__ == '__main__':
+ generate_op_mode_definitions()
+ generate_config_session_definitions()
+ generate_composite_definitions()
diff --git a/src/services/api/graphql/generate/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py
new file mode 100644
index 0000000..06e7403
--- /dev/null
+++ b/src/services/api/graphql/generate/schema_from_composite.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+#
+#
+# A utility to generate GraphQL schema defintions from typing information of
+# composite functions comprising several requests.
+
+import os
+import sys
+from inspect import signature
+from jinja2 import Template
+
+from vyos.defaults import directories
+if __package__ is None or __package__ == '':
+ sys.path.append(os.path.join(directories['services'], 'api'))
+ from graphql.libs.op_mode import snake_to_pascal_case, map_type_name
+ from composite_function import queries, mutations
+else:
+ from .. libs.op_mode import snake_to_pascal_case, map_type_name
+ from . composite_function import queries, mutations
+
+SCHEMA_PATH = directories['api_schema']
+CLIENT_OP_PATH = directories['api_client_op']
+
+schema_data: dict = {'schema_name': '',
+ 'schema_fields': []}
+
+query_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Query {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositequery
+}
+"""
+
+mutation_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Mutation {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @compositemutation
+}
+"""
+
+op_query_template = """
+query {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
+"""
+
+op_mutation_template = """
+mutation {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
+"""
+
+def create_schema(func_name: str, func: callable, template: str) -> str:
+ sig = signature(func)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation)
+
+ schema_fields = []
+ for k,v in field_dict.items():
+ schema_fields.append(k+': '+v)
+
+ schema_data['schema_name'] = snake_to_pascal_case(func_name)
+ schema_data['schema_fields'] = schema_fields
+
+ j2_template = Template(template)
+ res = j2_template.render(schema_data)
+
+ return res
+
+def create_client_op(func_name: str, func: callable, template: str) -> str:
+ sig = signature(func)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation)
+
+ op_sig = ['$key: String']
+ op_arg = ['key: $key']
+ for k,v in field_dict.items():
+ op_sig.append('$'+k+': '+v)
+ op_arg.append(k+': $'+k)
+
+ op_data = {}
+ op_data['op_name'] = snake_to_pascal_case(func_name)
+ op_data['op_sig'] = ', '.join(op_sig)
+ op_data['op_arg'] = ', '.join(op_arg)
+
+ j2_template = Template(template)
+
+ res = j2_template.render(op_data)
+
+ return res
+
+def generate_composite_definitions():
+ schema = []
+ client_op = []
+ for name,func in queries.items():
+ res = create_schema(name, func, query_template)
+ schema.append(res)
+ res = create_client_op(name, func, op_query_template)
+ client_op.append(res)
+
+ for name,func in mutations.items():
+ res = create_schema(name, func, mutation_template)
+ schema.append(res)
+ res = create_client_op(name, func, op_mutation_template)
+ client_op.append(res)
+
+ out = '\n'.join(schema)
+ with open(f'{SCHEMA_PATH}/composite.graphql', 'w') as f:
+ f.write(out)
+
+ out = '\n'.join(client_op)
+ with open(f'{CLIENT_OP_PATH}/composite.graphql', 'w') as f:
+ f.write(out)
+
+if __name__ == '__main__':
+ generate_composite_definitions()
diff --git a/src/services/api/graphql/generate/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py
new file mode 100644
index 0000000..1d5ff1e
--- /dev/null
+++ b/src/services/api/graphql/generate/schema_from_config_session.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+#
+#
+# A utility to generate GraphQL schema defintions from typing information of
+# (wrappers of) native configsession functions.
+
+import os
+import sys
+from inspect import signature
+from jinja2 import Template
+
+from vyos.defaults import directories
+if __package__ is None or __package__ == '':
+ sys.path.append(os.path.join(directories['services'], 'api'))
+ from graphql.libs.op_mode import snake_to_pascal_case, map_type_name
+ from config_session_function import queries, mutations
+else:
+ from .. libs.op_mode import snake_to_pascal_case, map_type_name
+ from . config_session_function import queries, mutations
+
+SCHEMA_PATH = directories['api_schema']
+CLIENT_OP_PATH = directories['api_client_op']
+
+schema_data: dict = {'schema_name': '',
+ 'schema_fields': []}
+
+query_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Query {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionquery
+}
+"""
+
+mutation_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Mutation {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @configsessionmutation
+}
+"""
+
+op_query_template = """
+query {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
+"""
+
+op_mutation_template = """
+mutation {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
+"""
+
+def create_schema(func_name: str, func: callable, template: str) -> str:
+ sig = signature(func)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation)
+
+ schema_fields = []
+ for k,v in field_dict.items():
+ schema_fields.append(k+': '+v)
+
+ schema_data['schema_name'] = snake_to_pascal_case(func_name)
+ schema_data['schema_fields'] = schema_fields
+
+ j2_template = Template(template)
+ res = j2_template.render(schema_data)
+
+ return res
+
+def create_client_op(func_name: str, func: callable, template: str) -> str:
+ sig = signature(func)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation)
+
+ op_sig = ['$key: String']
+ op_arg = ['key: $key']
+ for k,v in field_dict.items():
+ op_sig.append('$'+k+': '+v)
+ op_arg.append(k+': $'+k)
+
+ op_data = {}
+ op_data['op_name'] = snake_to_pascal_case(func_name)
+ op_data['op_sig'] = ', '.join(op_sig)
+ op_data['op_arg'] = ', '.join(op_arg)
+
+ j2_template = Template(template)
+
+ res = j2_template.render(op_data)
+
+ return res
+
+def generate_config_session_definitions():
+ schema = []
+ client_op = []
+ for name,func in queries.items():
+ res = create_schema(name, func, query_template)
+ schema.append(res)
+ res = create_client_op(name, func, op_query_template)
+ client_op.append(res)
+
+ for name,func in mutations.items():
+ res = create_schema(name, func, mutation_template)
+ schema.append(res)
+ res = create_client_op(name, func, op_mutation_template)
+ client_op.append(res)
+
+ out = '\n'.join(schema)
+ with open(f'{SCHEMA_PATH}/configsession.graphql', 'w') as f:
+ f.write(out)
+
+ out = '\n'.join(client_op)
+ with open(f'{CLIENT_OP_PATH}/configsession.graphql', 'w') as f:
+ f.write(out)
+
+if __name__ == '__main__':
+ generate_config_session_definitions()
diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py
new file mode 100644
index 0000000..ab7cb69
--- /dev/null
+++ b/src/services/api/graphql/generate/schema_from_op_mode.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+#
+#
+# A utility to generate GraphQL schema defintions from standardized op-mode
+# scripts.
+
+import os
+import sys
+import json
+from inspect import signature, getmembers, isfunction, isclass, getmro
+from jinja2 import Template
+
+from vyos.defaults import directories
+from vyos.opmode import _is_op_mode_function_name as is_op_mode_function_name
+from vyos.opmode import _get_literal_values as get_literal_values
+from vyos.utils.system import load_as_module
+if __package__ is None or __package__ == '':
+ sys.path.append(os.path.join(directories['services'], 'api'))
+ from graphql.libs.op_mode import is_show_function_name
+ from graphql.libs.op_mode import snake_to_pascal_case, map_type_name
+else:
+ from .. libs.op_mode import is_show_function_name
+ from .. libs.op_mode import snake_to_pascal_case, map_type_name
+
+OP_MODE_PATH = directories['op_mode']
+SCHEMA_PATH = directories['api_schema']
+CLIENT_OP_PATH = directories['api_client_op']
+DATA_DIR = directories['data']
+
+
+op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json')
+op_mode_error_schema = 'op_mode_error.graphql'
+
+schema_data: dict = {'schema_name': '',
+ 'schema_fields': []}
+
+query_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ op_mode_error: OpModeError
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Query {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopquery
+}
+"""
+
+mutation_template = """
+input {{ schema_name }}Input {
+ key: String
+ {%- for field_entry in schema_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+
+type {{ schema_name }} {
+ result: Generic
+}
+
+type {{ schema_name }}Result {
+ data: {{ schema_name }}
+ op_mode_error: OpModeError
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Mutation {
+ {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation
+}
+"""
+
+enum_template = """
+enum {{ enum_name }} {
+ {%- for field_entry in enum_fields %}
+ {{ field_entry }}
+ {%- endfor %}
+}
+"""
+
+error_template = """
+interface OpModeError {
+ name: String!
+ message: String!
+ vyos_code: Int!
+}
+{% for name in error_names %}
+type {{ name }} implements OpModeError {
+ name: String!
+ message: String!
+ vyos_code: Int!
+}
+{%- endfor %}
+"""
+
+op_query_template = """
+query {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ op_mode_error {
+ name
+ message
+ vyos_code
+ }
+ data {
+ result
+ }
+ }
+}
+"""
+
+op_mutation_template = """
+mutation {{ op_name }} ({{ op_sig }}) {
+ {{ op_name }} (data: { {{ op_arg }} }) {
+ success
+ errors
+ op_mode_error {
+ name
+ message
+ vyos_code
+ }
+ data {
+ result
+ }
+ }
+}
+"""
+
+def create_schema(func_name: str, base_name: str, func: callable,
+ enums: dict) -> str:
+ sig = signature(func)
+
+ for k in sig.parameters:
+ t = get_literal_values(sig.parameters[k].annotation)
+ if t:
+ enums[t] = snake_to_pascal_case(sig.parameters[k].name + '_' + base_name)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation, enums)
+
+ # It is assumed that if one is generating a schema for a 'show_*'
+ # function, that 'get_raw_data' is present and 'raw' is desired.
+ if 'raw' in list(field_dict):
+ del field_dict['raw']
+
+ schema_fields = []
+ for k,v in field_dict.items():
+ schema_fields.append(k+': '+v)
+
+ schema_data['schema_name'] = snake_to_pascal_case(func_name + '_' + base_name)
+ schema_data['schema_fields'] = schema_fields
+
+ if is_show_function_name(func_name):
+ j2_template = Template(query_template)
+ else:
+ j2_template = Template(mutation_template)
+
+ res = j2_template.render(schema_data)
+
+ return res
+
+def create_client_op(func_name: str, base_name: str, func: callable,
+ enums: dict) -> str:
+ sig = signature(func)
+
+ for k in sig.parameters:
+ t = get_literal_values(sig.parameters[k].annotation)
+ if t:
+ enums[t] = snake_to_pascal_case(sig.parameters[k].name + '_' + base_name)
+
+ field_dict = {}
+ for k in sig.parameters:
+ field_dict[sig.parameters[k].name] = map_type_name(sig.parameters[k].annotation, enums)
+
+ # It is assumed that if one is generating a schema for a 'show_*'
+ # function, that 'get_raw_data' is present and 'raw' is desired.
+ if 'raw' in list(field_dict):
+ del field_dict['raw']
+
+ op_sig = ['$key: String']
+ op_arg = ['key: $key']
+ for k,v in field_dict.items():
+ op_sig.append('$'+k+': '+v)
+ op_arg.append(k+': $'+k)
+
+ op_data = {}
+ op_data['op_name'] = snake_to_pascal_case(func_name + '_' + base_name)
+ op_data['op_sig'] = ', '.join(op_sig)
+ op_data['op_arg'] = ', '.join(op_arg)
+
+ if is_show_function_name(func_name):
+ j2_template = Template(op_query_template)
+ else:
+ j2_template = Template(op_mutation_template)
+
+ res = j2_template.render(op_data)
+
+ return res
+
+def create_enums(enums: dict) -> str:
+ enum_data = []
+ for k, v in enums.items():
+ enum = {'enum_name': v, 'enum_fields': list(k)}
+ enum_data.append(enum)
+
+ out = ''
+ j2_template = Template(enum_template)
+ for el in enum_data:
+ out += j2_template.render(el)
+ out += '\n'
+
+ return out
+
+def create_error_schema():
+ from vyos import opmode
+
+ e = Exception
+ err_types = getmembers(opmode, isclass)
+ err_types = [k for k in err_types if issubclass(k[1], e)]
+ # drop base class, to be replaced by interface type. Find the class
+ # programmatically, in case the base class name changes.
+ for i in range(len(err_types)):
+ if err_types[i][1] in getmro(err_types[i-1][1]):
+ del err_types[i]
+ break
+ err_names = [k[0] for k in err_types]
+ error_data = {'error_names': err_names}
+ j2_template = Template(error_template)
+ res = j2_template.render(error_data)
+
+ return res
+
+def generate_op_mode_definitions():
+ os.makedirs(CLIENT_OP_PATH, exist_ok=True)
+
+ out = create_error_schema()
+ with open(f'{SCHEMA_PATH}/{op_mode_error_schema}', 'w') as f:
+ f.write(out)
+
+ with open(op_mode_include_file) as f:
+ op_mode_files = json.load(f)
+
+ for file in op_mode_files:
+ basename = os.path.splitext(file)[0].replace('-', '_')
+ module = load_as_module(basename, os.path.join(OP_MODE_PATH, file))
+
+ funcs = getmembers(module, isfunction)
+ funcs = list(filter(lambda ft: is_op_mode_function_name(ft[0]), funcs))
+
+ funcs_dict = {}
+ for (name, thunk) in funcs:
+ funcs_dict[name] = thunk
+
+ schema = []
+ client_op = []
+ enums = {} # gather enums from function Literal type args
+ for name,func in funcs_dict.items():
+ res = create_schema(name, basename, func, enums)
+ schema.append(res)
+ res = create_client_op(name, basename, func, enums)
+ client_op.append(res)
+
+ out = create_enums(enums)
+ out += '\n'.join(schema)
+ with open(f'{SCHEMA_PATH}/{basename}.graphql', 'w') as f:
+ f.write(out)
+
+ out = '\n'.join(client_op)
+ with open(f'{CLIENT_OP_PATH}/{basename}.graphql', 'w') as f:
+ f.write(out)
+
+if __name__ == '__main__':
+ generate_op_mode_definitions()
diff --git a/src/services/api/graphql/graphql/__init__.py b/src/services/api/graphql/graphql/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/services/api/graphql/graphql/__init__.py
diff --git a/src/services/api/graphql/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py
new file mode 100644
index 0000000..a53fa4d
--- /dev/null
+++ b/src/services/api/graphql/graphql/auth_token_mutation.py
@@ -0,0 +1,61 @@
+# Copyright 2022-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/>.
+
+import datetime
+from typing import Any
+from typing import Dict
+from ariadne import ObjectType
+from graphql import GraphQLResolveInfo
+
+from .. libs.token_auth import generate_token
+from .. session.session import get_user_info
+from .. import state
+
+auth_token_mutation = ObjectType("Mutation")
+
+@auth_token_mutation.field('AuthToken')
+def auth_token_resolver(obj: Any, info: GraphQLResolveInfo, data: Dict):
+ # non-nullable fields
+ user = data['username']
+ passwd = data['password']
+
+ secret = state.settings['secret']
+ exp_interval = int(state.settings['app'].state.vyos_token_exp)
+ expiration = (datetime.datetime.now(tz=datetime.timezone.utc) +
+ datetime.timedelta(seconds=exp_interval))
+
+ res = generate_token(user, passwd, secret, expiration)
+ try:
+ res |= get_user_info(user)
+ except ValueError:
+ # non-existent user already caught
+ pass
+ if 'token' in res:
+ data['result'] = res
+ return {
+ "success": True,
+ "data": data
+ }
+
+ if 'errors' in res:
+ return {
+ "success": False,
+ "errors": res['errors']
+ }
+
+ return {
+ "success": False,
+ "errors": ['token generation failed']
+ }
diff --git a/src/services/api/graphql/graphql/client_op/auth_token.graphql b/src/services/api/graphql/graphql/client_op/auth_token.graphql
new file mode 100644
index 0000000..5ea2ecc
--- /dev/null
+++ b/src/services/api/graphql/graphql/client_op/auth_token.graphql
@@ -0,0 +1,10 @@
+
+mutation AuthToken ($username: String!, $password: String!) {
+ AuthToken (data: { username: $username, password: $password }) {
+ success
+ errors
+ data {
+ result
+ }
+ }
+}
diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py
new file mode 100644
index 0000000..3927aee
--- /dev/null
+++ b/src/services/api/graphql/graphql/directives.py
@@ -0,0 +1,87 @@
+# 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/>.
+
+from ariadne import SchemaDirectiveVisitor
+from . queries import *
+from . mutations import *
+
+def non(arg):
+ pass
+
+class VyosDirective(SchemaDirectiveVisitor):
+ def visit_field_definition(self, field, object_type, make_resolver=non):
+ name = f'{field.type}'
+ # field.type contains the return value of the mutation; trim value
+ # to produce canonical name
+ name = name.replace('Result', '', 1)
+
+ func = make_resolver(name)
+ field.resolve = func
+ return field
+
+class ConfigSessionQueryDirective(VyosDirective):
+ """
+ Class providing implementation of 'configsessionquery' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_config_session_query_resolver)
+
+class ConfigSessionMutationDirective(VyosDirective):
+ """
+ Class providing implementation of 'configsessionmutation' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_config_session_mutation_resolver)
+
+class GenOpQueryDirective(VyosDirective):
+ """
+ Class providing implementation of 'genopquery' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_gen_op_query_resolver)
+
+class GenOpMutationDirective(VyosDirective):
+ """
+ Class providing implementation of 'genopmutation' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_gen_op_mutation_resolver)
+
+class CompositeQueryDirective(VyosDirective):
+ """
+ Class providing implementation of 'system_status' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_composite_query_resolver)
+
+class CompositeMutationDirective(VyosDirective):
+ """
+ Class providing implementation of 'system_status' directive in schema.
+ """
+ def visit_field_definition(self, field, object_type):
+ super().visit_field_definition(field, object_type,
+ make_resolver=make_composite_mutation_resolver)
+
+directives_dict = {"configsessionquery": ConfigSessionQueryDirective,
+ "configsessionmutation": ConfigSessionMutationDirective,
+ "genopquery": GenOpQueryDirective,
+ "genopmutation": GenOpMutationDirective,
+ "compositequery": CompositeQueryDirective,
+ "compositemutation": CompositeMutationDirective}
diff --git a/src/services/api/graphql/graphql/errors.py b/src/services/api/graphql/graphql/errors.py
new file mode 100644
index 0000000..1066300
--- /dev/null
+++ b/src/services/api/graphql/graphql/errors.py
@@ -0,0 +1,8 @@
+
+from ariadne import InterfaceType
+
+op_mode_error = InterfaceType("OpModeError")
+
+@op_mode_error.type_resolver
+def resolve_op_mode_error(obj, *_):
+ return obj['name']
diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py
new file mode 100644
index 0000000..d115a8e
--- /dev/null
+++ b/src/services/api/graphql/graphql/mutations.py
@@ -0,0 +1,139 @@
+# 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/>.
+
+from importlib import import_module
+from ariadne import ObjectType, convert_camel_case_to_snake
+from makefun import with_signature
+
+# used below by func_sig
+from typing import Any, Dict, Optional # pylint: disable=W0611
+from graphql import GraphQLResolveInfo # pylint: disable=W0611
+
+from .. import state
+from .. libs import key_auth
+from api.graphql.session.session import Session
+from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code
+from vyos.opmode import Error as OpModeError
+
+mutation = ObjectType("Mutation")
+
+def make_mutation_resolver(mutation_name, class_name, session_func):
+ """Dynamically generate a resolver for the mutation named in the
+ schema by 'mutation_name'.
+
+ Dynamic generation is provided using the package 'makefun' (via the
+ decorator 'with_signature'), which provides signature-preserving
+ function wrappers; it provides several improvements over, say,
+ functools.wraps.
+
+ :raise Exception:
+ raising ConfigErrors, or internal errors
+ """
+
+ func_base_name = convert_camel_case_to_snake(class_name)
+ resolver_name = f'resolve_{func_base_name}'
+ func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)'
+
+ @mutation.field(mutation_name)
+ @with_signature(func_sig, func_name=resolver_name)
+ async def func_impl(*args, **kwargs):
+ try:
+ auth_type = state.settings['app'].state.vyos_auth_type
+
+ if auth_type == 'key':
+ data = kwargs['data']
+ key = data['key']
+
+ auth = key_auth.auth_required(key)
+ if auth is None:
+ return {
+ "success": False,
+ "errors": ['invalid API key']
+ }
+
+ # We are finished with the 'key' entry, and may remove so as to
+ # pass the rest of data (if any) to function.
+ del data['key']
+
+ elif auth_type == 'token':
+ data = kwargs['data']
+ if data is None:
+ data = {}
+ info = kwargs['info']
+ user = info.context.get('user')
+ if user is None:
+ error = info.context.get('error')
+ if error is not None:
+ return {
+ "success": False,
+ "errors": [error]
+ }
+ return {
+ "success": False,
+ "errors": ['not authenticated']
+ }
+ else:
+ # AtrributeError will have already been raised if no
+ # vyos_auth_type; validation and defaultValue ensure it is
+ # one of the previous cases, so this is never reached.
+ pass
+
+ session = state.settings['app'].state.vyos_session
+
+ # one may override the session functions with a local subclass
+ try:
+ mod = import_module(f'api.graphql.session.override.{func_base_name}')
+ klass = getattr(mod, class_name)
+ except ImportError:
+ # otherwise, dynamically generate subclass to invoke subclass
+ # name based functions
+ klass = type(class_name, (Session,), {})
+ k = klass(session, data)
+ method = getattr(k, session_func)
+ result = method()
+ data['result'] = result
+
+ return {
+ "success": True,
+ "data": data
+ }
+ except OpModeError as e:
+ typename = type(e).__name__
+ msg = str(e)
+ return {
+ "success": False,
+ "errore": ['op_mode_error'],
+ "op_mode_error": {"name": f"{typename}",
+ "message": msg if msg else op_mode_err_msg.get(typename, "Unknown"),
+ "vyos_code": op_mode_err_code.get(typename, 9999)}
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "errors": [repr(error)]
+ }
+
+ return func_impl
+
+def make_config_session_mutation_resolver(mutation_name):
+ return make_mutation_resolver(mutation_name, mutation_name,
+ convert_camel_case_to_snake(mutation_name))
+
+def make_gen_op_mutation_resolver(mutation_name):
+ return make_mutation_resolver(mutation_name, mutation_name, 'gen_op_mutation')
+
+def make_composite_mutation_resolver(mutation_name):
+ return make_mutation_resolver(mutation_name, mutation_name,
+ convert_camel_case_to_snake(mutation_name))
diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py
new file mode 100644
index 0000000..7170982
--- /dev/null
+++ b/src/services/api/graphql/graphql/queries.py
@@ -0,0 +1,139 @@
+# 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/>.
+
+from importlib import import_module
+from ariadne import ObjectType, convert_camel_case_to_snake
+from makefun import with_signature
+
+# used below by func_sig
+from typing import Any, Dict, Optional # pylint: disable=W0611
+from graphql import GraphQLResolveInfo # pylint: disable=W0611
+
+from .. import state
+from .. libs import key_auth
+from api.graphql.session.session import Session
+from api.graphql.session.errors.op_mode_errors import op_mode_err_msg, op_mode_err_code
+from vyos.opmode import Error as OpModeError
+
+query = ObjectType("Query")
+
+def make_query_resolver(query_name, class_name, session_func):
+ """Dynamically generate a resolver for the query named in the
+ schema by 'query_name'.
+
+ Dynamic generation is provided using the package 'makefun' (via the
+ decorator 'with_signature'), which provides signature-preserving
+ function wrappers; it provides several improvements over, say,
+ functools.wraps.
+
+ :raise Exception:
+ raising ConfigErrors, or internal errors
+ """
+
+ func_base_name = convert_camel_case_to_snake(class_name)
+ resolver_name = f'resolve_{func_base_name}'
+ func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)'
+
+ @query.field(query_name)
+ @with_signature(func_sig, func_name=resolver_name)
+ async def func_impl(*args, **kwargs):
+ try:
+ auth_type = state.settings['app'].state.vyos_auth_type
+
+ if auth_type == 'key':
+ data = kwargs['data']
+ key = data['key']
+
+ auth = key_auth.auth_required(key)
+ if auth is None:
+ return {
+ "success": False,
+ "errors": ['invalid API key']
+ }
+
+ # We are finished with the 'key' entry, and may remove so as to
+ # pass the rest of data (if any) to function.
+ del data['key']
+
+ elif auth_type == 'token':
+ data = kwargs['data']
+ if data is None:
+ data = {}
+ info = kwargs['info']
+ user = info.context.get('user')
+ if user is None:
+ error = info.context.get('error')
+ if error is not None:
+ return {
+ "success": False,
+ "errors": [error]
+ }
+ return {
+ "success": False,
+ "errors": ['not authenticated']
+ }
+ else:
+ # AtrributeError will have already been raised if no
+ # vyos_auth_type; validation and defaultValue ensure it is
+ # one of the previous cases, so this is never reached.
+ pass
+
+ session = state.settings['app'].state.vyos_session
+
+ # one may override the session functions with a local subclass
+ try:
+ mod = import_module(f'api.graphql.session.override.{func_base_name}')
+ klass = getattr(mod, class_name)
+ except ImportError:
+ # otherwise, dynamically generate subclass to invoke subclass
+ # name based functions
+ klass = type(class_name, (Session,), {})
+ k = klass(session, data)
+ method = getattr(k, session_func)
+ result = method()
+ data['result'] = result
+
+ return {
+ "success": True,
+ "data": data
+ }
+ except OpModeError as e:
+ typename = type(e).__name__
+ msg = str(e)
+ return {
+ "success": False,
+ "errors": ['op_mode_error'],
+ "op_mode_error": {"name": f"{typename}",
+ "message": msg if msg else op_mode_err_msg.get(typename, "Unknown"),
+ "vyos_code": op_mode_err_code.get(typename, 9999)}
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "errors": [repr(error)]
+ }
+
+ return func_impl
+
+def make_config_session_query_resolver(query_name):
+ return make_query_resolver(query_name, query_name,
+ convert_camel_case_to_snake(query_name))
+
+def make_gen_op_query_resolver(query_name):
+ return make_query_resolver(query_name, query_name, 'gen_op_query')
+
+def make_composite_query_resolver(query_name):
+ return make_query_resolver(query_name, query_name,
+ convert_camel_case_to_snake(query_name))
diff --git a/src/services/api/graphql/graphql/schema/auth_token.graphql b/src/services/api/graphql/graphql/schema/auth_token.graphql
new file mode 100644
index 0000000..af53a29
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/auth_token.graphql
@@ -0,0 +1,19 @@
+
+input AuthTokenInput {
+ username: String!
+ password: String!
+}
+
+type AuthToken {
+ result: Generic
+}
+
+type AuthTokenResult {
+ data: AuthToken
+ success: Boolean!
+ errors: [String]
+}
+
+extend type Mutation {
+ AuthToken(data: AuthTokenInput) : AuthTokenResult
+}
diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql
new file mode 100644
index 0000000..62b0d30
--- /dev/null
+++ b/src/services/api/graphql/graphql/schema/schema.graphql
@@ -0,0 +1,16 @@
+schema {
+ query: Query
+ mutation: Mutation
+}
+
+directive @compositequery on FIELD_DEFINITION
+directive @compositemutation on FIELD_DEFINITION
+directive @configsessionquery on FIELD_DEFINITION
+directive @configsessionmutation on FIELD_DEFINITION
+directive @genopquery on FIELD_DEFINITION
+directive @genopmutation on FIELD_DEFINITION
+
+scalar Generic
+
+type Query
+type Mutation
diff --git a/src/services/api/graphql/libs/key_auth.py b/src/services/api/graphql/libs/key_auth.py
new file mode 100644
index 0000000..2db0f7d
--- /dev/null
+++ b/src/services/api/graphql/libs/key_auth.py
@@ -0,0 +1,18 @@
+
+from .. import state
+
+def check_auth(key_list, key):
+ if not key_list:
+ return None
+ key_id = None
+ for k in key_list:
+ if k['key'] == key:
+ key_id = k['id']
+ return key_id
+
+def auth_required(key):
+ api_keys = None
+ api_keys = state.settings['app'].state.vyos_keys
+ key_id = check_auth(api_keys, key)
+ state.settings['app'].state.vyos_id = key_id
+ return key_id
diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py
new file mode 100644
index 0000000..86e38ea
--- /dev/null
+++ b/src/services/api/graphql/libs/op_mode.py
@@ -0,0 +1,103 @@
+# Copyright 2022-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/>.
+
+import os
+import re
+import typing
+
+from typing import Union
+from typing import Optional
+from humps import decamelize
+
+from vyos.defaults import directories
+from vyos.utils.system import load_as_module
+from vyos.opmode import _normalize_field_names
+from vyos.opmode import _is_literal_type, _get_literal_values
+
+def load_op_mode_as_module(name: str):
+ path = os.path.join(directories['op_mode'], name)
+ name = os.path.splitext(name)[0].replace('-', '_')
+ return load_as_module(name, path)
+
+def is_show_function_name(name):
+ if re.match(r"^show", name):
+ return True
+ return False
+
+def _nth_split(delim: str, n: int, s: str):
+ groups = s.split(delim)
+ l = len(groups)
+ if n > l-1 or n < 1:
+ return (s, '')
+ return (delim.join(groups[:n]), delim.join(groups[n:]))
+
+def _nth_rsplit(delim: str, n: int, s: str):
+ groups = s.split(delim)
+ l = len(groups)
+ if n > l-1 or n < 1:
+ return (s, '')
+ return (delim.join(groups[:l-n]), delim.join(groups[l-n:]))
+
+# Since we have mangled possible hyphens in the file name while constructing
+# the snake case of the query/mutation name, we will need to recover the
+# file name by searching with mangling:
+def _filter_on_mangled(test):
+ def func(elem):
+ mangle = os.path.splitext(elem)[0].replace('-', '_')
+ return test == mangle
+ return func
+
+# Find longest name in concatenated string that matches the basename of an
+# op-mode script. Should one prefer to concatenate in the reverse order
+# (script_name + '_' + function_name), use _nth_rsplit.
+def split_compound_op_mode_name(name: str, files: list):
+ for i in range(1, name.count('_') + 1):
+ pair = _nth_split('_', i, name)
+ f = list(filter(_filter_on_mangled(pair[1]), files))
+ if f:
+ pair = (pair[0], f[0])
+ return pair
+ return (name, '')
+
+def snake_to_pascal_case(name: str) -> str:
+ res = ''.join(map(str.title, name.split('_')))
+ return res
+
+def map_type_name(type_name: type, enums: Optional[dict] = None, optional: bool = False) -> str:
+ if type_name == str:
+ return 'String!' if not optional else 'String = null'
+ if type_name == int:
+ return 'Int!' if not optional else 'Int = null'
+ if type_name == bool:
+ return 'Boolean = false'
+ if typing.get_origin(type_name) == list:
+ if not optional:
+ return f'[{map_type_name(typing.get_args(type_name)[0], enums=enums)}]!'
+ return f'[{map_type_name(typing.get_args(type_name)[0], enums=enums)}]'
+ if _is_literal_type(type_name):
+ mapped = enums.get(_get_literal_values(type_name), '')
+ if not mapped:
+ raise ValueError(typing.get_args(type_name))
+ return f'{mapped}!' if not optional else mapped
+ # typing.Optional is typing.Union[_, NoneType]
+ if (typing.get_origin(type_name) is typing.Union and
+ typing.get_args(type_name)[1] == type(None)):
+ return f'{map_type_name(typing.get_args(type_name)[0], enums=enums, optional=True)}'
+
+ # scalar 'Generic' is defined in schema.graphql
+ return 'Generic'
+
+def normalize_output(result: Union[dict, list]) -> Union[dict, list]:
+ return _normalize_field_names(decamelize(result))
diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py
new file mode 100644
index 0000000..8585485
--- /dev/null
+++ b/src/services/api/graphql/libs/token_auth.py
@@ -0,0 +1,70 @@
+import jwt
+import uuid
+import pam
+from secrets import token_hex
+
+from .. import state
+
+def _check_passwd_pam(username: str, passwd: str) -> bool:
+ if pam.authenticate(username, passwd):
+ return True
+ return False
+
+def init_secret():
+ length = int(state.settings['app'].state.vyos_secret_len)
+ secret = token_hex(length)
+ state.settings['secret'] = secret
+
+def generate_token(user: str, passwd: str, secret: str, exp: int) -> dict:
+ if user is None or passwd is None:
+ return {}
+ if _check_passwd_pam(user, passwd):
+ app = state.settings['app']
+ try:
+ users = app.state.vyos_token_users
+ except AttributeError:
+ app.state.vyos_token_users = {}
+ users = app.state.vyos_token_users
+ user_id = uuid.uuid1().hex
+ payload_data = {'iss': user, 'sub': user_id, 'exp': exp}
+ secret = state.settings.get('secret')
+ if secret is None:
+ return {"errors": ['missing secret']}
+ token = jwt.encode(payload=payload_data, key=secret, algorithm="HS256")
+
+ users |= {user_id: user}
+ return {'token': token}
+ else:
+ return {"errors": ['failed pam authentication']}
+
+def get_user_context(request):
+ context = {}
+ context['request'] = request
+ context['user'] = None
+ if 'Authorization' in request.headers:
+ auth = request.headers['Authorization']
+ scheme, token = auth.split()
+ if scheme.lower() != 'bearer':
+ return context
+
+ try:
+ secret = state.settings.get('secret')
+ payload = jwt.decode(token, secret, algorithms=["HS256"])
+ user_id: str = payload.get('sub')
+ if user_id is None:
+ return context
+ except jwt.exceptions.ExpiredSignatureError:
+ context['error'] = 'expired token'
+ return context
+ except jwt.PyJWTError:
+ return context
+ try:
+ users = state.settings['app'].state.vyos_token_users
+ except AttributeError:
+ return context
+
+ user = users.get(user_id)
+ if user is not None:
+ context['user'] = user
+
+ return context
diff --git a/src/services/api/graphql/session/__init__.py b/src/services/api/graphql/session/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/services/api/graphql/session/__init__.py
diff --git a/src/services/api/graphql/session/composite/system_status.py b/src/services/api/graphql/session/composite/system_status.py
new file mode 100644
index 0000000..516a4ef
--- /dev/null
+++ b/src/services/api/graphql/session/composite/system_status.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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/>.
+
+from api.graphql.libs.op_mode import load_op_mode_as_module
+
+def get_system_version() -> dict:
+ show_version = load_op_mode_as_module('version.py')
+ return show_version.show(raw=True, funny=False)
+
+def get_system_uptime() -> dict:
+ show_uptime = load_op_mode_as_module('uptime.py')
+ return show_uptime._get_raw_data()
+
+def get_system_ram_usage() -> dict:
+ show_ram = load_op_mode_as_module('memory.py')
+ return show_ram.show(raw=True)
diff --git a/src/services/api/graphql/session/errors/op_mode_errors.py b/src/services/api/graphql/session/errors/op_mode_errors.py
new file mode 100644
index 0000000..8007672
--- /dev/null
+++ b/src/services/api/graphql/session/errors/op_mode_errors.py
@@ -0,0 +1,19 @@
+op_mode_err_msg = {
+ "UnconfiguredSubsystem": "subsystem is not configured or not running",
+ "UnconfiguredObject": "object does not exist in the system configuration",
+ "DataUnavailable": "data currently unavailable",
+ "PermissionDenied": "client does not have permission",
+ "InsufficientResources": "insufficient system resources",
+ "IncorrectValue": "argument value is incorrect",
+ "UnsupportedOperation": "operation is not supported (yet)",
+}
+
+op_mode_err_code = {
+ "UnconfiguredSubsystem": 2000,
+ "UnconfiguredObject": 2003,
+ "DataUnavailable": 2001,
+ "InsufficientResources": 2002,
+ "PermissionDenied": 1003,
+ "IncorrectValue": 1002,
+ "UnsupportedOperation": 1004,
+}
diff --git a/src/services/api/graphql/session/override/remove_firewall_address_group_members.py b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py
new file mode 100644
index 0000000..b91932e
--- /dev/null
+++ b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py
@@ -0,0 +1,35 @@
+# Copyright 2021 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/>.
+
+from . session import Session
+
+class RemoveFirewallAddressGroupMembers(Session):
+ def __init__(self, session, data):
+ super().__init__(session, data)
+
+ # Define any custom processing of parameters here by overriding
+ # configure:
+ #
+ # def configure(self):
+ # self._data = transform_data(self._data)
+ # super().configure()
+ # self.clean_up()
+
+ def configure(self):
+ super().configure()
+
+ group_name = self._data['name']
+ path = ['firewall', 'group', 'address-group', group_name]
+ self.delete_path_if_childless(path)
diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py
new file mode 100644
index 0000000..6ae44b9
--- /dev/null
+++ b/src/services/api/graphql/session/session.py
@@ -0,0 +1,211 @@
+# 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/>.
+
+import os
+import json
+
+from ariadne import convert_camel_case_to_snake
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.defaults import directories
+from vyos.opmode import Error as OpModeError
+
+from api.graphql.libs.op_mode import load_op_mode_as_module, split_compound_op_mode_name
+from api.graphql.libs.op_mode import normalize_output
+
+op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json')
+
+def get_config_dict(path=[], effective=False, key_mangling=None,
+ get_first_key=False, no_multi_convert=False,
+ no_tag_node_value_mangle=False):
+ config = Config()
+ return config.get_config_dict(path=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)
+
+def get_user_info(user):
+ user_info = {}
+ info = get_config_dict(['system', 'login', 'user', user],
+ get_first_key=True)
+ if not info:
+ raise ValueError("No such user")
+
+ user_info['user'] = user
+ user_info['full_name'] = info.get('full-name', '')
+
+ return user_info
+
+class Session:
+ """
+ Wrapper for calling configsession functions based on GraphQL requests.
+ Non-nullable fields in the respective schema allow avoiding a key check
+ in 'data'.
+ """
+ def __init__(self, session, data):
+ self._session = session
+ self._data = data
+ self._name = convert_camel_case_to_snake(type(self).__name__)
+
+ try:
+ with open(op_mode_include_file) as f:
+ self._op_mode_list = json.loads(f.read())
+ except Exception:
+ self._op_mode_list = None
+
+ def show_config(self):
+ session = self._session
+ data = self._data
+ out = ''
+
+ try:
+ out = session.show_config(data['path'])
+ if data.get('config_format', '') == 'json':
+ config_tree = ConfigTree(out)
+ out = json.loads(config_tree.to_json())
+ except Exception as error:
+ raise error
+
+ return out
+
+ def save_config_file(self):
+ session = self._session
+ data = self._data
+ if 'file_name' not in data or not data['file_name']:
+ data['file_name'] = '/config/config.boot'
+
+ try:
+ session.save_config(data['file_name'])
+ except Exception as error:
+ raise error
+
+ def load_config_file(self):
+ session = self._session
+ data = self._data
+
+ try:
+ session.load_config(data['file_name'])
+ session.commit()
+ except Exception as error:
+ raise error
+
+ def show(self):
+ session = self._session
+ data = self._data
+ out = ''
+
+ try:
+ out = session.show(data['path'])
+ except Exception as error:
+ raise error
+
+ return out
+
+ def add_system_image(self):
+ session = self._session
+ data = self._data
+
+ try:
+ res = session.install_image(data['location'])
+ except Exception as error:
+ raise error
+
+ return res
+
+ def delete_system_image(self):
+ session = self._session
+ data = self._data
+
+ try:
+ res = session.remove_image(data['name'])
+ except Exception as error:
+ raise error
+
+ return res
+
+ def show_user_info(self):
+ session = self._session
+ data = self._data
+
+ user_info = {}
+ user = data['user']
+ try:
+ user_info = get_user_info(user)
+ except Exception as error:
+ raise error
+
+ return user_info
+
+ def system_status(self):
+ import api.graphql.session.composite.system_status as system_status
+
+ session = self._session
+ data = self._data
+
+ status = {}
+ status['host_name'] = session.show(['host', 'name']).strip()
+ status['version'] = system_status.get_system_version()
+ status['uptime'] = system_status.get_system_uptime()
+ status['ram'] = system_status.get_system_ram_usage()
+
+ return status
+
+ def gen_op_query(self):
+ session = self._session
+ data = self._data
+ name = self._name
+ op_mode_list = self._op_mode_list
+
+ # handle the case that the op-mode file contains underscores:
+ if op_mode_list is None:
+ raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'")
+ (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list)
+ if scriptname == '':
+ raise FileNotFoundError(f"No op-mode file named in string '{name}'")
+
+ mod = load_op_mode_as_module(f'{scriptname}')
+ func = getattr(mod, func_name)
+ try:
+ res = func(True, **data)
+ except OpModeError as e:
+ raise e
+
+ res = normalize_output(res)
+
+ return res
+
+ def gen_op_mutation(self):
+ session = self._session
+ data = self._data
+ name = self._name
+ op_mode_list = self._op_mode_list
+
+ # handle the case that the op-mode file name contains underscores:
+ if op_mode_list is None:
+ raise FileNotFoundError(f"No op-mode file list at '{op_mode_include_file}'")
+ (func_name, scriptname) = split_compound_op_mode_name(name, op_mode_list)
+ if scriptname == '':
+ raise FileNotFoundError(f"No op-mode file named in string '{name}'")
+
+ mod = load_op_mode_as_module(f'{scriptname}')
+ func = getattr(mod, func_name)
+ try:
+ res = func(**data)
+ except OpModeError as e:
+ raise e
+
+ return res
diff --git a/src/services/api/graphql/session/templates/create_dhcp_server.tmpl b/src/services/api/graphql/session/templates/create_dhcp_server.tmpl
new file mode 100644
index 0000000..70de431
--- /dev/null
+++ b/src/services/api/graphql/session/templates/create_dhcp_server.tmpl
@@ -0,0 +1,9 @@
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} default-router {{ default_router }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} name-server {{ name_server }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} domain-name {{ domain_name }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} lease {{ lease }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} start {{ start }}
+set service dhcp-server shared-network-name {{ shared_network_name }} subnet {{ subnet }} range {{ range }} stop {{ stop }}
+set service dns forwarding allow-from {{ dns_forwarding_allow_from }}
+set service dns forwarding cache-size {{ dns_forwarding_cache_size }}
+set service dns forwarding listen-address {{ dns_forwarding_listen_address }}
diff --git a/src/services/api/graphql/session/templates/create_firewall_address_group.tmpl b/src/services/api/graphql/session/templates/create_firewall_address_group.tmpl
new file mode 100644
index 0000000..a890d00
--- /dev/null
+++ b/src/services/api/graphql/session/templates/create_firewall_address_group.tmpl
@@ -0,0 +1,4 @@
+set firewall group address-group {{ name }}
+{% for add in address %}
+set firewall group address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl b/src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl
new file mode 100644
index 0000000..e9b6607
--- /dev/null
+++ b/src/services/api/graphql/session/templates/create_firewall_address_ipv_6_group.tmpl
@@ -0,0 +1,4 @@
+set firewall group ipv6-address-group {{ name }}
+{% for add in address %}
+set firewall group ipv6-address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/session/templates/create_interface_ethernet.tmpl b/src/services/api/graphql/session/templates/create_interface_ethernet.tmpl
new file mode 100644
index 0000000..d9d7ed6
--- /dev/null
+++ b/src/services/api/graphql/session/templates/create_interface_ethernet.tmpl
@@ -0,0 +1,5 @@
+{% if replace %}
+delete interfaces ethernet {{ interface }} address
+{% endif %}
+set interfaces ethernet {{ interface }} address {{ address }}
+set interfaces ethernet {{ interface }} description {{ description }}
diff --git a/src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl b/src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl
new file mode 100644
index 0000000..458f3e5
--- /dev/null
+++ b/src/services/api/graphql/session/templates/remove_firewall_address_group_members.tmpl
@@ -0,0 +1,3 @@
+{% for add in address %}
+delete firewall group address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl
new file mode 100644
index 0000000..0efa0b2
--- /dev/null
+++ b/src/services/api/graphql/session/templates/remove_firewall_address_ipv_6_group_members.tmpl
@@ -0,0 +1,3 @@
+{% for add in address %}
+delete firewall group ipv6-address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl b/src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl
new file mode 100644
index 0000000..f56c612
--- /dev/null
+++ b/src/services/api/graphql/session/templates/update_firewall_address_group_members.tmpl
@@ -0,0 +1,3 @@
+{% for add in address %}
+set firewall group address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl b/src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl
new file mode 100644
index 0000000..f98a551
--- /dev/null
+++ b/src/services/api/graphql/session/templates/update_firewall_address_ipv_6_group_members.tmpl
@@ -0,0 +1,3 @@
+{% for add in address %}
+set firewall group ipv6-address-group {{ name }} address {{ add }}
+{% endfor %}
diff --git a/src/services/api/graphql/state.py b/src/services/api/graphql/state.py
new file mode 100644
index 0000000..63db9f4
--- /dev/null
+++ b/src/services/api/graphql/state.py
@@ -0,0 +1,4 @@
+
+def init():
+ global settings
+ settings = {}
diff --git a/src/services/vyos-configd b/src/services/vyos-configd
new file mode 100644
index 0000000..cb23642
--- /dev/null
+++ b/src/services/vyos-configd
@@ -0,0 +1,340 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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/>.
+
+# pylint: disable=redefined-outer-name
+
+import os
+import sys
+import grp
+import re
+import json
+import typing
+import logging
+import signal
+import traceback
+import importlib.util
+import io
+from contextlib import redirect_stdout
+
+import zmq
+
+from vyos.defaults import directories
+from vyos.utils.boot import boot_configuration_complete
+from vyos.configsource import ConfigSourceString
+from vyos.configsource import ConfigSourceError
+from vyos.configdiff import get_commit_scripts
+from vyos.config import Config
+from vyos import ConfigError
+
+CFG_GROUP = 'vyattacfg'
+
+script_stdout_log = '/tmp/vyos-configd-script-stdout'
+
+debug = True
+
+logger = logging.getLogger(__name__)
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+if debug:
+ logger.setLevel(logging.DEBUG)
+else:
+ logger.setLevel(logging.INFO)
+
+SOCKET_PATH = 'ipc:///run/vyos-configd.sock'
+MAX_MSG_SIZE = 65535
+
+# Response error codes
+R_SUCCESS = 1
+R_ERROR_COMMIT = 2
+R_ERROR_DAEMON = 4
+R_PASS = 8
+
+vyos_conf_scripts_dir = directories['conf_mode']
+configd_include_file = os.path.join(directories['data'], 'configd-include.json')
+configd_env_set_file = os.path.join(directories['data'], 'vyos-configd-env-set')
+configd_env_unset_file = os.path.join(directories['data'], 'vyos-configd-env-unset')
+# sourced on entering config session
+configd_env_file = '/etc/default/vyos-configd-env'
+
+def key_name_from_file_name(f):
+ return os.path.splitext(f)[0]
+
+def module_name_from_key(k):
+ return k.replace('-', '_')
+
+def path_from_file_name(f):
+ return os.path.join(vyos_conf_scripts_dir, f)
+
+
+# opt-in to be run by daemon
+with open(configd_include_file) as f:
+ try:
+ include = json.load(f)
+ except OSError as e:
+ logger.critical(f'configd include file error: {e}')
+ sys.exit(1)
+ except json.JSONDecodeError as e:
+ logger.critical(f'JSON load error: {e}')
+ sys.exit(1)
+
+
+# import conf_mode scripts
+(_, _, filenames) = next(iter(os.walk(vyos_conf_scripts_dir)))
+filenames.sort()
+
+load_filenames = [f for f in filenames if f in include]
+imports = [key_name_from_file_name(f) for f in load_filenames]
+module_names = [module_name_from_key(k) for k in imports]
+paths = [path_from_file_name(f) for f in load_filenames]
+to_load = list(zip(module_names, paths))
+
+modules = []
+
+for x in to_load:
+ spec = importlib.util.spec_from_file_location(x[0], x[1])
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ modules.append(module)
+
+conf_mode_scripts = dict(zip(imports, modules))
+
+exclude_set = {key_name_from_file_name(f) for f in filenames if f not in include}
+include_set = {key_name_from_file_name(f) for f in filenames if f in include}
+
+
+def write_stdout_log(file_name, msg):
+ if boot_configuration_complete():
+ return
+ with open(file_name, 'a') as f:
+ f.write(msg)
+
+
+def run_script(script_name, config, args) -> tuple[int, str]:
+ # pylint: disable=broad-exception-caught
+
+ script = conf_mode_scripts[script_name]
+ script.argv = args
+ config.set_level([])
+ try:
+ c = script.get_config(config)
+ script.verify(c)
+ script.generate(c)
+ script.apply(c)
+ except ConfigError as e:
+ logger.error(e)
+ return R_ERROR_COMMIT, str(e)
+ except Exception:
+ tb = traceback.format_exc()
+ logger.error(tb)
+ return R_ERROR_COMMIT, tb
+
+ return R_SUCCESS, ''
+
+
+def initialization(socket):
+ # pylint: disable=broad-exception-caught,too-many-locals
+
+ # Reset config strings:
+ active_string = ''
+ session_string = ''
+ # check first for resent init msg, in case of client timeout
+ while True:
+ msg = socket.recv().decode('utf-8', 'ignore')
+ try:
+ message = json.loads(msg)
+ if message['type'] == 'init':
+ resp = 'init'
+ socket.send(resp.encode())
+ except Exception:
+ break
+
+ # zmq synchronous for ipc from single client:
+ active_string = msg
+ resp = 'active'
+ socket.send(resp.encode())
+ session_string = socket.recv().decode('utf-8', 'ignore')
+ resp = 'session'
+ socket.send(resp.encode())
+ pid_string = socket.recv().decode('utf-8', 'ignore')
+ resp = 'pid'
+ socket.send(resp.encode())
+ sudo_user_string = socket.recv().decode('utf-8', 'ignore')
+ resp = 'sudo_user'
+ socket.send(resp.encode())
+ temp_config_dir_string = socket.recv().decode('utf-8', 'ignore')
+ resp = 'temp_config_dir'
+ socket.send(resp.encode())
+ changes_only_dir_string = socket.recv().decode('utf-8', 'ignore')
+ resp = 'changes_only_dir'
+ socket.send(resp.encode())
+
+ logger.debug(f'config session pid is {pid_string}')
+ logger.debug(f'config session sudo_user is {sudo_user_string}')
+
+ os.environ['SUDO_USER'] = sudo_user_string
+ if temp_config_dir_string:
+ os.environ['VYATTA_TEMP_CONFIG_DIR'] = temp_config_dir_string
+ if changes_only_dir_string:
+ os.environ['VYATTA_CHANGES_ONLY_DIR'] = changes_only_dir_string
+
+ try:
+ configsource = ConfigSourceString(running_config_text=active_string,
+ session_config_text=session_string)
+ except ConfigSourceError as e:
+ logger.debug(e)
+ return None
+
+ config = Config(config_source=configsource)
+ dependent_func: dict[str, list[typing.Callable]] = {}
+ setattr(config, 'dependent_func', dependent_func)
+
+ commit_scripts = get_commit_scripts(config)
+ logger.debug(f'commit_scripts: {commit_scripts}')
+
+ scripts_called = []
+ setattr(config, 'scripts_called', scripts_called)
+
+ return config
+
+
+def process_node_data(config, data, _last: bool = False) -> tuple[int, str]:
+ if not config:
+ out = 'Empty config'
+ logger.critical(out)
+ return R_ERROR_DAEMON, out
+
+ script_name = None
+ os.environ['VYOS_TAGNODE_VALUE'] = ''
+ args = []
+ config.dependency_list.clear()
+
+ res = re.match(r'^(VYOS_TAGNODE_VALUE=[^/]+)?.*\/([^/]+).py(.*)', data)
+ if res.group(1):
+ env = res.group(1).split('=')
+ os.environ[env[0]] = env[1]
+ if res.group(2):
+ script_name = res.group(2)
+ if not script_name:
+ out = 'Missing script_name'
+ logger.critical(out)
+ return R_ERROR_DAEMON, out
+ if res.group(3):
+ args = res.group(3).split()
+ args.insert(0, f'{script_name}.py')
+
+ tag_value = os.getenv('VYOS_TAGNODE_VALUE', '')
+ tag_ext = f'_{tag_value}' if tag_value else ''
+ script_record = f'{script_name}{tag_ext}'
+ scripts_called = getattr(config, 'scripts_called', [])
+ scripts_called.append(script_record)
+
+ if script_name not in include_set:
+ return R_PASS, ''
+
+ with redirect_stdout(io.StringIO()) as o:
+ result, err_out = run_script(script_name, config, args)
+ amb_out = o.getvalue()
+ o.close()
+
+ out = amb_out + err_out
+
+ return result, out
+
+
+def send_result(sock, err, msg):
+ msg_size = min(MAX_MSG_SIZE, len(msg)) if msg else 0
+
+ err_rep = err.to_bytes(1, byteorder=sys.byteorder)
+ logger.debug(f'Sending reply: {err}')
+ sock.send(err_rep)
+
+ # size req from vyshim client
+ size_req = sock.recv().decode()
+ logger.debug(f'Received request: {size_req}')
+ msg_size_rep = hex(msg_size).encode()
+ sock.send(msg_size_rep)
+ logger.debug(f'Sending reply: {msg_size}')
+
+ if msg_size > 0:
+ # send req is sent from vyshim client only if msg_size > 0
+ send_req = sock.recv().decode()
+ logger.debug(f'Received request: {send_req}')
+ sock.send(msg.encode())
+ logger.debug('Sending reply with output')
+
+ write_stdout_log(script_stdout_log, msg)
+
+
+def remove_if_file(f: str):
+ try:
+ os.remove(f)
+ except FileNotFoundError:
+ pass
+
+
+def shutdown():
+ remove_if_file(configd_env_file)
+ os.symlink(configd_env_unset_file, configd_env_file)
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ context = zmq.Context()
+ socket = context.socket(zmq.REP)
+
+ # Set the right permissions on the socket, then change it back
+ o_mask = os.umask(0)
+ socket.bind(SOCKET_PATH)
+ os.umask(o_mask)
+
+ cfg_group = grp.getgrnam(CFG_GROUP)
+ os.setgid(cfg_group.gr_gid)
+
+ os.environ['VYOS_CONFIGD'] = 't'
+
+ def sig_handler(signum, frame):
+ # pylint: disable=unused-argument
+ shutdown()
+
+ signal.signal(signal.SIGTERM, sig_handler)
+ signal.signal(signal.SIGINT, sig_handler)
+
+ # Define the vyshim environment variable
+ remove_if_file(configd_env_file)
+ os.symlink(configd_env_set_file, configd_env_file)
+
+ config = None
+
+ while True:
+ # Wait for next request from client
+ msg = socket.recv().decode()
+ logger.debug(f'Received message: {msg}')
+ message = json.loads(msg)
+
+ if message['type'] == 'init':
+ resp = 'init'
+ socket.send(resp.encode())
+ config = initialization(socket)
+ elif message['type'] == 'node':
+ res, out = process_node_data(config, message['data'], message['last'])
+ send_result(socket, res, out)
+
+ if message['last'] and config:
+ scripts_called = getattr(config, 'scripts_called', [])
+ logger.debug(f'scripts_called: {scripts_called}')
+ else:
+ logger.critical(f'Unexpected message: {message}')
diff --git a/src/services/vyos-conntrack-logger b/src/services/vyos-conntrack-logger
new file mode 100644
index 0000000..9c31b46
--- /dev/null
+++ b/src/services/vyos-conntrack-logger
@@ -0,0 +1,458 @@
+#!/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 argparse
+import grp
+import logging
+import multiprocessing
+import os
+import queue
+import signal
+import socket
+import threading
+from datetime import timedelta
+from pathlib import Path
+from time import sleep
+from typing import Dict, AnyStr
+
+from pyroute2 import conntrack
+from pyroute2.netlink import nfnetlink
+from pyroute2.netlink.nfnetlink import NFNL_SUBSYS_CTNETLINK
+from pyroute2.netlink.nfnetlink.nfctsocket import nfct_msg, \
+ IPCTNL_MSG_CT_DELETE, IPCTNL_MSG_CT_NEW, IPS_SEEN_REPLY, \
+ IPS_OFFLOAD, IPS_ASSURED
+
+from vyos.utils.file import read_json
+
+
+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)
+
+
+EVENT_NAME_TO_GROUP = {
+ 'new': nfnetlink.NFNLGRP_CONNTRACK_NEW,
+ 'update': nfnetlink.NFNLGRP_CONNTRACK_UPDATE,
+ 'destroy': nfnetlink.NFNLGRP_CONNTRACK_DESTROY
+}
+
+# https://github.com/torvalds/linux/blob/1dfe225e9af5bd3399a1dbc6a4df6a6041ff9c23/include/uapi/linux/netfilter/nf_conntrack_tcp.h#L9
+TCP_CONNTRACK_SYN_SENT = 1
+TCP_CONNTRACK_SYN_RECV = 2
+TCP_CONNTRACK_ESTABLISHED = 3
+TCP_CONNTRACK_FIN_WAIT = 4
+TCP_CONNTRACK_CLOSE_WAIT = 5
+TCP_CONNTRACK_LAST_ACK = 6
+TCP_CONNTRACK_TIME_WAIT = 7
+TCP_CONNTRACK_CLOSE = 8
+TCP_CONNTRACK_LISTEN = 9
+TCP_CONNTRACK_MAX = 10
+TCP_CONNTRACK_IGNORE = 11
+TCP_CONNTRACK_RETRANS = 12
+TCP_CONNTRACK_UNACK = 13
+TCP_CONNTRACK_TIMEOUT_MAX = 14
+
+TCP_CONNTRACK_TO_NAME = {
+ TCP_CONNTRACK_SYN_SENT: "SYN_SENT",
+ TCP_CONNTRACK_SYN_RECV: "SYN_RECV",
+ TCP_CONNTRACK_ESTABLISHED: "ESTABLISHED",
+ TCP_CONNTRACK_FIN_WAIT: "FIN_WAIT",
+ TCP_CONNTRACK_CLOSE_WAIT: "CLOSE_WAIT",
+ TCP_CONNTRACK_LAST_ACK: "LAST_ACK",
+ TCP_CONNTRACK_TIME_WAIT: "TIME_WAIT",
+ TCP_CONNTRACK_CLOSE: "CLOSE",
+ TCP_CONNTRACK_LISTEN: "LISTEN",
+ TCP_CONNTRACK_MAX: "MAX",
+ TCP_CONNTRACK_IGNORE: "IGNORE",
+ TCP_CONNTRACK_RETRANS: "RETRANS",
+ TCP_CONNTRACK_UNACK: "UNACK",
+ TCP_CONNTRACK_TIMEOUT_MAX: "TIMEOUT_MAX",
+}
+
+# https://github.com/torvalds/linux/blob/1dfe225e9af5bd3399a1dbc6a4df6a6041ff9c23/include/uapi/linux/netfilter/nf_conntrack_sctp.h#L8
+SCTP_CONNTRACK_CLOSED = 1
+SCTP_CONNTRACK_COOKIE_WAIT = 2
+SCTP_CONNTRACK_COOKIE_ECHOED = 3
+SCTP_CONNTRACK_ESTABLISHED = 4
+SCTP_CONNTRACK_SHUTDOWN_SENT = 5
+SCTP_CONNTRACK_SHUTDOWN_RECD = 6
+SCTP_CONNTRACK_SHUTDOWN_ACK_SENT = 7
+SCTP_CONNTRACK_HEARTBEAT_SENT = 8
+SCTP_CONNTRACK_HEARTBEAT_ACKED = 9 # no longer used
+SCTP_CONNTRACK_MAX = 10
+
+SCTP_CONNTRACK_TO_NAME = {
+ SCTP_CONNTRACK_CLOSED: 'CLOSED',
+ SCTP_CONNTRACK_COOKIE_WAIT: 'COOKIE_WAIT',
+ SCTP_CONNTRACK_COOKIE_ECHOED: 'COOKIE_ECHOED',
+ SCTP_CONNTRACK_ESTABLISHED: 'ESTABLISHED',
+ SCTP_CONNTRACK_SHUTDOWN_SENT: 'SHUTDOWN_SENT',
+ SCTP_CONNTRACK_SHUTDOWN_RECD: 'SHUTDOWN_RECD',
+ SCTP_CONNTRACK_SHUTDOWN_ACK_SENT: 'SHUTDOWN_ACK_SENT',
+ SCTP_CONNTRACK_HEARTBEAT_SENT: 'HEARTBEAT_SENT',
+ SCTP_CONNTRACK_HEARTBEAT_ACKED: 'HEARTBEAT_ACKED',
+ SCTP_CONNTRACK_MAX: 'MAX',
+}
+
+PROTO_CONNTRACK_TO_NAME = {
+ 'TCP': TCP_CONNTRACK_TO_NAME,
+ 'SCTP': SCTP_CONNTRACK_TO_NAME
+}
+
+SUPPORTED_PROTO_TO_NAME = {
+ socket.IPPROTO_ICMP: 'icmp',
+ socket.IPPROTO_TCP: 'tcp',
+ socket.IPPROTO_UDP: 'udp',
+}
+
+PROTO_TO_NAME = {
+ socket.IPPROTO_ICMPV6: 'icmpv6',
+ socket.IPPROTO_SCTP: 'sctp',
+ socket.IPPROTO_GRE: 'gre',
+}
+
+PROTO_TO_NAME.update(SUPPORTED_PROTO_TO_NAME)
+
+
+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 format_flow_data(data: Dict) -> AnyStr:
+ """
+ Formats the flow event data into a string suitable for logging.
+ """
+ key_format = {
+ 'SRC_PORT': 'sport',
+ 'DST_PORT': 'dport'
+ }
+ message = f"src={data['ADDR'].get('SRC')} dst={data['ADDR'].get('DST')}"
+
+ for key in ['SRC_PORT', 'DST_PORT', 'TYPE', 'CODE', 'ID']:
+ tmp = data['PROTO'].get(key)
+ if tmp is not None:
+ key = key_format.get(key, key)
+ message += f" {key.lower()}={tmp}"
+
+ if 'COUNTERS' in data:
+ for key in ['PACKETS', 'BYTES']:
+ tmp = data['COUNTERS'].get(key)
+ if tmp is not None:
+ message += f" {key.lower()}={tmp}"
+
+ return message
+
+
+def format_event_message(event: Dict) -> AnyStr:
+ """
+ Formats the internal parsed event data into a string suitable for logging.
+ """
+ event_type = f"[{event['COMMON']['EVENT_TYPE'].upper()}]"
+ message = f"{event_type:<{9}} {event['COMMON']['ID']} " \
+ f"{event['ORIG']['PROTO'].get('NAME'):<{8}} " \
+ f"{event['ORIG']['PROTO'].get('NUMBER')} "
+
+ tmp = event['COMMON']['TIME_OUT']
+ if tmp is not None: message += f"{tmp} "
+
+ if proto_info := event['COMMON'].get('PROTO_INFO'):
+ message += f"{proto_info.get('STATE_NAME')} "
+
+ for key in ['ORIG', 'REPLY']:
+ message += f"{format_flow_data(event[key])} "
+ if key == 'ORIG' and not (event['COMMON']['STATUS'] & IPS_SEEN_REPLY):
+ message += f"[UNREPLIED] "
+
+ tmp = event['COMMON']['MARK']
+ if tmp is not None: message += f"mark={tmp} "
+
+ if event['COMMON']['STATUS'] & IPS_OFFLOAD: message += f" [OFFLOAD] "
+ elif event['COMMON']['STATUS'] & IPS_ASSURED: message += f" [ASSURED] "
+
+ if tmp := event['COMMON']['PORTID']: message += f"portid={tmp} "
+ if tstamp := event['COMMON'].get('TIMESTAMP'):
+ message += f"start={tstamp['START']} stop={tstamp['STOP']} "
+ delta_ns = tstamp['STOP'] - tstamp['START']
+ delta_s = delta_ns // 1e9
+ remaining_ns = delta_ns % 1e9
+ delta = timedelta(seconds=delta_s, microseconds=remaining_ns / 1000)
+ message += f"delta={delta.total_seconds()} "
+
+ return message
+
+
+def parse_event_type(header: Dict) -> AnyStr:
+ """
+ Extract event type from nfct_msg. new, update, destroy
+ """
+ event_type = 'unknown'
+ if header['type'] == IPCTNL_MSG_CT_DELETE | (NFNL_SUBSYS_CTNETLINK << 8):
+ event_type = 'destroy'
+ elif header['type'] == IPCTNL_MSG_CT_NEW | (NFNL_SUBSYS_CTNETLINK << 8):
+ event_type = 'update'
+ if header['flags']:
+ event_type = 'new'
+ return event_type
+
+
+def parse_proto(cta: nfct_msg.cta_tuple) -> Dict:
+ """
+ Extract proto info from nfct_msg. src/dst port, code, type, id
+ """
+ data = dict()
+
+ cta_proto = cta.get_attr('CTA_TUPLE_PROTO')
+ proto_num = cta_proto.get_attr('CTA_PROTO_NUM')
+
+ data['NUMBER'] = proto_num
+ data['NAME'] = PROTO_TO_NAME.get(proto_num, 'unknown')
+
+ if proto_num in (socket.IPPROTO_ICMP, socket.IPPROTO_ICMPV6):
+ pref = 'CTA_PROTO_ICMP'
+ if proto_num == socket.IPPROTO_ICMPV6: pref += 'V6'
+ keys = ['TYPE', 'CODE', 'ID']
+ else:
+ pref = 'CTA_PROTO'
+ keys = ['SRC_PORT', 'DST_PORT']
+
+ for key in keys:
+ data[key] = cta_proto.get_attr(f'{pref}_{key}')
+
+ return data
+
+
+def parse_proto_info(cta: nfct_msg.cta_protoinfo) -> Dict:
+ """
+ Extract proto state and state name from nfct_msg
+ """
+ data = dict()
+ if not cta:
+ return data
+
+ for proto in ['TCP', 'SCTP']:
+ if proto_info := cta.get_attr(f'CTA_PROTOINFO_{proto}'):
+ data['STATE'] = proto_info.get_attr(f'CTA_PROTOINFO_{proto}_STATE')
+ data['STATE_NAME'] = PROTO_CONNTRACK_TO_NAME.get(proto, {}).get(data['STATE'], 'unknown')
+ return data
+
+
+def parse_timestamp(cta: nfct_msg.cta_timestamp) -> Dict:
+ """
+ Extract timestamp from nfct_msg
+ """
+ data = dict()
+ if not cta:
+ return data
+ data['START'] = cta.get_attr('CTA_TIMESTAMP_START')
+ data['STOP'] = cta.get_attr('CTA_TIMESTAMP_STOP')
+
+ return data
+
+
+def parse_ip_addr(family: int, cta: nfct_msg.cta_tuple) -> Dict:
+ """
+ Extract ip adr from nfct_msg
+ """
+ data = dict()
+ cta_ip = cta.get_attr('CTA_TUPLE_IP')
+
+ if family == socket.AF_INET:
+ pref = 'CTA_IP_V4'
+ elif family == socket.AF_INET6:
+ pref = 'CTA_IP_V6'
+ else:
+ logger.error(f'Undefined INET: {family}')
+ raise NotImplementedError(family)
+
+ for direct in ['SRC', 'DST']:
+ data[direct] = cta_ip.get_attr(f'{pref}_{direct}')
+
+ return data
+
+
+def parse_counters(cta: nfct_msg.cta_counters) -> Dict:
+ """
+ Extract counters from nfct_msg
+ """
+ data = dict()
+ if not cta:
+ return data
+
+ for key in ['PACKETS', 'BYTES']:
+ tmp = cta.get_attr(f'CTA_COUNTERS_{key}')
+ if tmp is None:
+ tmp = cta.get_attr(f'CTA_COUNTERS32_{key}')
+ data['key'] = tmp
+
+ return data
+
+
+def is_need_to_log(event_type: AnyStr, proto_num: int, conf_event: Dict):
+ """
+ Filter message by event type and protocols
+ """
+ conf = conf_event.get(event_type)
+ if conf == {} or conf.get(SUPPORTED_PROTO_TO_NAME.get(proto_num, 'other')) is not None:
+ return True
+ return False
+
+
+def parse_conntrack_event(msg: nfct_msg, conf_event: Dict) -> Dict:
+ """
+ Convert nfct_msg to internal data dict.
+ """
+ data = dict()
+ event_type = parse_event_type(msg['header'])
+ proto_num = msg.get_nested('CTA_TUPLE_ORIG', 'CTA_TUPLE_PROTO', 'CTA_PROTO_NUM')
+
+ if not is_need_to_log(event_type, proto_num, conf_event):
+ return data
+
+ data = {
+ 'COMMON': {
+ 'ID': msg.get_attr('CTA_ID'),
+ 'EVENT_TYPE': event_type,
+ 'TIME_OUT': msg.get_attr('CTA_TIMEOUT'),
+ 'MARK': msg.get_attr('CTA_MARK'),
+ 'PORTID': msg['header'].get('pid'),
+ 'PROTO_INFO': parse_proto_info(msg.get_attr('CTA_PROTOINFO')),
+ 'STATUS': msg.get_attr('CTA_STATUS'),
+ 'TIMESTAMP': parse_timestamp(msg.get_attr('CTA_TIMESTAMP'))
+ },
+ 'ORIG': {},
+ 'REPLY': {},
+ }
+
+ for direct in ['ORIG', 'REPLY']:
+ data[direct]['ADDR'] = parse_ip_addr(msg['nfgen_family'], msg.get_attr(f'CTA_TUPLE_{direct}'))
+ data[direct]['PROTO'] = parse_proto(msg.get_attr(f'CTA_TUPLE_{direct}'))
+ data[direct]['COUNTERS'] = parse_counters(msg.get_attr(f'CTA_COUNTERS_{direct}'))
+
+ return data
+
+
+def worker(ct: conntrack.Conntrack, shutdown_event: multiprocessing.Event, conf_event: Dict):
+ """
+ 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():
+ try:
+ for msg in ct.get():
+ parsed_event = parse_conntrack_event(msg, conf_event)
+ if parsed_event:
+ message = format_event_message(parsed_event)
+ if logger.level == logging.DEBUG:
+ logger.debug(f"[{process_name}]: {message} raw: {msg}")
+ else:
+ logger.info(message)
+ except queue.Full:
+ logger.error("Conntrack message queue if full.")
+ except Exception as e:
+ logger.error(f"Error in queue: {e.__class__} {e}")
+ else:
+ sleep(timeout)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-c',
+ '--config',
+ action='store',
+ help='Path to vyos-conntrack-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 = conntrack.Conntrack(async_qsize=int(qsize) if qsize else None)
+ ct.buffer_queue = multiprocessing.Queue(ct.async_qsize)
+ ct.bind(async_cache=True)
+
+ for name in event_groups:
+ if group := EVENT_NAME_TO_GROUP.get(name):
+ ct.add_membership(group)
+ else:
+ logger.error(f'Unexpected event group {name}')
+ 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('Conntrack 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("Conntrack socket closed.")
+ exit()
diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd
new file mode 100644
index 0000000..1ba9047
--- /dev/null
+++ b/src/services/vyos-hostsd
@@ -0,0 +1,651 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019-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/>.
+#
+#########
+# USAGE #
+#########
+# This daemon listens on its socket for JSON messages.
+# The received message format is:
+#
+# { 'type': '<message type>',
+# 'op': '<message operation>',
+# 'data': <data list or dict>
+# }
+#
+# For supported message types, see below.
+# 'op' can be 'add', delete', 'get', 'set' or 'apply'.
+# Different message types support different sets of operations and different
+# data formats.
+#
+# Changes to configuration made via add or delete don't take effect immediately,
+# they are remembered in a state variable and saved to disk to a state file.
+# State is remembered across daemon restarts but not across system reboots
+# as it's saved in a temporary filesystem (/run).
+#
+# 'apply' is a special operation that applies the configuration from the cached
+# state, rendering all config files and reloading relevant daemons (currently
+# just pdns-recursor via rec-control).
+#
+# note: 'add' operation also acts as 'update' as it uses dict.update, if the
+# 'data' dict item value is a dict. If it is a list, it uses list.append.
+#
+### tags
+# Tags can be arbitrary, but they are generally in this format:
+# 'static', 'system', 'dhcp(v6)-<intf>' or 'dhcp-server-<client ip>'
+# They are used to distinguish entries created by different scripts so they can
+# be removed and recreated without having to track what needs to be changed.
+# They are also used as a way to control which tags settings (e.g. nameservers)
+# get added to various config files via name_server_tags_(recursor|system)
+#
+### name_server_tags_(recursor|system)
+# A list of tags whose nameservers and search domains is used to generate
+# /etc/resolv.conf and pdns-recursor config.
+# system list is used to generate resolv.conf.
+# recursor list is used to generate pdns-rec forward-zones.
+# When generating each file, the order of nameservers is as per the order of
+# name_server_tags (the order in which tags were added), then the order in
+# which the name servers for each tag were added.
+#
+#### Message types
+#
+### name_servers
+#
+# { 'type': 'name_servers',
+# 'op': 'add',
+# 'data': {
+# '<str tag>': ['<str nameserver>', ...],
+# ...
+# }
+# }
+#
+# { 'type': 'name_servers',
+# 'op': 'delete',
+# 'data': ['<str tag>', ...]
+# }
+#
+# { 'type': 'name_servers',
+# 'op': 'get',
+# 'tag_regex': '<str regex>'
+# }
+# response:
+# { 'data': {
+# '<str tag>': ['<str nameserver>', ...],
+# ...
+# }
+# }
+#
+### name_server_tags
+#
+# { 'type': 'name_server_tags',
+# 'op': 'add',
+# 'data': ['<str tag>', ...]
+# }
+#
+# { 'type': 'name_server_tags',
+# 'op': 'delete',
+# 'data': ['<str tag>', ...]
+# }
+#
+# { 'type': 'name_server_tags',
+# 'op': 'get',
+# }
+# response:
+# { 'data': ['<str tag>', ...] }
+#
+### forward_zones
+## Additional zones added to pdns-recursor forward-zones-file.
+## If recursion_desired is true, '+' will be prepended to the zone line.
+## If addnta is true, a NTA (Negative Trust Anchor) will be added via
+## lua-config-file.
+#
+# { 'type': 'forward_zones',
+# 'op': 'add',
+# 'data': {
+# '<str zone>': {
+# 'server': ['<str nameserver>', ...],
+# 'addnta': <bool>,
+# 'recursion_desired': <bool>
+# }
+# ...
+# }
+# }
+#
+# { 'type': 'forward_zones',
+# 'op': 'delete',
+# 'data': ['<str zone>', ...]
+# }
+#
+# { 'type': 'forward_zones',
+# 'op': 'get',
+# }
+# response:
+# { 'data': {
+# '<str zone>': { ... },
+# ...
+# }
+# }
+#
+#
+### authoritative_zones
+## Additional zones hosted authoritatively by pdns-recursor.
+## We add NTAs for these zones but do not do much else here.
+#
+# { 'type': 'authoritative_zones',
+# 'op': 'add',
+# 'data': ['<str zone>', ...]
+# }
+#
+# { 'type': 'authoritative_zones',
+# 'op': 'delete',
+# 'data': ['<str zone>', ...]
+# }
+#
+# { 'type': 'authoritative_zones',
+# 'op': 'get',
+# }
+# response:
+# { 'data': ['<str zone>', ...] }
+#
+#
+### search_domains
+#
+# { 'type': 'search_domains',
+# 'op': 'add',
+# 'data': {
+# '<str tag>': ['<str domain>', ...],
+# ...
+# }
+# }
+#
+# { 'type': 'search_domains',
+# 'op': 'delete',
+# 'data': ['<str tag>', ...]
+# }
+#
+# { 'type': 'search_domains',
+# 'op': 'get',
+# }
+# response:
+# { 'data': {
+# '<str tag>': ['<str domain>', ...],
+# ...
+# }
+# }
+#
+### hosts
+#
+# { 'type': 'hosts',
+# 'op': 'add',
+# 'data': {
+# '<str tag>': {
+# '<str host>': {
+# 'address': '<str address>',
+# 'aliases': ['<str alias>, ...]
+# },
+# ...
+# },
+# ...
+# }
+# }
+#
+# { 'type': 'hosts',
+# 'op': 'delete',
+# 'data': ['<str tag>', ...]
+# }
+#
+# { 'type': 'hosts',
+# 'op': 'get'
+# 'tag_regex': '<str regex>'
+# }
+# response:
+# { 'data': {
+# '<str tag>': {
+# '<str host>': {
+# 'address': '<str address>',
+# 'aliases': ['<str alias>, ...]
+# },
+# ...
+# },
+# ...
+# }
+# }
+### host_name
+#
+# { 'type': 'host_name',
+# 'op': 'set',
+# 'data': {
+# 'host_name': '<str hostname>'
+# 'domain_name': '<str domainname>'
+# }
+# }
+
+import os
+import sys
+import time
+import json
+import signal
+import traceback
+import re
+import logging
+import zmq
+
+from voluptuous import Schema, MultipleInvalid, Required, Any
+from collections import OrderedDict
+from vyos.utils.file import makedir
+from vyos.utils.permission import chown
+from vyos.utils.permission import chmod_755
+from vyos.utils.process import popen
+from vyos.utils.process import process_named_running
+from vyos.template import render
+
+debug = True
+
+# Configure logging
+logger = logging.getLogger(__name__)
+# set stream as output
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+if debug:
+ logger.setLevel(logging.DEBUG)
+else:
+ logger.setLevel(logging.INFO)
+
+RUN_DIR = "/run/vyos-hostsd"
+STATE_FILE = os.path.join(RUN_DIR, "vyos-hostsd.state")
+SOCKET_PATH = "ipc://" + os.path.join(RUN_DIR, 'vyos-hostsd.sock')
+
+RESOLV_CONF_FILE = '/etc/resolv.conf'
+HOSTS_FILE = '/etc/hosts'
+
+PDNS_REC_USER_GROUP = 'pdns'
+PDNS_REC_RUN_DIR = '/run/pdns-recursor'
+PDNS_REC_LUA_CONF_FILE = f'{PDNS_REC_RUN_DIR}/recursor.vyos-hostsd.conf.lua'
+PDNS_REC_ZONES_FILE = f'{PDNS_REC_RUN_DIR}/recursor.forward-zones.conf'
+
+STATE = {
+ "name_servers": {},
+ "name_server_tags_recursor": [],
+ "name_server_tags_system": [],
+ "forward_zones": {},
+ "authoritative_zones": [],
+ "hosts": {},
+ "host_name": "vyos",
+ "domain_name": "",
+ "search_domains": {},
+ "changes": 0
+ }
+
+# the base schema that every received message must be in
+base_schema = Schema({
+ Required('op'): Any('add', 'delete', 'set', 'get', 'apply'),
+ 'type': Any('name_servers',
+ 'name_server_tags_recursor', 'name_server_tags_system',
+ 'forward_zones', 'authoritative_zones', 'search_domains',
+ 'hosts', 'host_name'),
+ 'data': Any(list, dict),
+ 'tag': str,
+ 'tag_regex': str
+ })
+
+# more specific schemas
+op_schema = Schema({
+ 'op': str,
+ }, required=True)
+
+op_type_schema = op_schema.extend({
+ 'type': str,
+ }, required=True)
+
+host_name_add_schema = op_type_schema.extend({
+ 'data': {
+ 'host_name': str,
+ 'domain_name': Any(str, None)
+ }
+ }, required=True)
+
+data_dict_list_schema = op_type_schema.extend({
+ 'data': {
+ str: [str]
+ }
+ }, required=True)
+
+data_list_schema = op_type_schema.extend({
+ 'data': [str]
+ }, required=True)
+
+tag_regex_schema = op_type_schema.extend({
+ 'tag_regex': str
+ }, required=True)
+
+forward_zone_add_schema = op_type_schema.extend({
+ 'data': {
+ str: {
+ 'name_server': [str],
+ 'addnta': Any({}, None),
+ 'recursion_desired': Any({}, None),
+ }
+ }
+ }, required=False)
+
+hosts_add_schema = op_type_schema.extend({
+ 'data': {
+ str: {
+ str: {
+ 'address': [str],
+ 'aliases': [str]
+ }
+ }
+ }
+ }, required=True)
+
+
+# op and type to schema mapping
+msg_schema_map = {
+ 'name_servers': {
+ 'add': data_dict_list_schema,
+ 'delete': data_list_schema,
+ 'get': tag_regex_schema
+ },
+ 'name_server_tags_recursor': {
+ 'add': data_list_schema,
+ 'delete': data_list_schema,
+ 'get': op_type_schema
+ },
+ 'name_server_tags_system': {
+ 'add': data_list_schema,
+ 'delete': data_list_schema,
+ 'get': op_type_schema
+ },
+ 'forward_zones': {
+ 'add': forward_zone_add_schema,
+ 'delete': data_list_schema,
+ 'get': op_type_schema
+ },
+ 'authoritative_zones': {
+ 'add': data_list_schema,
+ 'delete': data_list_schema,
+ 'get': op_type_schema
+ },
+ 'search_domains': {
+ 'add': data_dict_list_schema,
+ 'delete': data_list_schema,
+ 'get': tag_regex_schema
+ },
+ 'hosts': {
+ 'add': hosts_add_schema,
+ 'delete': data_list_schema,
+ 'get': tag_regex_schema
+ },
+ 'host_name': {
+ 'set': host_name_add_schema
+ },
+ None: {
+ 'apply': op_schema
+ }
+ }
+
+def validate_schema(data):
+ base_schema(data)
+
+ try:
+ schema = msg_schema_map[data['type'] if 'type' in data else None][data['op']]
+ schema(data)
+ except KeyError:
+ raise ValueError((
+ 'Invalid or unknown combination: '
+ f'op: "{data["op"]}", type: "{data["type"]}"'))
+
+
+def pdns_rec_control(command):
+ if not process_named_running('pdns_recursor'):
+ logger.info(f'pdns_recursor not running, not sending "{command}"')
+ return
+
+ logger.info(f'Running "rec_control {command}"')
+ (ret,ret_code) = popen((
+ f"rec_control --socket-dir={PDNS_REC_RUN_DIR} {command}"))
+ if ret_code > 0:
+ logger.exception((
+ f'"rec_control {command}" failed with exit status {ret_code}, '
+ f'output: "{ret}"'))
+
+def make_resolv_conf(state):
+ logger.info(f"Writing {RESOLV_CONF_FILE}")
+ render(RESOLV_CONF_FILE, 'vyos-hostsd/resolv.conf.j2', state,
+ user='root', group='root')
+
+def make_hosts(state):
+ logger.info(f"Writing {HOSTS_FILE}")
+ render(HOSTS_FILE, 'vyos-hostsd/hosts.j2', state,
+ user='root', group='root')
+
+def make_pdns_rec_conf(state):
+ logger.info(f"Writing {PDNS_REC_LUA_CONF_FILE}")
+
+ # on boot, /run/pdns-recursor does not exist, so create it
+ makedir(PDNS_REC_RUN_DIR, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP)
+ chmod_755(PDNS_REC_RUN_DIR)
+
+ render(PDNS_REC_LUA_CONF_FILE,
+ 'dns-forwarding/recursor.vyos-hostsd.conf.lua.j2',
+ state, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP)
+
+ logger.info(f"Writing {PDNS_REC_ZONES_FILE}")
+ render(PDNS_REC_ZONES_FILE,
+ 'dns-forwarding/recursor.forward-zones.conf.j2',
+ state, user=PDNS_REC_USER_GROUP, group=PDNS_REC_USER_GROUP)
+
+def set_host_name(state, data):
+ if data['host_name']:
+ state['host_name'] = data['host_name']
+ if 'domain_name' in data:
+ state['domain_name'] = data['domain_name']
+
+def add_items_to_dict(_dict, items):
+ """
+ Dedupes and preserves sort order.
+ """
+ assert isinstance(_dict, dict)
+ assert isinstance(items, dict)
+
+ if not items:
+ return
+
+ _dict.update(items)
+
+def add_items_to_dict_as_keys(_dict, items):
+ """
+ Added item values are converted to OrderedDict with the value as keys
+ and null values. This is to emulate a list but with inherent deduplication.
+ Dedupes and preserves sort order.
+ """
+ assert isinstance(_dict, dict)
+ assert isinstance(items, dict)
+
+ if not items:
+ return
+
+ for item, item_val in items.items():
+ if item not in _dict:
+ _dict[item] = OrderedDict({})
+ _dict[item].update(OrderedDict.fromkeys(item_val))
+
+def add_items_to_list(_list, items):
+ """
+ Dedupes and preserves sort order.
+ """
+ assert isinstance(_list, list)
+ assert isinstance(items, list)
+
+ if not items:
+ return
+
+ for item in items:
+ if item not in _list:
+ _list.append(item)
+
+def delete_items_from_dict(_dict, items):
+ """
+ items is a list of keys to delete.
+ Doesn't error if the key doesn't exist.
+ """
+ assert isinstance(_dict, dict)
+ assert isinstance(items, list)
+
+ for item in items:
+ if item in _dict:
+ del _dict[item]
+
+def delete_items_from_list(_list, items):
+ """
+ items is a list of items to remove.
+ Doesn't error if the key doesn't exist.
+ """
+ assert isinstance(_list, list)
+ assert isinstance(items, list)
+
+ for item in items:
+ if item in _list:
+ _list.remove(item)
+
+def get_items_from_dict_regex(_dict, item_regex_string):
+ """
+ Returns the items whose keys match item_regex_string.
+ """
+ assert isinstance(_dict, dict)
+ assert isinstance(item_regex_string, str)
+
+ tmp = {}
+ regex = re.compile(item_regex_string)
+ for item in _dict:
+ if regex.match(item):
+ tmp[item] = _dict[item]
+ return tmp
+
+def get_option(msg, key):
+ if key in msg:
+ return msg[key]
+ else:
+ raise ValueError("Missing required option \"{0}\"".format(key))
+
+def handle_message(msg):
+ result = None
+ op = get_option(msg, 'op')
+
+ if op in ['add', 'delete', 'set']:
+ STATE['changes'] += 1
+
+ if op == 'delete':
+ _type = get_option(msg, 'type')
+ data = get_option(msg, 'data')
+ if _type in ['name_servers', 'forward_zones', 'search_domains', 'hosts']:
+ delete_items_from_dict(STATE[_type], data)
+ elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:
+ delete_items_from_list(STATE[_type], data)
+ else:
+ raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
+ elif op == 'add':
+ _type = get_option(msg, 'type')
+ data = get_option(msg, 'data')
+ if _type in ['name_servers', 'search_domains']:
+ add_items_to_dict_as_keys(STATE[_type], data)
+ elif _type in ['forward_zones', 'hosts']:
+ add_items_to_dict(STATE[_type], data)
+ # maybe we need to rec_control clear-nta each domain that was removed here?
+ elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'authoritative_zones']:
+ add_items_to_list(STATE[_type], data)
+ else:
+ raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
+ elif op == 'set':
+ _type = get_option(msg, 'type')
+ data = get_option(msg, 'data')
+ if _type == 'host_name':
+ set_host_name(STATE, data)
+ else:
+ raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
+ elif op == 'get':
+ _type = get_option(msg, 'type')
+ if _type in ['name_servers', 'search_domains', 'hosts']:
+ tag_regex = get_option(msg, 'tag_regex')
+ result = get_items_from_dict_regex(STATE[_type], tag_regex)
+ elif _type in ['name_server_tags_recursor', 'name_server_tags_system', 'forward_zones', 'authoritative_zones']:
+ result = STATE[_type]
+ else:
+ raise ValueError(f'Operation "{op}" unknown data type "{_type}"')
+ elif op == 'apply':
+ logger.info(f"Applying {STATE['changes']} changes")
+ make_resolv_conf(STATE)
+ make_hosts(STATE)
+ make_pdns_rec_conf(STATE)
+ pdns_rec_control('reload-lua-config')
+ pdns_rec_control('reload-zones')
+ logger.info("Success")
+ result = {'message': f'Applied {STATE["changes"]} changes'}
+ STATE['changes'] = 0
+
+ else:
+ raise ValueError(f"Unknown operation {op}")
+
+ logger.debug(f"Saving state to {STATE_FILE}")
+ with open(STATE_FILE, 'w') as f:
+ json.dump(STATE, f)
+
+ return result
+
+if __name__ == '__main__':
+ # Create a directory for state checkpoints
+ os.makedirs(RUN_DIR, exist_ok=True)
+ if os.path.exists(STATE_FILE):
+ with open(STATE_FILE, 'r') as f:
+ try:
+ STATE = json.load(f)
+ except:
+ logger.exception(traceback.format_exc())
+ logger.exception("Failed to load the state file, using default")
+
+ context = zmq.Context()
+ socket = context.socket(zmq.REP)
+
+ # Set the right permissions on the socket, then change it back
+ o_mask = os.umask(0o000)
+ socket.bind(SOCKET_PATH)
+ os.umask(o_mask)
+
+ while True:
+ # Wait for next request from client
+ msg_json = socket.recv().decode()
+ logger.debug(f"Request data: {msg_json}")
+
+ try:
+ msg = json.loads(msg_json)
+ validate_schema(msg)
+
+ resp = {}
+ resp['data'] = handle_message(msg)
+ except ValueError as e:
+ resp['error'] = str(e)
+ except MultipleInvalid as e:
+ # raised by schema
+ resp['error'] = f'Invalid message: {str(e)}'
+ logger.exception(resp['error'])
+ except:
+ logger.exception(traceback.format_exc())
+ resp['error'] = "Internal error"
+
+ # Send reply back to client
+ socket.send(json.dumps(resp).encode())
+ logger.debug(f"Sent response: {resp}")
diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server
new file mode 100644
index 0000000..9110041
--- /dev/null
+++ b/src/services/vyos-http-api-server
@@ -0,0 +1,1036 @@
+#!/usr/share/vyos-http-api-tools/bin/python3
+#
+# Copyright (C) 2019-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 sys
+import grp
+import copy
+import json
+import logging
+import signal
+import traceback
+import threading
+from enum import Enum
+
+from time import sleep
+from typing import List, Union, Callable, Dict, Self
+
+from fastapi import FastAPI, Depends, Request, Response, HTTPException
+from fastapi import BackgroundTasks
+from fastapi.responses import HTMLResponse
+from fastapi.exceptions import RequestValidationError
+from fastapi.routing import APIRoute
+from pydantic import BaseModel, StrictStr, validator, model_validator
+from starlette.middleware.cors import CORSMiddleware
+from starlette.datastructures import FormData
+from starlette.formparsers import FormParser, MultiPartParser
+from multipart.multipart import parse_options_header
+from uvicorn import Config as UvicornConfig
+from uvicorn import Server as UvicornServer
+
+from ariadne.asgi import GraphQL
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.configdiff import get_config_diff
+from vyos.configsession import ConfigSession
+from vyos.configsession import ConfigSessionError
+from vyos.defaults import api_config_state
+
+import api.graphql.state
+
+CFG_GROUP = 'vyattacfg'
+
+debug = True
+
+logger = logging.getLogger(__name__)
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+if debug:
+ logger.setLevel(logging.DEBUG)
+else:
+ logger.setLevel(logging.INFO)
+
+# Giant lock!
+lock = threading.Lock()
+
+def load_server_config():
+ with open(api_config_state) as f:
+ config = json.load(f)
+ return config
+
+def check_auth(key_list, key):
+ key_id = None
+ for k in key_list:
+ if k['key'] == key:
+ key_id = k['id']
+ return key_id
+
+def error(code, msg):
+ resp = {"success": False, "error": msg, "data": None}
+ resp = json.dumps(resp)
+ return HTMLResponse(resp, status_code=code)
+
+def success(data):
+ resp = {"success": True, "data": data, "error": None}
+ resp = json.dumps(resp)
+ return HTMLResponse(resp)
+
+# Pydantic models for validation
+# Pydantic will cast when possible, so use StrictStr
+# validators added as needed for additional constraints
+# schema_extra adds anotations to OpenAPI, to add examples
+
+class ApiModel(BaseModel):
+ key: StrictStr
+
+class BasePathModel(BaseModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ @validator("path")
+ def check_non_empty(cls, path):
+ if not len(path) > 0:
+ raise ValueError('path must be non-empty')
+ return path
+
+class BaseConfigureModel(BasePathModel):
+ value: StrictStr = None
+
+class ConfigureModel(ApiModel, BaseConfigureModel):
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "set | delete | comment",
+ "path": ['config', 'mode', 'path'],
+ }
+ }
+
+class ConfigureListModel(ApiModel):
+ commands: List[BaseConfigureModel]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "commands": "list of commands",
+ }
+ }
+
+class BaseConfigSectionModel(BasePathModel):
+ section: Dict
+
+class ConfigSectionModel(ApiModel, BaseConfigSectionModel):
+ pass
+
+class ConfigSectionListModel(ApiModel):
+ commands: List[BaseConfigSectionModel]
+
+class BaseConfigSectionTreeModel(BaseModel):
+ op: StrictStr
+ mask: Dict
+ config: Dict
+
+class ConfigSectionTreeModel(ApiModel, BaseConfigSectionTreeModel):
+ pass
+
+class RetrieveModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ configFormat: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "returnValue | returnValues | exists | showConfig",
+ "path": ['config', 'mode', 'path'],
+ "configFormat": "json (default) | json_ast | raw",
+
+ }
+ }
+
+class ConfigFileModel(ApiModel):
+ op: StrictStr
+ file: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "save | load",
+ "file": "filename",
+ }
+ }
+
+
+class ImageOp(str, Enum):
+ add = "add"
+ delete = "delete"
+ show = "show"
+ set_default = "set_default"
+
+
+class ImageModel(ApiModel):
+ op: ImageOp
+ url: StrictStr = None
+ name: StrictStr = None
+
+ @model_validator(mode='after')
+ def check_data(self) -> Self:
+ if self.op == 'add':
+ if not self.url:
+ raise ValueError("Missing required field \"url\"")
+ elif self.op in ['delete', 'set_default']:
+ if not self.name:
+ raise ValueError("Missing required field \"name\"")
+
+ return self
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "add | delete | show | set_default",
+ "url": "imagelocation",
+ "name": "imagename",
+ }
+ }
+
+class ImportPkiModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+ passphrase: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "import_pki",
+ "path": ["op", "mode", "path"],
+ "passphrase": "passphrase",
+ }
+ }
+
+
+class ContainerImageModel(ApiModel):
+ op: StrictStr
+ name: StrictStr = None
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "add | delete | show",
+ "name": "imagename",
+ }
+ }
+
+class GenerateModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "generate",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class ShowModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "show",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class RebootModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "reboot",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class ResetModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "reset",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+class PoweroffModel(ApiModel):
+ op: StrictStr
+ path: List[StrictStr]
+
+ class Config:
+ schema_extra = {
+ "example": {
+ "key": "id_key",
+ "op": "poweroff",
+ "path": ["op", "mode", "path"],
+ }
+ }
+
+
+class Success(BaseModel):
+ success: bool
+ data: Union[str, bool, Dict]
+ error: str
+
+class Error(BaseModel):
+ success: bool = False
+ data: Union[str, bool, Dict]
+ error: str
+
+responses = {
+ 200: {'model': Success},
+ 400: {'model': Error},
+ 422: {'model': Error, 'description': 'Validation Error'},
+ 500: {'model': Error}
+}
+
+def auth_required(data: ApiModel):
+ key = data.key
+ api_keys = app.state.vyos_keys
+ key_id = check_auth(api_keys, key)
+ if not key_id:
+ raise HTTPException(status_code=401, detail="Valid API key is required")
+ app.state.vyos_id = key_id
+
+# override Request and APIRoute classes in order to convert form request to json;
+# do all explicit validation here, for backwards compatability of error messages;
+# the explicit validation may be dropped, if desired, in favor of native
+# validation by FastAPI/Pydantic, as is used for application/json requests
+class MultipartRequest(Request):
+ _form_err = ()
+ @property
+ def form_err(self):
+ return self._form_err
+
+ @form_err.setter
+ def form_err(self, val):
+ if not self._form_err:
+ self._form_err = val
+
+ @property
+ def orig_headers(self):
+ self._orig_headers = super().headers
+ return self._orig_headers
+
+ @property
+ def headers(self):
+ self._headers = super().headers.mutablecopy()
+ self._headers['content-type'] = 'application/json'
+ return self._headers
+
+ async def form(self) -> FormData:
+ if self._form is None:
+ assert (
+ parse_options_header is not None
+ ), "The `python-multipart` library must be installed to use form parsing."
+ content_type_header = self.orig_headers.get("Content-Type")
+ content_type, options = parse_options_header(content_type_header)
+ if content_type == b"multipart/form-data":
+ multipart_parser = MultiPartParser(self.orig_headers, self.stream())
+ self._form = await multipart_parser.parse()
+ elif content_type == b"application/x-www-form-urlencoded":
+ form_parser = FormParser(self.orig_headers, self.stream())
+ self._form = await form_parser.parse()
+ else:
+ self._form = FormData()
+ return self._form
+
+ async def body(self) -> bytes:
+ if not hasattr(self, "_body"):
+ forms = {}
+ merge = {}
+ body = await super().body()
+ self._body = body
+
+ form_data = await self.form()
+ if form_data:
+ endpoint = self.url.path
+ logger.debug("processing form data")
+ for k, v in form_data.multi_items():
+ forms[k] = v
+
+ if 'data' not in forms:
+ self.form_err = (422, "Non-empty data field is required")
+ return self._body
+ else:
+ try:
+ tmp = json.loads(forms['data'])
+ except json.JSONDecodeError as e:
+ self.form_err = (400, f'Failed to parse JSON: {e}')
+ return self._body
+ if isinstance(tmp, list):
+ merge['commands'] = tmp
+ else:
+ merge = tmp
+
+ if 'commands' in merge:
+ cmds = merge['commands']
+ else:
+ cmds = copy.deepcopy(merge)
+ cmds = [cmds]
+
+ for c in cmds:
+ if not isinstance(c, dict):
+ self.form_err = (400,
+ f"Malformed command '{c}': any command must be JSON of dict")
+ return self._body
+ if 'op' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'op' field")
+ if endpoint not in ('/config-file', '/container-image',
+ '/image', '/configure-section'):
+ if 'path' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'path' field")
+ elif not isinstance(c['path'], list):
+ self.form_err = (400,
+ f"Malformed command '{c}': 'path' field must be a list")
+ elif not all(isinstance(el, str) for el in c['path']):
+ self.form_err = (400,
+ f"Malformed command '{0}': 'path' field must be a list of strings")
+ if endpoint in ('/configure'):
+ if not c['path']:
+ self.form_err = (400,
+ f"Malformed command '{c}': 'path' list must be non-empty")
+ if 'value' in c and not isinstance(c['value'], str):
+ self.form_err = (400,
+ f"Malformed command '{c}': 'value' field must be a string")
+ if endpoint in ('/configure-section'):
+ if 'section' not in c and 'config' not in c:
+ self.form_err = (400,
+ f"Malformed command '{c}': missing 'section' or 'config' field")
+
+ if 'key' not in forms and 'key' not in merge:
+ self.form_err = (401, "Valid API key is required")
+ if 'key' in forms and 'key' not in merge:
+ merge['key'] = forms['key']
+
+ new_body = json.dumps(merge)
+ new_body = new_body.encode()
+ self._body = new_body
+
+ return self._body
+
+class MultipartRoute(APIRoute):
+ def get_route_handler(self) -> Callable:
+ original_route_handler = super().get_route_handler()
+
+ async def custom_route_handler(request: Request) -> Response:
+ request = MultipartRequest(request.scope, request.receive)
+ try:
+ response: Response = await original_route_handler(request)
+ except HTTPException as e:
+ return error(e.status_code, e.detail)
+ except Exception as e:
+ form_err = request.form_err
+ if form_err:
+ return error(*form_err)
+ raise e
+
+ return response
+
+ return custom_route_handler
+
+app = FastAPI(debug=True,
+ title="VyOS API",
+ version="0.1.0",
+ responses={**responses},
+ dependencies=[Depends(auth_required)])
+
+app.router.route_class = MultipartRoute
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request, exc):
+ return error(400, str(exc.errors()[0]))
+
+self_ref_msg = "Requested HTTP API server configuration change; commit will be called in the background"
+
+def call_commit(s: ConfigSession):
+ try:
+ s.commit()
+ except ConfigSessionError as e:
+ s.discard()
+ if app.state.vyos_debug:
+ logger.warning(f"ConfigSessionError:\n {traceback.format_exc()}")
+ else:
+ logger.warning(f"ConfigSessionError: {e}")
+
+def _configure_op(data: Union[ConfigureModel, ConfigureListModel,
+ ConfigSectionModel, ConfigSectionListModel,
+ ConfigSectionTreeModel],
+ request: Request, background_tasks: BackgroundTasks):
+ session = app.state.vyos_session
+ env = session.get_session_env()
+
+ endpoint = request.url.path
+
+ # Allow users to pass just one command
+ if not isinstance(data, (ConfigureListModel, ConfigSectionListModel)):
+ data = [data]
+ else:
+ data = data.commands
+
+ # We don't want multiple people/apps to be able to commit at once,
+ # or modify the shared session while someone else is doing the same,
+ # so the lock is really global
+ lock.acquire()
+
+ config = Config(session_env=env)
+
+ status = 200
+ msg = None
+ error_msg = None
+ try:
+ for c in data:
+ op = c.op
+ if not isinstance(c, BaseConfigSectionTreeModel):
+ path = c.path
+
+ if isinstance(c, BaseConfigureModel):
+ if c.value:
+ value = c.value
+ else:
+ value = ""
+ # For vyos.configsession calls that have no separate value arguments,
+ # and for type checking too
+ cfg_path = " ".join(path + [value]).strip()
+
+ elif isinstance(c, BaseConfigSectionModel):
+ section = c.section
+
+ elif isinstance(c, BaseConfigSectionTreeModel):
+ mask = c.mask
+ config = c.config
+
+ if isinstance(c, BaseConfigureModel):
+ if op == 'set':
+ session.set(path, value=value)
+ elif op == 'delete':
+ if app.state.vyos_strict and not config.exists(cfg_path):
+ raise ConfigSessionError(f"Cannot delete [{cfg_path}]: path/value does not exist")
+ session.delete(path, value=value)
+ elif op == 'comment':
+ session.comment(path, value=value)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+
+ elif isinstance(c, BaseConfigSectionModel):
+ if op == 'set':
+ session.set_section(path, section)
+ elif op == 'load':
+ session.load_section(path, section)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+
+ elif isinstance(c, BaseConfigSectionTreeModel):
+ if op == 'set':
+ session.set_section_tree(config)
+ elif op == 'load':
+ session.load_section_tree(mask, config)
+ else:
+ raise ConfigSessionError(f"'{op}' is not a valid operation")
+ # end for
+ config = Config(session_env=env)
+ d = get_config_diff(config)
+
+ if d.is_node_changed(['service', 'https']):
+ background_tasks.add_task(call_commit, session)
+ msg = self_ref_msg
+ else:
+ # capture non-fatal warnings
+ out = session.commit()
+ msg = out if out else msg
+
+ logger.info(f"Configuration modified via HTTP API using key '{app.state.vyos_id}'")
+ except ConfigSessionError as e:
+ session.discard()
+ status = 400
+ if app.state.vyos_debug:
+ logger.critical(f"ConfigSessionError:\n {traceback.format_exc()}")
+ error_msg = str(e)
+ except Exception as e:
+ session.discard()
+ logger.critical(traceback.format_exc())
+ status = 500
+
+ # Don't give the details away to the outer world
+ error_msg = "An internal error occured. Check the logs for details."
+ finally:
+ lock.release()
+
+ if status != 200:
+ return error(status, error_msg)
+
+ return success(msg)
+
+def create_path_import_pki_no_prompt(path):
+ correct_paths = ['ca', 'certificate', 'key-pair']
+ if path[1] not in correct_paths:
+ return False
+ path[1] = '--' + path[1].replace('-', '')
+ path[3] = '--key-filename'
+ return path[1:]
+
+@app.post('/configure')
+def configure_op(data: Union[ConfigureModel,
+ ConfigureListModel],
+ request: Request, background_tasks: BackgroundTasks):
+ return _configure_op(data, request, background_tasks)
+
+@app.post('/configure-section')
+def configure_section_op(data: Union[ConfigSectionModel,
+ ConfigSectionListModel,
+ ConfigSectionTreeModel],
+ request: Request, background_tasks: BackgroundTasks):
+ return _configure_op(data, request, background_tasks)
+
+@app.post("/retrieve")
+async def retrieve_op(data: RetrieveModel):
+ session = app.state.vyos_session
+ env = session.get_session_env()
+ config = Config(session_env=env)
+
+ op = data.op
+ path = " ".join(data.path)
+
+ try:
+ if op == 'returnValue':
+ res = config.return_value(path)
+ elif op == 'returnValues':
+ res = config.return_values(path)
+ elif op == 'exists':
+ res = config.exists(path)
+ elif op == 'showConfig':
+ config_format = 'json'
+ if data.configFormat:
+ config_format = data.configFormat
+
+ res = session.show_config(path=data.path)
+ if config_format == 'json':
+ config_tree = ConfigTree(res)
+ res = json.loads(config_tree.to_json())
+ elif config_format == 'json_ast':
+ config_tree = ConfigTree(res)
+ res = json.loads(config_tree.to_json_ast())
+ elif config_format == 'raw':
+ pass
+ else:
+ return error(400, f"'{config_format}' is not a valid config format")
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/config-file')
+def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks):
+ session = app.state.vyos_session
+ env = session.get_session_env()
+ op = data.op
+ msg = None
+
+ try:
+ if op == 'save':
+ if data.file:
+ path = data.file
+ else:
+ path = '/config/config.boot'
+ msg = session.save_config(path)
+ elif op == 'load':
+ if data.file:
+ path = data.file
+ else:
+ return error(400, "Missing required field \"file\"")
+
+ session.migrate_and_load_config(path)
+
+ config = Config(session_env=env)
+ d = get_config_diff(config)
+
+ if d.is_node_changed(['service', 'https']):
+ background_tasks.add_task(call_commit, session)
+ msg = self_ref_msg
+ else:
+ session.commit()
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(msg)
+
+@app.post('/image')
+def image_op(data: ImageModel):
+ session = app.state.vyos_session
+
+ op = data.op
+
+ try:
+ if op == 'add':
+ res = session.install_image(data.url)
+ elif op == 'delete':
+ res = session.remove_image(data.name)
+ elif op == 'show':
+ res = session.show(["system", "image"])
+ elif op == 'set_default':
+ res = session.set_default_image(data.name)
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/container-image')
+def container_image_op(data: ContainerImageModel):
+ session = app.state.vyos_session
+
+ op = data.op
+
+ try:
+ if op == 'add':
+ if data.name:
+ name = data.name
+ else:
+ return error(400, "Missing required field \"name\"")
+ res = session.add_container_image(name)
+ elif op == 'delete':
+ if data.name:
+ name = data.name
+ else:
+ return error(400, "Missing required field \"name\"")
+ res = session.delete_container_image(name)
+ elif op == 'show':
+ res = session.show_container_image()
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/generate')
+def generate_op(data: GenerateModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'generate':
+ res = session.generate(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/show')
+def show_op(data: ShowModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'show':
+ res = session.show(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/reboot')
+def reboot_op(data: RebootModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'reboot':
+ res = session.reboot(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/reset')
+def reset_op(data: ResetModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'reset':
+ res = session.reset(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+@app.post('/import-pki')
+def import_pki(data: ImportPkiModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ lock.acquire()
+
+ try:
+ if op == 'import-pki':
+ # need to get rid or interactive mode for private key
+ if len(path) == 5 and path[3] in ['key-file', 'private-key']:
+ path_no_prompt = create_path_import_pki_no_prompt(path)
+ if not path_no_prompt:
+ return error(400, f"Invalid command: {' '.join(path)}")
+ if data.passphrase:
+ path_no_prompt += ['--passphrase', data.passphrase]
+ res = session.import_pki_no_prompt(path_no_prompt)
+ else:
+ res = session.import_pki(path)
+ if not res[0].isdigit():
+ return error(400, res)
+ # commit changes
+ session.commit()
+ res = res.split('. ')[0]
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+ finally:
+ lock.release()
+
+ return success(res)
+
+@app.post('/poweroff')
+def poweroff_op(data: PoweroffModel):
+ session = app.state.vyos_session
+
+ op = data.op
+ path = data.path
+
+ try:
+ if op == 'poweroff':
+ res = session.poweroff(path)
+ else:
+ return error(400, f"'{op}' is not a valid operation")
+ except ConfigSessionError as e:
+ return error(400, str(e))
+ except Exception as e:
+ logger.critical(traceback.format_exc())
+ return error(500, "An internal error occured. Check the logs for details.")
+
+ return success(res)
+
+
+###
+# GraphQL integration
+###
+
+def graphql_init(app: FastAPI = app):
+ from api.graphql.libs.token_auth import get_user_context
+ api.graphql.state.init()
+ api.graphql.state.settings['app'] = app
+
+ # import after initializaion of state
+ from api.graphql.bindings import generate_schema
+ schema = generate_schema()
+
+ in_spec = app.state.vyos_introspection
+
+ if app.state.vyos_origins:
+ origins = app.state.vyos_origins
+ app.add_route('/graphql', CORSMiddleware(GraphQL(schema,
+ context_value=get_user_context,
+ debug=True,
+ introspection=in_spec),
+ allow_origins=origins,
+ allow_methods=("GET", "POST", "OPTIONS"),
+ allow_headers=("Authorization",)))
+ else:
+ app.add_route('/graphql', GraphQL(schema,
+ context_value=get_user_context,
+ debug=True,
+ introspection=in_spec))
+###
+# Modify uvicorn to allow reloading server within the configsession
+###
+
+server = None
+shutdown = False
+
+class ApiServerConfig(UvicornConfig):
+ pass
+
+class ApiServer(UvicornServer):
+ def install_signal_handlers(self):
+ pass
+
+def reload_handler(signum, frame):
+ global server
+ logger.debug('Reload signal received...')
+ if server is not None:
+ server.handle_exit(signum, frame)
+ server = None
+ logger.info('Server stopping for reload...')
+ else:
+ logger.warning('Reload called for non-running server...')
+
+def shutdown_handler(signum, frame):
+ global shutdown
+ logger.debug('Shutdown signal received...')
+ server.handle_exit(signum, frame)
+ logger.info('Server shutdown...')
+ shutdown = True
+
+def flatten_keys(d: dict) -> list[dict]:
+ keys_list = []
+ for el in list(d['keys'].get('id', {})):
+ key = d['keys']['id'][el].get('key', '')
+ if key:
+ keys_list.append({'id': el, 'key': key})
+ return keys_list
+
+def initialization(session: ConfigSession, app: FastAPI = app):
+ global server
+ try:
+ server_config = load_server_config()
+ except Exception as e:
+ logger.critical(f'Failed to load the HTTP API server config: {e}')
+ sys.exit(1)
+
+ app.state.vyos_session = session
+ app.state.vyos_keys = []
+
+ if 'keys' in server_config:
+ app.state.vyos_keys = flatten_keys(server_config)
+
+ app.state.vyos_debug = bool('debug' in server_config)
+ app.state.vyos_strict = bool('strict' in server_config)
+ app.state.vyos_origins = server_config.get('cors', {}).get('allow_origin', [])
+ if 'graphql' in server_config:
+ app.state.vyos_graphql = True
+ if isinstance(server_config['graphql'], dict):
+ if 'introspection' in server_config['graphql']:
+ app.state.vyos_introspection = True
+ else:
+ app.state.vyos_introspection = False
+ # default values if not set explicitly
+ app.state.vyos_auth_type = server_config['graphql']['authentication']['type']
+ app.state.vyos_token_exp = server_config['graphql']['authentication']['expiration']
+ app.state.vyos_secret_len = server_config['graphql']['authentication']['secret_length']
+ else:
+ app.state.vyos_graphql = False
+
+ if app.state.vyos_graphql:
+ graphql_init(app)
+
+ config = ApiServerConfig(app, uds="/run/api.sock", proxy_headers=True)
+ server = ApiServer(config)
+
+def run_server():
+ try:
+ server.run()
+ except OSError as e:
+ logger.critical(e)
+ sys.exit(1)
+
+if __name__ == '__main__':
+ # systemd's user and group options don't work, do it by hand here,
+ # else no one else will be able to commit
+ cfg_group = grp.getgrnam(CFG_GROUP)
+ os.setgid(cfg_group.gr_gid)
+
+ # Need to set file permissions to 775 too so that every vyattacfg group member
+ # has write access to the running config
+ os.umask(0o002)
+
+ signal.signal(signal.SIGHUP, reload_handler)
+ signal.signal(signal.SIGTERM, shutdown_handler)
+
+ config_session = ConfigSession(os.getpid())
+
+ while True:
+ logger.debug('Enter main loop...')
+ if shutdown:
+ break
+ if server is None:
+ initialization(config_session)
+ server.run()
+ sleep(1)
diff --git a/src/shim/Makefile b/src/shim/Makefile
new file mode 100644
index 0000000..c8487e3
--- /dev/null
+++ b/src/shim/Makefile
@@ -0,0 +1,20 @@
+DEBUG = 0
+
+CC := gcc
+CFLAGS := -I./mkjson -L./mkjson/lib -DDEBUG=${DEBUG}
+LIBS := -lmkjson -lzmq
+
+.PHONY: vyshim
+vyshim: vyshim.c libmkjson
+ $(CC) $(CFLAGS) -o $@ $< $(LIBS)
+
+.PHONY: libmkjson
+libmkjson:
+ $(MAKE) -C mkjson
+
+all: vyshim
+
+.PHONY: clean
+clean:
+ $(MAKE) -C mkjson clean
+ rm -f vyshim
diff --git a/src/shim/mkjson/LICENSE b/src/shim/mkjson/LICENSE
new file mode 100644
index 0000000..8c4284c
--- /dev/null
+++ b/src/shim/mkjson/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Jacek Wieczorek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/shim/mkjson/makefile b/src/shim/mkjson/makefile
new file mode 100644
index 0000000..ba75399
--- /dev/null
+++ b/src/shim/mkjson/makefile
@@ -0,0 +1,30 @@
+CFLAGS = -Wall -Os -I.
+CC = gcc
+AR = ar
+
+#USE_ASPRINTF make flag can be used in order to encourage asprintf use inside the library
+ifeq ($(USE_ASPRINTF),1)
+CFLAGS += -D_GNU_SOURCE
+endif
+
+#Builds object and a static library file
+all: clean force
+ $(CC) $(CFLAGS) -c mkjson.c -o obj/mkjson.o
+ $(AR) -cvq lib/libmkjson.a obj/mkjson.o
+ $(AR) -t lib/libmkjson.a
+
+#Normal cleanup
+clean:
+ -rm -rf obj
+ -rm -rf lib
+
+#Environment init
+force:
+ -mkdir obj
+ -mkdir lib
+
+#Build the example snippet
+example: all
+ gcc -o example examples/example.c -I. -Llib -lmkjson
+
+
diff --git a/src/shim/mkjson/mkjson.c b/src/shim/mkjson/mkjson.c
new file mode 100644
index 0000000..1172664
--- /dev/null
+++ b/src/shim/mkjson/mkjson.c
@@ -0,0 +1,307 @@
+/* mkjson.c - a part of mkjson library
+ *
+ * Copyright (C) 2018 Jacek Wieczorek
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+#include <mkjson.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+#include <string.h>
+
+// Works like asprintf, but it's always there
+// I don't want the name to collide with anything
+static int allsprintf( char **strp, const char *fmt, ... )
+{
+ int len;
+ va_list ap;
+ va_start( ap, fmt );
+
+ #ifdef _GNU_SOURCE
+ // Just hand everything to vasprintf, if it's available
+ len = vasprintf( strp, fmt, ap );
+ #else
+ // Or do it the manual way
+ char *buf;
+ len = vsnprintf( NULL, 0, fmt, ap );
+ if ( len >= 0 )
+ {
+ buf = malloc( ++len );
+ if ( buf != NULL )
+ {
+ // Hopefully, that's the right way to do it
+ va_end( ap );
+ va_start( ap, fmt );
+
+ // Write and return the data
+ len = vsnprintf( buf, len, fmt, ap );
+ if ( len >= 0 )
+ {
+ *strp = buf;
+ }
+ else
+ {
+ free( buf );
+ }
+ }
+ }
+ #endif
+
+ va_end( ap );
+ return len;
+}
+
+// Return JSON string built from va_arg arguments
+// If no longer needed, should be passed to free() by user
+char *mkjson( enum mkjson_container_type otype, int count, ... )
+{
+ int i, len, goodchunks = 0, failure = 0;
+ char *json, *prefix, **chunks, ign;
+
+ // Value - type and data
+ enum mkjson_value_type vtype;
+ const char *key;
+ long long int intval;
+ long double dblval;
+ const char *strval;
+
+ // Since v0.9 count cannot be a negative value and datatype is indicated by a separate argument
+ // Since I'm not sure whether it's right to put assertions in libraries, the next line is commented out
+ // assert( count >= 0 && "After v0.9 negative count is prohibited; please use otype argument instead" );
+ if ( count < 0 || ( otype != MKJSON_OBJ && otype != MKJSON_ARR ) ) return NULL;
+
+ // Allocate chunk pointer array - on standard platforms each one should be NULL
+ chunks = calloc( count, sizeof( char* ) );
+ if ( chunks == NULL ) return NULL;
+
+ // This should rather be at the point of no return
+ va_list ap;
+ va_start( ap, count );
+
+ // Create chunks
+ for ( i = 0; i < count && !failure; i++ )
+ {
+ // Get value type
+ vtype = va_arg( ap, enum mkjson_value_type );
+
+ // Get key
+ if ( otype == MKJSON_OBJ )
+ {
+ key = va_arg( ap, char* );
+ if ( key == NULL )
+ {
+ failure = 1;
+ break;
+ }
+ }
+ else key = "";
+
+ // Generate prefix
+ if ( allsprintf( &prefix, "%s%s%s",
+ otype == MKJSON_OBJ ? "\"" : "", // Quote before key
+ key, // Key
+ otype == MKJSON_OBJ ? "\": " : "" ) == -1 ) // Quote and colon after key
+ {
+ failure = 1;
+ break;
+ }
+
+ // Depending on value type
+ ign = 0;
+ switch ( vtype )
+ {
+ // Ignore string / JSON data
+ case MKJSON_IGN_STRING:
+ case MKJSON_IGN_JSON:
+ (void) va_arg( ap, const char* );
+ ign = 1;
+ break;
+
+ // Ignore string / JSON data and pass the pointer to free
+ case MKJSON_IGN_STRING_FREE:
+ case MKJSON_IGN_JSON_FREE:
+ free( va_arg( ap, char* ) );
+ ign = 1;
+ break;
+
+ // Ignore int / long long int
+ case MKJSON_IGN_INT:
+ case MKJSON_IGN_LLINT:
+ if ( vtype == MKJSON_IGN_INT )
+ (void) va_arg( ap, int );
+ else
+ (void) va_arg( ap, long long int );
+ ign = 1;
+ break;
+
+ // Ignore double / long double
+ case MKJSON_IGN_DOUBLE:
+ case MKJSON_IGN_LDOUBLE:
+ if ( vtype == MKJSON_IGN_DOUBLE )
+ (void) va_arg( ap, double );
+ else
+ (void) va_arg( ap, long double );
+ ign = 1;
+ break;
+
+ // Ignore boolean
+ case MKJSON_IGN_BOOL:
+ (void) va_arg( ap, int );
+ ign = 1;
+ break;
+
+ // Ignore null value
+ case MKJSON_IGN_NULL:
+ ign = 1;
+ break;
+
+ // A null-terminated string
+ case MKJSON_STRING:
+ case MKJSON_STRING_FREE:
+ strval = va_arg( ap, const char* );
+
+ // If the pointer points to NULL, the string will be replaced with JSON null value
+ if ( strval == NULL )
+ {
+ if ( allsprintf( chunks + i, "%snull", prefix ) == -1 )
+ chunks[i] = NULL;
+ }
+ else
+ {
+ if ( allsprintf( chunks + i, "%s\"%s\"", prefix, strval ) == -1 )
+ chunks[i] = NULL;
+ }
+
+ // Optional free
+ if ( vtype == MKJSON_STRING_FREE )
+ free( (char*) strval );
+ break;
+
+ // Embed JSON data
+ case MKJSON_JSON:
+ case MKJSON_JSON_FREE:
+ strval = va_arg( ap, const char* );
+
+ // If the pointer points to NULL, the JSON data is replaced with null value
+ if ( allsprintf( chunks + i, "%s%s", prefix, strval == NULL ? "null" : strval ) == -1 )
+ chunks[i] = NULL;
+
+ // Optional free
+ if ( vtype == MKJSON_JSON_FREE )
+ free( (char*) strval );
+ break;
+
+ // int / long long int
+ case MKJSON_INT:
+ case MKJSON_LLINT:
+ if ( vtype == MKJSON_INT )
+ intval = va_arg( ap, int );
+ else
+ intval = va_arg( ap, long long int );
+
+ if ( allsprintf( chunks + i, "%s%Ld", prefix, intval ) == -1 ) chunks[i] = NULL;
+ break;
+
+ // double / long double
+ case MKJSON_DOUBLE:
+ case MKJSON_LDOUBLE:
+ if ( vtype == MKJSON_DOUBLE )
+ dblval = va_arg( ap, double );
+ else
+ dblval = va_arg( ap, long double );
+
+ if ( allsprintf( chunks + i, "%s%Lf", prefix, dblval ) == -1 ) chunks[i] = NULL;
+ break;
+
+ // double / long double
+ case MKJSON_SCI_DOUBLE:
+ case MKJSON_SCI_LDOUBLE:
+ if ( vtype == MKJSON_SCI_DOUBLE )
+ dblval = va_arg( ap, double );
+ else
+ dblval = va_arg( ap, long double );
+
+ if ( allsprintf( chunks + i, "%s%Le", prefix, dblval ) == -1 ) chunks[i] = NULL;
+ break;
+
+ // Boolean
+ case MKJSON_BOOL:
+ intval = va_arg( ap, int );
+ if ( allsprintf( chunks + i, "%s%s", prefix, intval ? "true" : "false" ) == -1 ) chunks[i] = NULL;
+ break;
+
+ // JSON null
+ case MKJSON_NULL:
+ if ( allsprintf( chunks + i, "%snull", prefix ) == -1 ) chunks[i] = NULL;
+ break;
+
+ // Bad type specifier
+ default:
+ chunks[i] = NULL;
+ break;
+ }
+
+ // Free prefix memory
+ free( prefix );
+
+ // NULL chunk without ignore flag indicates failure
+ if ( !ign && chunks[i] == NULL ) failure = 1;
+
+ // NULL chunk now indicates ignore flag
+ if ( ign ) chunks[i] = NULL;
+ else goodchunks++;
+ }
+
+ // We won't use ap anymore
+ va_end( ap );
+
+ // If everything is fine, merge chunks and create full JSON table
+ if ( !failure )
+ {
+ // Get total length (this is without NUL byte)
+ len = 0;
+ for ( i = 0; i < count; i++ )
+ if ( chunks[i] != NULL )
+ len += strlen( chunks[i] );
+
+ // Total length = Chunks length + 2 brackets + separators
+ if ( goodchunks == 0 ) goodchunks = 1;
+ len = len + 2 + ( goodchunks - 1 ) * 2;
+
+ // Allocate memory for the whole thing
+ json = calloc( len + 1, sizeof( char ) );
+ if ( json != NULL )
+ {
+ // Merge chunks (and do not overwrite the first bracket)
+ for ( i = 0; i < count; i++ )
+ {
+ // Add separators:
+ // - not on the begining
+ // - always after valid chunk
+ // - between two valid chunks
+ // - between valid and ignored chunk if the latter isn't the last one
+ if ( i != 0 && chunks[i - 1] != NULL && ( chunks[i] != NULL || ( chunks[i] == NULL && i != count - 1 ) ) )
+ strcat( json + 1, ", ");
+
+ if ( chunks[i] != NULL )
+ strcat( json + 1, chunks[i] );
+ }
+
+ // Add proper brackets
+ json[0] = otype == MKJSON_OBJ ? '{' : '[';
+ json[len - 1] = otype == MKJSON_OBJ ? '}' : ']';
+ }
+ }
+ else json = NULL;
+
+ // Free chunks
+ for ( i = 0; i < count; i++ )
+ free( chunks[i] );
+ free( chunks );
+
+ return json;
+}
+
diff --git a/src/shim/mkjson/mkjson.h b/src/shim/mkjson/mkjson.h
new file mode 100644
index 0000000..38cc07b
--- /dev/null
+++ b/src/shim/mkjson/mkjson.h
@@ -0,0 +1,50 @@
+/* mkjson.h - a part of mkjson library
+ *
+ * Copyright (C) 2018 Jacek Wieczorek
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+#ifndef MKJSON_H
+#define MKJSON_H
+
+// JSON container types
+enum mkjson_container_type
+{
+ MKJSON_ARR = 0, // An array
+ MKJSON_OBJ = 1 // An object (hash or whatever you call it)
+};
+
+// JSON data types
+enum mkjson_value_type
+{
+ MKJSON_STRING = (int)('s'), // const char* - String data
+ MKJSON_STRING_FREE = (int)('f'), // char* - String data, but pointer is freed
+ MKJSON_JSON = (int)('r'), // const char* - JSON data (like string, but no quotes)
+ MKJSON_JSON_FREE = (int)('j'), // char* - JSON data, but pointer is freed
+ MKJSON_INT = (int)('i'), // int - An integer
+ MKJSON_LLINT = (int)('I'), // long long int - A long integer
+ MKJSON_DOUBLE = (int)('d'), // double - A double
+ MKJSON_LDOUBLE = (int)('D'), // long double - A long double
+ MKJSON_SCI_DOUBLE = (int)('e'), // double - A double with scientific notation
+ MKJSON_SCI_LDOUBLE = (int)('E'), // long double - A long double with scientific notation
+ MKJSON_BOOL = (int)('b'), // int - A boolean value
+ MKJSON_NULL = (int)('n'), // -- - JSON null value
+
+ // These cause one argument of certain type to be ignored
+ MKJSON_IGN_STRING = (-MKJSON_STRING),
+ MKJSON_IGN_STRING_FREE = (-MKJSON_STRING_FREE),
+ MKJSON_IGN_JSON = (-MKJSON_JSON),
+ MKJSON_IGN_JSON_FREE = (-MKJSON_JSON_FREE),
+ MKJSON_IGN_INT = (-MKJSON_INT),
+ MKJSON_IGN_LLINT = (-MKJSON_LLINT),
+ MKJSON_IGN_DOUBLE = (-MKJSON_DOUBLE),
+ MKJSON_IGN_LDOUBLE = (-MKJSON_LDOUBLE),
+ MKJSON_IGN_BOOL = (-MKJSON_BOOL),
+ MKJSON_IGN_NULL = (-MKJSON_NULL)
+};
+
+extern char *mkjson( enum mkjson_container_type otype, int count, ... );
+
+#endif
diff --git a/src/shim/vyshim.c b/src/shim/vyshim.c
new file mode 100644
index 0000000..68e6c40
--- /dev/null
+++ b/src/shim/vyshim.c
@@ -0,0 +1,371 @@
+/*
+ * Copyright (C) 2020-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/>.
+ *
+ */
+
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+#include <string.h>
+#include <sys/time.h>
+#include <time.h>
+#include <stdint.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <zmq.h>
+#include "mkjson.h"
+
+/*
+ *
+ *
+ */
+
+#if DEBUG
+#define DEBUG_ON 1
+#else
+#define DEBUG_ON 0
+#endif
+#define debug_print(fmt, ...) \
+ do { if (DEBUG_ON) fprintf(stderr, fmt, ##__VA_ARGS__); } while (0)
+#define debug_call(f) \
+ do { if (DEBUG_ON) f; } while (0)
+
+#define SOCKET_PATH "ipc:///run/vyos-configd.sock"
+
+#define GET_ACTIVE "cli-shell-api --show-active-only --show-show-defaults --show-ignore-edit showConfig"
+#define GET_SESSION "cli-shell-api --show-working-only --show-show-defaults --show-ignore-edit showConfig"
+
+#define COMMIT_MARKER "/var/tmp/initial_in_commit"
+#define QUEUE_MARKER "/var/tmp/last_in_queue"
+
+enum {
+ SUCCESS = 1 << 0,
+ ERROR_COMMIT = 1 << 1,
+ ERROR_DAEMON = 1 << 2,
+ PASS = 1 << 3
+};
+
+volatile int init_alarm = 0;
+volatile int timeout = 0;
+
+int initialization(void *);
+int pass_through(char **, int);
+void timer_handler(int);
+
+double get_posix_clock_time(void);
+
+static char * s_recv_string (void *, int);
+
+int main(int argc, char* argv[])
+{
+ // string for node data: conf_mode script and tagnode, if applicable
+ char string_node_data[256];
+ string_node_data[0] = '\0';
+
+ void *context = zmq_ctx_new();
+ void *requester = zmq_socket(context, ZMQ_REQ);
+
+ int ex_index;
+ int init_timeout = 0;
+ int last = 0;
+
+ debug_print("Connecting to vyos-configd ...\n");
+ zmq_connect(requester, SOCKET_PATH);
+
+ for (int i = 1; i < argc ; i++) {
+ strncat(&string_node_data[0], argv[i], 127);
+ }
+
+ debug_print("data to send: %s\n", string_node_data);
+
+ char *test = strstr(string_node_data, "VYOS_TAGNODE_VALUE");
+ ex_index = test ? 2 : 1;
+
+ if (access(COMMIT_MARKER, F_OK) != -1) {
+ init_timeout = initialization(requester);
+ if (!init_timeout) remove(COMMIT_MARKER);
+ }
+
+ // if initial communication failed, pass through execution of script
+ if (init_timeout) {
+ int ret = pass_through(argv, ex_index);
+ return ret;
+ }
+
+ if (access(QUEUE_MARKER, F_OK) != -1) {
+ last = 1;
+ remove(QUEUE_MARKER);
+ }
+
+ char error_code[1];
+ debug_print("Sending node data ...\n");
+ char *string_node_data_msg = mkjson(MKJSON_OBJ, 3,
+ MKJSON_STRING, "type", "node",
+ MKJSON_BOOL, "last", last,
+ MKJSON_STRING, "data", &string_node_data[0]);
+
+ zmq_send(requester, string_node_data_msg, strlen(string_node_data_msg), 0);
+ zmq_recv(requester, error_code, 1, 0);
+ debug_print("Received node data receipt\n");
+
+ char msg_size_str[7];
+ zmq_send(requester, "msg_size", 8, 0);
+ zmq_recv(requester, msg_size_str, 6, 0);
+ msg_size_str[6] = '\0';
+ int msg_size = (int)strtol(msg_size_str, NULL, 16);
+ debug_print("msg_size: %d\n", msg_size);
+
+ if (msg_size > 0) {
+ zmq_send(requester, "send", 4, 0);
+ char *msg = s_recv_string(requester, msg_size);
+ printf("%s", msg);
+ free(msg);
+ }
+
+ free(string_node_data_msg);
+
+ int err = (int)error_code[0];
+ int ret = 0;
+
+ if (err & PASS) {
+ debug_print("Received PASS\n");
+ ret = pass_through(argv, ex_index);
+ }
+
+ if (err & ERROR_DAEMON) {
+ debug_print("Received ERROR_DAEMON\n");
+ ret = pass_through(argv, ex_index);
+ }
+
+ if (err & ERROR_COMMIT) {
+ debug_print("Received ERROR_COMMIT\n");
+ ret = -1;
+ }
+
+ zmq_close(requester);
+ zmq_ctx_destroy(context);
+
+ return ret;
+}
+
+int initialization(void* Requester)
+{
+ char *active_str = NULL;
+ size_t active_len = 0;
+
+ char *session_str = NULL;
+ size_t session_len = 0;
+
+ char *empty_string = "\n";
+
+ char buffer[16];
+
+ struct sigaction sa;
+ struct itimerval timer, none_timer;
+
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_handler = &timer_handler;
+ sigaction(SIGALRM, &sa, NULL);
+
+ timer.it_value.tv_sec = 0;
+ timer.it_value.tv_usec = 10000;
+ timer.it_interval.tv_sec = timer.it_interval.tv_usec = 0;
+ none_timer.it_value.tv_sec = none_timer.it_value.tv_usec = 0;
+ none_timer.it_interval.tv_sec = none_timer.it_interval.tv_usec = 0;
+
+ double prev_time_value, time_value;
+ double time_diff;
+
+ char *pid_val = getenv("VYATTA_CONFIG_TMP");
+ strsep(&pid_val, "_");
+ debug_print("config session pid: %s\n", pid_val);
+
+ char *sudo_user = getenv("SUDO_USER");
+ if (!sudo_user) {
+ char nobody[] = "nobody";
+ sudo_user = nobody;
+ }
+ debug_print("sudo_user is %s\n", sudo_user);
+
+ char *temp_config_dir = getenv("VYATTA_TEMP_CONFIG_DIR");
+ if (!temp_config_dir) {
+ char none[] = "";
+ temp_config_dir = none;
+ }
+ debug_print("temp_config_dir is %s\n", temp_config_dir);
+
+ char *changes_only_dir = getenv("VYATTA_CHANGES_ONLY_DIR");
+ if (!changes_only_dir) {
+ char none[] = "";
+ changes_only_dir = none;
+ }
+ debug_print("changes_only_dir is %s\n", changes_only_dir);
+
+ debug_print("Sending init announcement\n");
+ char *init_announce = mkjson(MKJSON_OBJ, 1,
+ MKJSON_STRING, "type", "init");
+
+ // check for timeout on initial contact
+ while (!init_alarm) {
+ debug_call(prev_time_value = get_posix_clock_time());
+
+ setitimer(ITIMER_REAL, &timer, NULL);
+
+ zmq_send(Requester, init_announce, strlen(init_announce), 0);
+ zmq_recv(Requester, buffer, 16, 0);
+
+ setitimer(ITIMER_REAL, &none_timer, &timer);
+
+ debug_call(time_value = get_posix_clock_time());
+
+ debug_print("Received init receipt\n");
+ debug_call(time_diff = time_value - prev_time_value);
+ debug_print("time elapse %f\n", time_diff);
+
+ break;
+ }
+
+ free(init_announce);
+
+ if (timeout) return -1;
+
+ FILE *fp_a = popen(GET_ACTIVE, "r");
+ getdelim(&active_str, &active_len, '\0', fp_a);
+ int ret = pclose(fp_a);
+
+ if (!ret) {
+ debug_print("Sending active config\n");
+ zmq_send(Requester, active_str, active_len - 1, 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received active receipt\n");
+ } else {
+ debug_print("Sending empty active config\n");
+ zmq_send(Requester, empty_string, 0, 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received active receipt\n");
+ }
+
+ free(active_str);
+
+ FILE *fp_s = popen(GET_SESSION, "r");
+ getdelim(&session_str, &session_len, '\0', fp_s);
+ pclose(fp_s);
+
+ debug_print("Sending session config\n");
+ zmq_send(Requester, session_str, session_len - 1, 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received session receipt\n");
+
+ free(session_str);
+
+ debug_print("Sending config session pid\n");
+ zmq_send(Requester, pid_val, strlen(pid_val), 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received pid receipt\n");
+
+ debug_print("Sending config session sudo_user\n");
+ zmq_send(Requester, sudo_user, strlen(sudo_user), 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received sudo_user receipt\n");
+
+ debug_print("Sending config session temp_config_dir\n");
+ zmq_send(Requester, temp_config_dir, strlen(temp_config_dir), 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received temp_config_dir receipt\n");
+
+ debug_print("Sending config session changes_only_dir\n");
+ zmq_send(Requester, changes_only_dir, strlen(changes_only_dir), 0);
+ zmq_recv(Requester, buffer, 16, 0);
+ debug_print("Received changes_only_dir receipt\n");
+
+ return 0;
+}
+
+int pass_through(char **argv, int ex_index)
+{
+ char **newargv = NULL;
+ pid_t child_pid;
+
+ newargv = &argv[ex_index];
+ if (ex_index > 1) {
+ putenv(argv[ex_index - 1]);
+ }
+
+ debug_print("pass-through invoked\n");
+
+ if ((child_pid=fork()) < 0) {
+ debug_print("fork() failed\n");
+ return -1;
+ } else if (child_pid == 0) {
+ if (-1 == execv(argv[ex_index], newargv)) {
+ debug_print("pass_through execve failed %s: %s\n",
+ argv[ex_index], strerror(errno));
+ return -1;
+ }
+ } else if (child_pid > 0) {
+ int status;
+ pid_t wait_pid = waitpid(child_pid, &status, 0);
+ if (wait_pid < 0) {
+ debug_print("waitpid() failed\n");
+ return -1;
+ } else if (wait_pid == child_pid) {
+ if (WIFEXITED(status)) {
+ debug_print("child exited with code %d\n",
+ WEXITSTATUS(status));
+ return WEXITSTATUS(status);
+ }
+ }
+ }
+
+ return 0;
+}
+
+void timer_handler(int signum)
+{
+ debug_print("timer_handler invoked\n");
+ timeout = 1;
+ init_alarm = 1;
+
+ return;
+}
+
+#ifdef _POSIX_MONOTONIC_CLOCK
+double get_posix_clock_time(void)
+{
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) {
+ return (double) (ts.tv_sec + ts.tv_nsec / 1000000000.0);
+ } else {
+ return 0;
+ }
+}
+#else
+double get_posix_clock_time(void)
+{return (double)0;}
+#endif
+
+// Receive string from socket and convert into C string
+static char * s_recv_string (void *socket, int bufsize) {
+ char * buffer = (char *)malloc(bufsize+1);
+ int size = zmq_recv(socket, buffer, bufsize, 0);
+ if (size == -1)
+ return NULL;
+ if (size > bufsize)
+ size = bufsize;
+ buffer[size] = '\0';
+ return buffer;
+}
diff --git a/src/system/grub_update.py b/src/system/grub_update.py
new file mode 100644
index 0000000..5a05341
--- /dev/null
+++ b/src/system/grub_update.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from sys import exit
+
+from vyos.system import disk, grub, image, compat, SYSTEM_CFG_VER
+from vyos.template import render
+
+
+def cfg_check_update() -> bool:
+ """Check if GRUB structure update is required
+
+ Returns:
+ bool: False if not required, True if required
+ """
+ current_ver = grub.get_cfg_ver()
+ if current_ver and current_ver >= SYSTEM_CFG_VER:
+ return False
+
+ return True
+
+
+if __name__ == '__main__':
+ if image.is_live_boot():
+ exit(0)
+
+ if image.is_running_as_container():
+ exit(0)
+
+ # Skip everything if update is not required
+ if not cfg_check_update():
+ exit(0)
+
+ # find root directory of persistent storage
+ root_dir = disk.find_persistence()
+
+ # read current GRUB config
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+ vars = grub.vars_read(grub_cfg_main)
+ modules = grub.modules_read(grub_cfg_main)
+ vyos_menuentries = compat.parse_menuentries(grub_cfg_main)
+ vyos_versions = compat.find_versions(vyos_menuentries)
+ unparsed_items = compat.filter_unparsed(grub_cfg_main)
+ # compatibilty for raid installs
+ search_root = compat.get_search_root(unparsed_items)
+ common_dict = {}
+ common_dict['search_root'] = search_root
+ # find default values
+ default_entry = vyos_menuentries[int(vars['default'])]
+ default_settings = {
+ 'default': grub.gen_version_uuid(default_entry['version']),
+ 'bootmode': default_entry['bootmode'],
+ 'console_type': default_entry['console_type'],
+ 'console_num': default_entry['console_num'],
+ 'console_speed': default_entry['console_speed']
+ }
+ vars.update(default_settings)
+
+ # create new files
+ grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+ grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}'
+ grub_cfg_platform = f'{root_dir}/{grub.CFG_VYOS_PLATFORM}'
+ grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}'
+ grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}'
+
+ Path(image.GRUB_DIR_VYOS).mkdir(exist_ok=True)
+ grub.vars_write(grub_cfg_vars, vars)
+ grub.modules_write(grub_cfg_modules, modules)
+ grub.common_write(grub_common=common_dict)
+ render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {})
+ render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {})
+
+ # create menu entries
+ for vyos_ver in vyos_versions:
+ boot_opts = None
+ for entry in vyos_menuentries:
+ if entry.get('version') == vyos_ver and entry.get(
+ 'bootmode') == 'normal':
+ boot_opts = entry.get('boot_opts')
+ grub.version_add(vyos_ver, root_dir, boot_opts)
+
+ # update structure version
+ cfg_ver = compat.update_cfg_ver(root_dir)
+ grub.write_cfg_ver(cfg_ver, root_dir)
+
+ if compat.mode():
+ compat.render_grub_cfg(root_dir)
+ else:
+ render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {})
+
+ # sort inodes (to make GRUB read config files in alphabetical order)
+ grub.sort_inodes(f'{root_dir}/{grub.GRUB_DIR_VYOS}')
+ grub.sort_inodes(f'{root_dir}/{grub.GRUB_DIR_VYOS_VERS}')
+
+ exit(0)
diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py
new file mode 100644
index 0000000..2473380
--- /dev/null
+++ b/src/system/keepalived-fifo.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2020-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 time
+import signal
+import argparse
+import threading
+import re
+import logging
+
+from queue import Queue
+from logging.handlers import SysLogHandler
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search
+from vyos.utils.commit import commit_in_progress
+
+# configure logging
+logger = logging.getLogger(__name__)
+logs_format = logging.Formatter('%(filename)s: %(message)s')
+logs_handler_syslog = SysLogHandler('/dev/log')
+logs_handler_syslog.setFormatter(logs_format)
+logger.addHandler(logs_handler_syslog)
+logger.setLevel(logging.DEBUG)
+
+mdns_running_file = '/run/mdns_vrrp_active'
+mdns_update_command = 'sudo /usr/libexec/vyos/conf_mode/service_mdns_repeater.py'
+
+# class for all operations
+class KeepalivedFifo:
+ # init - read command arguments
+ def __init__(self):
+ logger.info('Starting FIFO pipe for Keepalived')
+ # define program arguments
+ cmd_args_parser = argparse.ArgumentParser(description='Create FIFO pipe for keepalived and process notify events', add_help=False)
+ cmd_args_parser.add_argument('PIPE', help='path to the FIFO pipe')
+ # parse arguments
+ cmd_args = cmd_args_parser.parse_args()
+
+ self._config_load()
+ self.pipe_path = cmd_args.PIPE
+
+ # create queue for messages and events for syncronization
+ self.message_queue = Queue(maxsize=100)
+ self.stopme = threading.Event()
+ self.message_event = threading.Event()
+
+ # load configuration
+ def _config_load(self):
+ # For VRRP configuration to be read, the commit must be finished
+ count = 1
+ while commit_in_progress():
+ if ( count <= 20 ):
+ logger.debug(f'Attempt to load keepalived configuration aborted due to a commit in progress (attempt {count}/20)')
+ else:
+ logger.error(f'Forced keepalived configuration loading despite a commit in progress ({count} wait time expired, not waiting further)')
+ break
+ count += 1
+ time.sleep(1)
+
+ try:
+ base = ['high-availability', 'vrrp']
+ conf = ConfigTreeQuery()
+ if not conf.exists(base):
+ raise ValueError()
+
+ # Read VRRP configuration directly from CLI
+ self.vrrp_config_dict = conf.get_config_dict(base,
+ key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ logger.debug(f'Loaded configuration: {self.vrrp_config_dict}')
+ except Exception as err:
+ logger.error(f'Unable to load configuration: {err}')
+
+ # run command
+ def _run_command(self, command):
+ logger.debug(f'Running the command: {command}')
+ try:
+ cmd(command)
+ except OSError as err:
+ logger.error(f'Unable to execute command "{command}": {err}')
+
+ # create FIFO pipe
+ def pipe_create(self):
+ if os.path.exists(self.pipe_path):
+ logger.info(f'PIPE already exist: {self.pipe_path}')
+ else:
+ os.mkfifo(self.pipe_path)
+
+ # process message from pipe
+ def pipe_process(self):
+ logger.debug('Message processing start')
+ regex_notify = re.compile(r'^(?P<type>\w+) "(?P<name>[\w-]+)" (?P<state>\w+) (?P<priority>\d+)$', re.MULTILINE)
+ while self.stopme.is_set() is False:
+ # wait for a new message event from pipe_wait
+ self.message_event.wait()
+ try:
+ # clear mesage event flag
+ self.message_event.clear()
+ # get all messages from queue and try to process them
+ while self.message_queue.empty() is not True:
+ message = self.message_queue.get()
+ logger.debug(f'Received message: {message}')
+ notify_message = regex_notify.search(message)
+ # try to process a message if it looks valid
+ if notify_message:
+ n_type = notify_message.group('type')
+ n_name = notify_message.group('name')
+ n_state = notify_message.group('state')
+ logger.info(f'{n_type} {n_name} changed state to {n_state}')
+ # check and run commands for VRRP instances
+ if n_type == 'INSTANCE':
+ if os.path.exists(mdns_running_file):
+ cmd(mdns_update_command)
+
+ tmp = dict_search(f'group.{n_name}.transition_script.{n_state.lower()}', self.vrrp_config_dict)
+ if tmp != None:
+ self._run_command(tmp)
+ # check and run commands for VRRP sync groups
+ elif n_type == 'GROUP':
+ if os.path.exists(mdns_running_file):
+ cmd(mdns_update_command)
+
+ tmp = dict_search(f'sync_group.{n_name}.transition_script.{n_state.lower()}', self.vrrp_config_dict)
+ if tmp != None:
+ self._run_command(tmp)
+ # mark task in queue as done
+ self.message_queue.task_done()
+ except Exception as err:
+ logger.error(f'Error processing message: {err}')
+ logger.debug('Terminating messages processing thread')
+
+ # wait for messages
+ def pipe_wait(self):
+ logger.debug('Message reading start')
+ self.pipe_read = os.open(self.pipe_path, os.O_RDONLY | os.O_NONBLOCK)
+ while self.stopme.is_set() is False:
+ # sleep a bit to not produce 100% CPU load
+ time.sleep(0.250)
+ try:
+ # try to read a message from PIPE
+ message = os.read(self.pipe_read, 500)
+ if message:
+ # split PIPE content by lines and put them into queue
+ for line in message.decode().strip().splitlines():
+ self.message_queue.put(line)
+ # set new message flag to start processing
+ self.message_event.set()
+ except Exception as err:
+ # ignore the "Resource temporarily unavailable" error
+ if err.errno != 11:
+ logger.error(f'Error receiving message: {err}')
+
+ logger.debug('Closing FIFO pipe')
+ os.close(self.pipe_read)
+
+# handle SIGTERM signal to allow finish all messages processing
+def sigterm_handle(signum, frame):
+ logger.info('Ending processing: Received SIGTERM signal')
+ fifo.stopme.set()
+ thread_wait_message.join()
+ fifo.message_event.set()
+ thread_process_message.join()
+
+signal.signal(signal.SIGTERM, sigterm_handle)
+
+# init our class
+fifo = KeepalivedFifo()
+# try to create PIPE if it is not exist yet
+# It looks like keepalived do it before the script will be running, but if we
+# will decide to run this not from keepalived config, then we may get in
+# trouble. So it is betteer to leave this here.
+fifo.pipe_create()
+# create and run dedicated threads for reading and processing messages
+thread_wait_message = threading.Thread(target=fifo.pipe_wait)
+thread_process_message = threading.Thread(target=fifo.pipe_process)
+thread_wait_message.start()
+thread_process_message.start()
diff --git a/src/system/normalize-ip b/src/system/normalize-ip
new file mode 100644
index 0000000..08f922a
--- /dev/null
+++ b/src/system/normalize-ip
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018 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/>.
+#
+#
+
+# Normalizes IPv6 addresses so that they can be passed to iproute2,
+# since iproute2 will not take an address with leading zeroes for an argument
+
+import re
+import sys
+import ipaddress
+
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Argument required")
+ sys.exit(1)
+
+ address_string, prefix_length = re.match(r'(.+)/(.+)', sys.argv[1]).groups()
+
+ try:
+ address = ipaddress.IPv6Address(address_string)
+ normalized_address = address.compressed
+ except ipaddress.AddressValueError:
+ # It's likely an IPv4 address, do nothing
+ normalized_address = address_string
+
+ print("{0}/{1}".format(normalized_address, prefix_length))
+ sys.exit(0)
+
diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh
new file mode 100644
index 0000000..47c2762
--- /dev/null
+++ b/src/system/on-dhcp-event.sh
@@ -0,0 +1,98 @@
+#!/bin/bash
+#
+# 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/>.
+#
+#
+
+if [ $# -lt 1 ]; then
+ echo Invalid args
+ logger -s -t on-dhcp-event "Invalid args \"$@\""
+ exit 1
+fi
+
+action=$1
+hostsd_client="/usr/bin/vyos-hostsd-client"
+
+get_subnet_domain_name () {
+ python3 <<EOF
+from vyos.kea import kea_get_active_config
+from vyos.utils.dict import dict_search_args
+
+config = kea_get_active_config('4')
+shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks')
+
+found = False
+
+if shared_networks:
+ for network in shared_networks:
+ for subnet in network[f'subnet4']:
+ if subnet['id'] == $1:
+ for option in subnet['option-data']:
+ if option['name'] == 'domain-name':
+ print(option['data'])
+ found = True
+
+ if not found:
+ for option in network['option-data']:
+ if option['name'] == 'domain-name':
+ print(option['data'])
+EOF
+}
+
+case "$action" in
+ lease4_renew|lease4_recover)
+ exit 0
+ ;;
+
+ lease4_release|lease4_expire|lease4_decline) # delete mapping for released/declined address
+ client_ip=$LEASE4_ADDRESS
+ $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply
+ exit 0
+ ;;
+
+ leases4_committed) # process committed leases (added/renewed/recovered)
+ for ((i = 0; i < $LEASES4_SIZE; i++)); do
+ client_ip_var="LEASES4_AT${i}_ADDRESS"
+ client_mac_var="LEASES4_AT${i}_HWADDR"
+ client_name_var="LEASES4_AT${i}_HOSTNAME"
+ client_subnet_id_var="LEASES4_AT${i}_SUBNET_ID"
+
+ client_ip=${!client_ip_var}
+ client_mac=${!client_mac_var}
+ client_name=${!client_name_var%.}
+ client_subnet_id=${!client_subnet_id_var}
+
+ if [ -z "$client_name" ]; then
+ logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead"
+ client_name=$(echo "host-$client_mac" | tr : -)
+ fi
+
+ client_domain=$(get_subnet_domain_name $client_subnet_id)
+
+ if [[ -n "$client_domain" ]] && ! [[ $client_name =~ .*$client_domain$ ]]; then
+ client_name="$client_name.$client_domain"
+ fi
+
+ $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply
+ done
+
+ exit 0
+ ;;
+
+ *)
+ logger -s -t on-dhcp-event "Invalid command \"$1\""
+ exit 1
+ ;;
+esac
diff --git a/src/system/on-dhcpv6-event.sh b/src/system/on-dhcpv6-event.sh
new file mode 100644
index 0000000..cbb3709
--- /dev/null
+++ b/src/system/on-dhcpv6-event.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+#
+# 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/>.
+#
+#
+
+if [ $# -lt 1 ]; then
+ echo Invalid args
+ logger -s -t on-dhcpv6-event "Invalid args \"$@\""
+ exit 1
+fi
+
+action=$1
+
+case "$action" in
+ lease6_renew|lease6_recover)
+ exit 0
+ ;;
+
+ lease6_release|lease6_expire|lease6_decline)
+ ifname=$QUERY6_IFACE_NAME
+ lease_addr=$LEASE6_ADDRESS
+ lease_prefix_len=$LEASE6_PREFIX_LEN
+
+ if [[ "$LEASE6_TYPE" != "IA_PD" ]]; then
+ exit 0
+ fi
+
+ logger -s -t on-dhcpv6-event "Processing route deletion for ${lease_addr}/${lease_prefix_len}"
+ route_cmd="sudo -n /sbin/ip -6 route del ${lease_addr}/${lease_prefix_len}"
+
+ # the ifname is not always present, like in LEASE6_VALID_LIFETIME=0 updates,
+ # but 'route del' works either way. Use interface only if there is one.
+ if [[ "$ifname" != "" ]]; then
+ route_cmd+=" dev ${ifname}"
+ fi
+ route_cmd+=" proto static"
+ eval "$route_cmd"
+
+ exit 0
+ ;;
+
+ leases6_committed)
+ for ((i = 0; i < $LEASES6_SIZE; i++)); do
+ ifname=$QUERY6_IFACE_NAME
+ requester_link_local=$QUERY6_REMOTE_ADDR
+ lease_type_var="LEASES6_AT${i}_TYPE"
+ lease_ip_var="LEASES6_AT${i}_ADDRESS"
+ lease_prefix_len_var="LEASES6_AT${i}_PREFIX_LEN"
+
+ lease_type=${!lease_type_var}
+
+ if [[ "$lease_type" != "IA_PD" ]]; then
+ continue
+ fi
+
+ lease_ip=${!lease_ip_var}
+ lease_prefix_len=${!lease_prefix_len_var}
+
+ logger -s -t on-dhcpv6-event "Processing PD route for ${lease_addr}/${lease_prefix_len}. Link local: ${requester_link_local} ifname: ${ifname}"
+
+ sudo -n /sbin/ip -6 route replace ${lease_ip}/${lease_prefix_len} \
+ via ${requester_link_local} \
+ dev ${ifname} \
+ proto static
+ done
+
+ exit 0
+ ;;
+
+ *)
+ logger -s -t on-dhcpv6-event "Invalid command \"$1\""
+ exit 1
+ ;;
+esac
diff --git a/src/system/post-upgrade b/src/system/post-upgrade
new file mode 100644
index 0000000..41b7c01
--- /dev/null
+++ b/src/system/post-upgrade
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+chown -R root:vyattacfg /config
diff --git a/src/system/standalone_root_pw_reset b/src/system/standalone_root_pw_reset
new file mode 100644
index 0000000..c82cea3
--- /dev/null
+++ b/src/system/standalone_root_pw_reset
@@ -0,0 +1,178 @@
+#!/bin/bash
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# Author: Bob Gilligan <gilligan@vyatta.com>
+# Description: Standalone script to set the admin passwd to new value
+# value. Note: This script can ONLY be run as a standalone
+# init program by grub.
+#
+# **** End License ****
+
+# The Vyatta config file:
+CF=/opt/vyatta/etc/config/config.boot
+
+# Admin user name
+ADMIN=vyos
+
+set_encrypted_password() {
+ sed -i \
+ -e "/ user $1 {/,/encrypted-password/s/encrypted-password .*\$/encrypted-password \"$2\"/" $3
+}
+
+
+# How long to wait for user to respond, in seconds
+TIME_TO_WAIT=30
+
+change_password() {
+ local user=$1
+ local pwd1="1"
+ local pwd2="2"
+
+ until [ "$pwd1" == "$pwd2" ]
+ do
+ read -p "Enter $user password: " -r -s pwd1
+ echo
+ read -p "Retype $user password: " -r -s pwd2
+ echo
+
+ if [ "$pwd1" != "$pwd2" ]
+ then echo "Passwords do not match"
+ fi
+ done
+
+ # set the password for the user then store it in the config
+ # so the user is recreated on the next full system boot.
+ local epwd=$(mkpasswd --method=sha-512 "$pwd1")
+ # escape any slashes in resulting password
+ local eepwd=$(sed 's:/:\\/:g' <<< $epwd)
+ set_encrypted_password $user $eepwd $CF
+}
+
+# System is so messed up that doing anything would be a mistake
+dead() {
+ echo $*
+ echo
+ echo "This tool can only recover missing admininistrator password."
+ echo "It is not a full system restore"
+ echo
+ echo -n "Hit return to reboot system: "
+ read
+ /sbin/reboot -f
+}
+
+echo "Standalone root password recovery tool."
+echo
+#
+# Check to see if we are running in standalone mode. We'll
+# know that we are if our pid is 1.
+#
+if [ "$$" != "1" ]; then
+ echo "This tool can only be run in standalone mode."
+ exit 1
+fi
+
+#
+# OK, now we know we are running in standalone mode. Talk to the
+# user.
+#
+echo -n "Do you wish to reset the admin password? (y or n) "
+read -t $TIME_TO_WAIT response
+if [ "$?" != "0" ]; then
+ echo
+ echo "Response not received in time."
+ echo "The admin password will not be reset."
+ echo "Rebooting in 5 seconds..."
+ sleep 5
+ echo
+ /sbin/reboot -f
+fi
+
+response=${response:0:1}
+if [ "$response" != "y" -a "$response" != "Y" ]; then
+ echo "OK, the admin password will not be reset."
+ echo -n "Rebooting in 5 seconds..."
+ sleep 5
+ echo
+ /sbin/reboot -f
+fi
+
+echo -en "Which admin account do you want to reset? [$ADMIN] "
+read admin_user
+ADMIN=${admin_user:-$ADMIN}
+
+echo "Starting process to reset the admin password..."
+
+echo "Re-mounting root filesystem read/write..."
+mount -o remount,rw /
+
+if [ ! -f /etc/passwd ]
+then dead "Missing password file"
+fi
+
+if [ ! -d /opt/vyatta/etc/config ]
+then dead "Missing VyOS config directory /opt/vyatta/etc/config"
+fi
+
+# Leftover from V3.0
+if grep -q /opt/vyatta/etc/config /etc/fstab
+then
+ echo "Mounting the config filesystem..."
+ mount /opt/vyatta/etc/config/
+fi
+
+if [ ! -f $CF ]
+then dead "$CF file not found"
+fi
+
+if ! grep -q 'system {' $CF
+then dead "$CF file does not contain system settings"
+fi
+
+if ! grep -q ' login {' $CF
+then
+ # Recreate login section of system
+ sed -i -e '/system {/a\
+ login {\
+ }' $CF
+fi
+
+if ! grep -q " user $ADMIN " $CF
+then
+ echo "Recreating administrator $ADMIN in $CF..."
+ sed -i -e "/ login {/a\\
+ user $ADMIN {\\
+ authentication {\\
+ encrypted-password \$6$IhbXHdwgYkLnt/$VRIsIN5c2f2v4L2l4F9WPDrRDEtWXzH75yBswmWGERAdX7oBxmq6m.sWON6pO6mi6mrVgYBxdVrFcCP5bI.nt.\\
+ plaintext-password \"\"\\
+ }\\
+ level admin\\
+ }" $CF
+fi
+
+echo "Saving backup copy of config.boot..."
+cp $CF ${CF}.before_pwrecovery
+sync
+
+echo "Setting the administrator ($ADMIN) password..."
+change_password $ADMIN
+
+echo $(date "+%b%e %T") $(hostname) "Admin password changed" \
+ | tee -a /var/log/auth.log >>/var/log/messages
+
+sync
+
+echo "System will reboot in 10 seconds..."
+sleep 10
+/sbin/reboot -f
diff --git a/src/system/uacctd_stop.py b/src/system/uacctd_stop.py
new file mode 100644
index 0000000..a1b5733
--- /dev/null
+++ b/src/system/uacctd_stop.py
@@ -0,0 +1,68 @@
+#!/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/>.
+
+# Control pmacct daemons in a tricky way.
+# Pmacct has signal processing in a main loop, together with packet
+# processing. Because of this, while it is waiting for packets, it cannot
+# handle the control signal. We need to start the systemctl command and then
+# send some packets to pmacct to wake it up
+
+from argparse import ArgumentParser
+from socket import socket, AF_INET, SOCK_DGRAM
+from sys import exit
+from time import sleep
+
+from psutil import Process
+
+
+def stop_process(pid: int, timeout: int) -> None:
+ """Send a signal to uacctd
+ and then send packets to special address predefined in a firewall
+ to unlock main loop in uacctd and finish the process properly
+
+ Args:
+ pid (int): uacctd PID
+ timeout (int): seconds to wait for a process end
+ """
+ # find a process
+ uacctd = Process(pid)
+ uacctd.terminate()
+
+ # create a socket
+ trigger = socket(AF_INET, SOCK_DGRAM)
+
+ first_cycle: bool = True
+ while uacctd.is_running() and timeout:
+ print('sending a packet to uacctd...')
+ trigger.sendto(b'WAKEUP', ('127.0.254.0', 1))
+ # do not sleep during first attempt
+ if not first_cycle:
+ sleep(1)
+ timeout -= 1
+ first_cycle = False
+
+
+if __name__ == '__main__':
+ parser = ArgumentParser()
+ parser.add_argument('process_id',
+ type=int,
+ help='PID file of uacctd core process')
+ parser.add_argument('timeout',
+ type=int,
+ help='time to wait for process end')
+ args = parser.parse_args()
+ stop_process(args.process_id, args.timeout)
+ exit()
diff --git a/src/system/vyos-config-cloud-init.py b/src/system/vyos-config-cloud-init.py
new file mode 100644
index 0000000..0a6c1f9
--- /dev/null
+++ b/src/system/vyos-config-cloud-init.py
@@ -0,0 +1,169 @@
+#!/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 logging
+from concurrent.futures import ProcessPoolExecutor
+from pathlib import Path
+from subprocess import run, TimeoutExpired
+from sys import exit
+
+from psutil import net_if_addrs, AF_LINK
+from systemd.journal import JournalHandler
+from yaml import safe_load
+
+from vyos.template import render
+
+# define a path to the configuration file and template
+config_file = '/etc/cloud/cloud.cfg.d/20_vyos_network.cfg'
+template_file = 'system/cloud_init_networking.j2'
+
+
+def check_interface_dhcp(iface_name: str) -> bool:
+ """Check DHCP client can work on an interface
+
+ Args:
+ iface_name (str): interface name
+
+ Returns:
+ bool: check result
+ """
+ dhclient_command: list[str] = [
+ 'dhclient', '-4', '-1', '-q', '--no-pid', '-sf', '/bin/true', iface_name
+ ]
+ check_result = False
+ # try to get an IP address
+ # we use dhclient behavior here to speedup detection
+ # if dhclient receives a configuration and configure an interface
+ # it switch to background
+ # If no - it will keep running in foreground
+ try:
+ run(['ip', 'l', 'set', iface_name, 'up'])
+ run(dhclient_command, timeout=5)
+ check_result = True
+ except TimeoutExpired:
+ pass
+ finally:
+ run(['ip', 'l', 'set', iface_name, 'down'])
+
+ logger.info(f'DHCP server was found on {iface_name}: {check_result}')
+ return check_result
+
+
+def dhclient_cleanup() -> None:
+ """Clean up after dhclients
+ """
+ run(['killall', 'dhclient'])
+ leases_file: Path = Path('/var/lib/dhcp/dhclient.leases')
+ leases_file.unlink(missing_ok=True)
+ logger.debug('cleaned up after dhclients')
+
+
+def dict_interfaces() -> dict[str, str]:
+ """Return list of available network interfaces except loopback
+
+ Returns:
+ list[str]: a list of interfaces
+ """
+ interfaces_dict: dict[str, str] = {}
+ ifaces = net_if_addrs()
+ for iface_name, iface_addresses in ifaces.items():
+ # we do not need loopback interface
+ if iface_name == 'lo':
+ continue
+ # check other interfaces for MAC addresses
+ for iface_addr in iface_addresses:
+ if iface_addr.family == AF_LINK and iface_addr.address:
+ interfaces_dict[iface_name] = iface_addr.address
+ continue
+
+ logger.debug(f'found interfaces: {interfaces_dict}')
+ return interfaces_dict
+
+
+def need_to_check() -> bool:
+ """Check if we need to perform DHCP checks
+
+ Returns:
+ bool: check result
+ """
+ # if cloud-init config does not exist, we do not need to do anything
+ ci_config_vyos = Path('/etc/cloud/cloud.cfg.d/20_vyos_custom.cfg')
+ if not ci_config_vyos.exists():
+ logger.info(
+ 'No need to check interfaces: Cloud-init config file was not found')
+ return False
+
+ # load configuration file
+ try:
+ config = safe_load(ci_config_vyos.read_text())
+ except:
+ logger.error('Cloud-init config file has a wrong format')
+ return False
+
+ # check if we have in config configured option
+ # vyos_config_options:
+ # network_preconfigure: true
+ if not config.get('vyos_config_options', {}).get('network_preconfigure'):
+ logger.info(
+ 'No need to check interfaces: Cloud-init config option "network_preconfigure" is not set'
+ )
+ return False
+
+ return True
+
+
+if __name__ == '__main__':
+ # prepare logger
+ logger = logging.getLogger(__name__)
+ logger.addHandler(JournalHandler(SYSLOG_IDENTIFIER=Path(__file__).name))
+ logger.setLevel(logging.INFO)
+
+ # we need to give udev some time to rename all interfaces
+ # this is placed before need_to_check() call, because we are not always
+ # need to preconfigure cloud-init, but udev always need to finish its work
+ # before cloud-init start
+ run(['udevadm', 'settle'])
+ logger.info('udev finished its work, we continue')
+
+ # do not perform any checks if this is not required
+ if not need_to_check():
+ exit()
+
+ # get list of interfaces and check them
+ interfaces_dhcp: list[dict[str, str]] = []
+ interfaces_dict: dict[str, str] = dict_interfaces()
+
+ with ProcessPoolExecutor(max_workers=len(interfaces_dict)) as executor:
+ iface_check_results = [{
+ 'dhcp': executor.submit(check_interface_dhcp, iface_name),
+ 'append': {
+ 'name': iface_name,
+ 'mac': iface_mac
+ }
+ } for iface_name, iface_mac in interfaces_dict.items()]
+
+ dhclient_cleanup()
+
+ for iface_check_result in iface_check_results:
+ if iface_check_result.get('dhcp').result():
+ interfaces_dhcp.append(iface_check_result.get('append'))
+
+ # render cloud-init config
+ if interfaces_dhcp:
+ logger.debug('rendering cloud-init network configuration')
+ render(config_file, template_file, {'ifaces_list': interfaces_dhcp})
+
+ exit()
diff --git a/src/system/vyos-event-handler.py b/src/system/vyos-event-handler.py
new file mode 100644
index 0000000..dd27930
--- /dev/null
+++ b/src/system/vyos-event-handler.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-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 argparse
+import json
+import re
+import select
+
+from copy import deepcopy
+from os import getpid, environ
+from pathlib import Path
+from signal import signal, SIGTERM, SIGINT
+from sys import exit
+from systemd import journal
+
+from vyos.utils.dict import dict_search
+from vyos.utils.process import run
+
+# Identify this script
+my_pid = getpid()
+my_name = Path(__file__).stem
+
+# handle termination signal
+def handle_signal(signal_type, frame):
+ if signal_type == SIGTERM:
+ journal.send('Received SIGTERM signal, stopping normally',
+ SYSLOG_IDENTIFIER=my_name)
+ if signal_type == SIGINT:
+ journal.send('Received SIGINT signal, stopping normally',
+ SYSLOG_IDENTIFIER=my_name)
+ exit(0)
+
+
+# Class for analyzing and process messages
+class Analyzer:
+ # Initialize settings
+ def __init__(self, config: dict) -> None:
+ self.config = {}
+ # Prepare compiled regex objects
+ for event_id, event_config in config.items():
+ script = dict_search('script.path', event_config)
+ # Check for arguments
+ if dict_search('script.arguments', event_config):
+ script_arguments = dict_search('script.arguments', event_config)
+ script = f'{script} {script_arguments}'
+ # Prepare environment
+ environment = deepcopy(environ)
+ # Check for additional environment options
+ if dict_search('script.environment', event_config):
+ for env_variable, env_value in dict_search(
+ 'script.environment', event_config).items():
+ environment[env_variable] = env_value.get('value')
+ # Create final config dictionary
+ pattern_raw = event_config['filter']['pattern']
+ pattern_compiled = re.compile(
+ rf'{event_config["filter"]["pattern"]}')
+ pattern_config = {
+ pattern_compiled: {
+ 'pattern_raw':
+ pattern_raw,
+ 'syslog_id':
+ dict_search('filter.syslog-identifier', event_config),
+ 'pattern_script': {
+ 'path': script,
+ 'environment': environment
+ }
+ }
+ }
+ self.config.update(pattern_config)
+
+ # Execute script safely
+ def script_run(self, pattern: str, script_path: str,
+ script_env: dict) -> None:
+ try:
+ run(script_path, env=script_env)
+ journal.send(
+ f'Pattern found: "{pattern}", script executed: "{script_path}"',
+ SYSLOG_IDENTIFIER=my_name)
+ except Exception as err:
+ journal.send(
+ f'Pattern found: "{pattern}", failed to execute script "{script_path}": {err}',
+ SYSLOG_IDENTIFIER=my_name)
+
+ # Analyze a message
+ def process_message(self, message: dict) -> None:
+ for pattern_compiled, pattern_config in self.config.items():
+ # Check if syslog id is presented in config and matches
+ syslog_id = pattern_config.get('syslog_id')
+ if syslog_id and message['SYSLOG_IDENTIFIER'] != syslog_id:
+ continue
+ if pattern_compiled.fullmatch(message['MESSAGE']):
+ # Add message to environment variables
+ pattern_config['pattern_script']['environment'][
+ 'message'] = message['MESSAGE']
+ # Run script
+ self.script_run(
+ pattern=pattern_config['pattern_raw'],
+ script_path=pattern_config['pattern_script']['path'],
+ script_env=pattern_config['pattern_script']['environment'])
+
+
+if __name__ == '__main__':
+ # Parse command arguments and get config
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-c',
+ '--config',
+ action='store',
+ help='Path to even-handler configuration',
+ required=True,
+ type=Path)
+
+ args = parser.parse_args()
+ try:
+ config_path = Path(args.config)
+ config = json.loads(config_path.read_text())
+ # Create an object for analazyng messages
+ analyzer = Analyzer(config)
+ except Exception as err:
+ print(
+ f'Configuration file "{config_path}" does not exist or malformed: {err}'
+ )
+ exit(1)
+
+ # Prepare for proper exitting
+ signal(SIGTERM, handle_signal)
+ signal(SIGINT, handle_signal)
+
+ # Set up journal connection
+ data = journal.Reader()
+ data.seek_tail()
+ data.get_previous()
+ p = select.poll()
+ p.register(data, data.get_events())
+
+ journal.send(f'Started with configuration: {config}',
+ SYSLOG_IDENTIFIER=my_name)
+
+ while p.poll():
+ if data.process() != journal.APPEND:
+ continue
+ for entry in data:
+ message = entry['MESSAGE']
+ pid = -1
+ try:
+ pid = entry['_PID']
+ except Exception as ex:
+ journal.send(f'Unable to extract PID from message entry: {entry}', SYSLOG_IDENTIFIER=my_name)
+ continue
+ # Skip empty messages and messages from this process
+ if message and pid != my_pid:
+ try:
+ analyzer.process_message(entry)
+ except Exception as err:
+ journal.send(f'Unable to process message: {err}',
+ SYSLOG_IDENTIFIER=my_name)
diff --git a/src/system/vyos-system-update-check.py b/src/system/vyos-system-update-check.py
new file mode 100644
index 0000000..c874f1e
--- /dev/null
+++ b/src/system/vyos-system-update-check.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022 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 json
+import jmespath
+
+from pathlib import Path
+from sys import exit
+from time import sleep
+
+from vyos.utils.process import call
+
+import vyos.version
+
+motd_file = Path('/run/motd.d/10-vyos-update')
+
+
+if __name__ == '__main__':
+ # Parse command arguments and get config
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-c',
+ '--config',
+ action='store',
+ help='Path to system-update-check configuration',
+ required=True,
+ type=Path)
+
+ args = parser.parse_args()
+ try:
+ config_path = Path(args.config)
+ config = json.loads(config_path.read_text())
+ except Exception as err:
+ print(
+ f'Configuration file "{config_path}" does not exist or malformed: {err}'
+ )
+ exit(1)
+
+ url_json = config.get('url')
+ local_data = vyos.version.get_full_version_data()
+ local_version = local_data.get('version')
+
+ while True:
+ remote_data = vyos.version.get_remote_version(url_json)
+ if remote_data:
+ url = jmespath.search('[0].url', remote_data)
+ remote_version = jmespath.search('[0].version', remote_data)
+ if local_version != remote_version and remote_version:
+ call(f'wall -n "Update available: {remote_version} \nUpdate URL: {url}"')
+ # MOTD used in /run/motd.d/10-update
+ motd_file.parent.mkdir(exist_ok=True)
+ motd_file.write_text(f'---\n'
+ f'Current version: {local_version}\n'
+ f'Update available: \033[1;34m{remote_version}\033[0m\n'
+ f'---\n')
+ # Check every 12 hours
+ sleep(43200)
diff --git a/src/systemd/LCDd.service b/src/systemd/LCDd.service
new file mode 100644
index 0000000..233c1e2
--- /dev/null
+++ b/src/systemd/LCDd.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=LCD display daemon
+Documentation=man:LCDd(8) http://www.lcdproc.org/
+RequiresMountsFor=/run
+ConditionPathExists=/run/LCDd/LCDd.conf
+After=vyos-router.service
+
+
+[Service]
+User=root
+ExecStart=/usr/sbin/LCDd -s 1 -f -c /run/LCDd/LCDd.conf
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/accel-ppp@.service b/src/systemd/accel-ppp@.service
new file mode 100644
index 0000000..2561127
--- /dev/null
+++ b/src/systemd/accel-ppp@.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Accel-PPP - High performance VPN server application for Linux
+RequiresMountsFor=/run
+ConditionPathExists=/run/accel-pppd/%i.conf
+After=vyos-router.service
+
+[Service]
+WorkingDirectory=/run/accel-pppd
+ExecStart=/usr/sbin/accel-pppd -d -p /run/accel-pppd/%i.pid -c /run/accel-pppd/%i.conf
+ExecReload=/bin/kill -SIGUSR1 $MAINPID
+PIDFile=/run/accel-pppd/%i.pid
+Type=forking
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/aws-gwlbtun.service b/src/systemd/aws-gwlbtun.service
new file mode 100644
index 0000000..97d772d
--- /dev/null
+++ b/src/systemd/aws-gwlbtun.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Description=AWS Gateway Load Balancer Tunnel Handler
+Documentation=https://github.com/aws-samples/aws-gateway-load-balancer-tunnel-handler
+After=network.target
+
+[Service]
+ExecStart=
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/dhclient@.service b/src/systemd/dhclient@.service
new file mode 100644
index 0000000..d430d88
--- /dev/null
+++ b/src/systemd/dhclient@.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=DHCP client on %i
+Documentation=man:dhclient(8)
+StartLimitIntervalSec=0
+After=vyos-router.service
+ConditionPathExists=/run/dhclient/dhclient_%i.conf
+
+[Service]
+Type=exec
+ExecStart=/sbin/dhclient -4 -d $DHCLIENT_OPTS
+ExecStop=/sbin/dhclient -4 -r $DHCLIENT_OPTS
+Restart=always
+RestartPreventExitStatus=
+RestartSec=10
+RuntimeDirectoryPreserve=yes
+TimeoutStopSec=20
+SendSIGKILL=true
+FinalKillSignal=SIGABRT
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/dhcp6c@.service b/src/systemd/dhcp6c@.service
new file mode 100644
index 0000000..f634bd9
--- /dev/null
+++ b/src/systemd/dhcp6c@.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=WIDE DHCPv6 client on %i
+Documentation=man:dhcp6c(8) man:dhcp6c.conf(5)
+StartLimitIntervalSec=0
+After=vyos-router.service
+
+[Service]
+Type=forking
+WorkingDirectory=/run/dhcp6c
+EnvironmentFile=-/run/dhcp6c/dhcp6c.%i.options
+PIDFile=/run/dhcp6c/dhcp6c.%i.pid
+ExecStart=/usr/sbin/dhcp6c $DHCP6C_OPTS
+Restart=always
+RestartPreventExitStatus=
+RestartSec=10
+RuntimeDirectoryPreserve=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/dropbear@.service b/src/systemd/dropbear@.service
new file mode 100644
index 0000000..acf926a
--- /dev/null
+++ b/src/systemd/dropbear@.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Dropbear SSH per-connection server
+Requires=dropbearkey.service
+Wants=conserver-server.service
+ConditionPathExists=/run/conserver/conserver.cf
+After=dropbearkey.service vyos-router.service conserver-server.service
+StartLimitIntervalSec=0
+
+[Service]
+Type=forking
+ExecStart=/usr/sbin/dropbear -w -j -k -r /etc/dropbear/dropbear_rsa_host_key -P /run/dropbear/dropbear.%I.pid -p %I
+PIDFile=/run/dropbear/dropbear.%I.pid
+KillMode=process
+Restart=always
+RestartSec=10
+RuntimeDirectoryPreserve=yes
diff --git a/src/systemd/dropbearkey.service b/src/systemd/dropbearkey.service
new file mode 100644
index 0000000..770641c
--- /dev/null
+++ b/src/systemd/dropbearkey.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Dropbear SSH Key Generation
+ConditionPathExists=|!/etc/dropbear/dropbear_rsa_host_key
+
+[Service]
+ExecStart=/usr/bin/dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
+
diff --git a/src/systemd/isc-dhcp-relay.service b/src/systemd/isc-dhcp-relay.service
new file mode 100644
index 0000000..de2e51a
--- /dev/null
+++ b/src/systemd/isc-dhcp-relay.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=ISC DHCP IPv4 relay
+Documentation=man:dhcrelay(8)
+Wants=network-online.target
+RequiresMountsFor=/run
+ConditionPathExists=/run/dhcp-relay/dhcrelay.conf
+After=vyos-router.service
+
+[Service]
+Type=forking
+WorkingDirectory=/run/dhcp-relay
+RuntimeDirectory=dhcp-relay
+RuntimeDirectoryPreserve=yes
+EnvironmentFile=/run/dhcp-relay/dhcrelay.conf
+PIDFile=/run/dhcp-relay/dhcrelay.pid
+ExecStart=/usr/sbin/dhcrelay -4 -pf /run/dhcp-relay/dhcrelay.pid $OPTIONS
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/isc-dhcp-relay6.service b/src/systemd/isc-dhcp-relay6.service
new file mode 100644
index 0000000..a365ae4
--- /dev/null
+++ b/src/systemd/isc-dhcp-relay6.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=ISC DHCP IPv6 relay
+Documentation=man:dhcrelay(8)
+Wants=network-online.target
+RequiresMountsFor=/run
+ConditionPathExists=/run/dhcp-relay/dhcrelay6.conf
+After=vyos-router.service
+StartLimitIntervalSec=0
+[Service]
+Type=forking
+WorkingDirectory=/run/dhcp-relay
+RuntimeDirectory=dhcp-relay
+RuntimeDirectoryPreserve=yes
+EnvironmentFile=/run/dhcp-relay/dhcrelay6.conf
+PIDFile=/run/dhcp-relay/dhcrelay6.pid
+ExecStart=/usr/sbin/dhcrelay -6 -pf /run/dhcp-relay/dhcrelay6.pid $OPTIONS
+Restart=always
+RestartSec=10
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/lcdproc.service b/src/systemd/lcdproc.service
new file mode 100644
index 0000000..ef71766
--- /dev/null
+++ b/src/systemd/lcdproc.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=LCDproc system status information viewer on %I
+Documentation=man:lcdproc(8) http://www.lcdproc.org/
+After=vyos-router.service LCDd.service
+Requires=LCDd.service
+
+[Service]
+User=root
+ExecStart=/usr/bin/lcdproc -f -c /run/lcdproc/lcdproc.conf
+PIDFile=/run/lcdproc/lcdproc.pid
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/ndppd.service b/src/systemd/ndppd.service
new file mode 100644
index 0000000..5790d37
--- /dev/null
+++ b/src/systemd/ndppd.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=NDP Proxy Daemon
+After=vyos-router.service
+ConditionPathExists=/run/ndppd/ndppd.conf
+StartLimitIntervalSec=0
+
+[Service]
+Type=forking
+ExecStart=/usr/sbin/ndppd -d -p /run/ndppd/ndppd.pid -c /run/ndppd/ndppd.conf
+PIDFile=/run/ndppd/ndppd.pid
+Restart=on-failure
+RestartSec=20
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/opennhrp.service b/src/systemd/opennhrp.service
new file mode 100644
index 0000000..c9a44de
--- /dev/null
+++ b/src/systemd/opennhrp.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=OpenNHRP
+After=vyos-router.service
+ConditionPathExists=/run/opennhrp/opennhrp.conf
+StartLimitIntervalSec=0
+
+[Service]
+Type=forking
+ExecStart=/usr/sbin/opennhrp -d -v -a /run/opennhrp.socket -c /run/opennhrp/opennhrp.conf -s /etc/opennhrp/opennhrp-script.py -p /run/opennhrp/opennhrp.pid
+ExecReload=/usr/bin/kill -HUP $MAINPID
+PIDFile=/run/opennhrp/opennhrp.pid
+Restart=on-failure
+RestartSec=20
diff --git a/src/systemd/podman.service b/src/systemd/podman.service
new file mode 100644
index 0000000..20a1630
--- /dev/null
+++ b/src/systemd/podman.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=Podman API Service
+Requires=podman.socket
+After=podman.socket
+Documentation=man:podman-system-service(1)
+StartLimitIntervalSec=0
+
+[Service]
+Delegate=true
+Type=exec
+KillMode=process
+Environment=LOGGING="--log-level=info"
+ExecStart=/usr/bin/podman $LOGGING system service
+
+[Install]
+WantedBy=default.target
diff --git a/src/systemd/podman.socket b/src/systemd/podman.socket
new file mode 100644
index 0000000..397058e
--- /dev/null
+++ b/src/systemd/podman.socket
@@ -0,0 +1,10 @@
+[Unit]
+Description=Podman API Socket
+Documentation=man:podman-system-service(1)
+
+[Socket]
+ListenStream=%t/podman/podman.sock
+SocketMode=0660
+
+[Install]
+WantedBy=sockets.target
diff --git a/src/systemd/ppp@.service b/src/systemd/ppp@.service
new file mode 100644
index 0000000..bb46220
--- /dev/null
+++ b/src/systemd/ppp@.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Dialing PPP connection %I
+After=vyos-router.service
+
+[Service]
+ExecStart=/usr/sbin/pppd call %I nodetach nolog
+Restart=on-failure
+RestartSec=5s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/root-partition-auto-resize.service b/src/systemd/root-partition-auto-resize.service
new file mode 100644
index 0000000..a57fbc3
--- /dev/null
+++ b/src/systemd/root-partition-auto-resize.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=VyOS root partition auto resizing
+After=multi-user.target
+
+[Service]
+Type=oneshot
+User=root
+Group=root
+ExecStart=/usr/libexec/vyos/op_mode/force_root-partition-auto-resize.sh
+
+[Install]
+WantedBy=vyos.target \ No newline at end of file
diff --git a/src/systemd/stunnel.service b/src/systemd/stunnel.service
new file mode 100644
index 0000000..b260e29
--- /dev/null
+++ b/src/systemd/stunnel.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=SSL tunneling service
+Documentation=http://man.he.net/man8/stunnel4
+After=network.target
+
+[Service]
+ExecStart=/usr/bin/stunnel /run/stunnel/stunnel.conf
+ExecReload=/bin/kill -HUP $MAINPID
+KillMode=process
+PIDFile=/run/stunnel/stunnel.pid
+Type=forking
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/telegraf.service b/src/systemd/telegraf.service
new file mode 100644
index 0000000..553942a
--- /dev/null
+++ b/src/systemd/telegraf.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=The plugin-driven server agent for reporting metrics into InfluxDB
+Documentation=https://github.com/influxdata/telegraf
+After=network.target
+
+[Service]
+EnvironmentFile=-/etc/default/telegraf
+ExecStart=/usr/bin/telegraf --config /run/telegraf/vyos-telegraf.conf --config-directory /etc/telegraf/telegraf.d
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=on-failure
+RestartForceExitStatus=SIGPIPE
+KillMode=control-group
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/tftpd@.service b/src/systemd/tftpd@.service
new file mode 100644
index 0000000..a674bf5
--- /dev/null
+++ b/src/systemd/tftpd@.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=TFTP server
+After=vyos-router.service
+RequiresMountsFor=/run
+
+[Service]
+Type=forking
+#NotifyAccess=main
+EnvironmentFile=-/etc/default/tftpd%I
+ExecStart=/bin/sh -c "${VRF_ARGS} /usr/sbin/in.tftpd ${DAEMON_ARGS}"
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-beep.service b/src/systemd/vyos-beep.service
new file mode 100644
index 0000000..78baa54
--- /dev/null
+++ b/src/systemd/vyos-beep.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=Beep after system start
+DefaultDependencies=no
+After=vyos.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/beep -f 130 -l 100 -n -f 262 -l 100 -n -f 330 -l 100 -n -f 392 -l 100 -n -f 523 -l 100 -n -f 660 -l 100 -n -f 784 -l 300 -n -f 660 -l 300
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-config-cloud-init.service b/src/systemd/vyos-config-cloud-init.service
new file mode 100644
index 0000000..ba6f90e
--- /dev/null
+++ b/src/systemd/vyos-config-cloud-init.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Pre-configure Cloud-init
+DefaultDependencies=no
+Requires=systemd-remount-fs.service
+Requires=systemd-udevd.service
+Wants=network-pre.target
+After=systemd-remount-fs.service
+After=systemd-udevd.service
+Before=cloud-init-local.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/libexec/vyos/system/vyos-config-cloud-init.py
+TimeoutSec=120
+KillMode=process
+StandardOutput=journal+console
+
+[Install]
+WantedBy=cloud-init-local.service
diff --git a/src/systemd/vyos-configd.service b/src/systemd/vyos-configd.service
new file mode 100644
index 0000000..274ccc7
--- /dev/null
+++ b/src/systemd/vyos-configd.service
@@ -0,0 +1,27 @@
+[Unit]
+Description=VyOS configuration daemon
+
+# Without this option, lots of default dependencies are added,
+# among them network.target, which creates a dependency cycle
+DefaultDependencies=no
+
+# Seemingly sensible way to say "as early as the system is ready"
+# All vyos-configd needs is read/write mounted root
+After=systemd-remount-fs.service
+Before=vyos-router.service
+
+[Service]
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-configd
+Type=idle
+
+SyslogIdentifier=vyos-configd
+SyslogFacility=daemon
+
+Restart=on-failure
+
+# Does't work in Jessie but leave it here
+User=root
+Group=vyattacfg
+
+[Install]
+WantedBy=vyos.target
diff --git a/src/systemd/vyos-conntrack-logger.service b/src/systemd/vyos-conntrack-logger.service
new file mode 100644
index 0000000..9bc1d85
--- /dev/null
+++ b/src/systemd/vyos-conntrack-logger.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=VyOS conntrack logger daemon
+
+# Seemingly sensible way to say "as early as the system is ready"
+# All vyos-configd needs is read/write mounted root
+After=conntrackd.service
+
+[Service]
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-conntrack-logger -c /run/vyos-conntrack-logger.conf
+Type=idle
+
+SyslogIdentifier=vyos-conntrack-logger
+SyslogFacility=daemon
+
+Restart=on-failure
+
+User=root
+Group=vyattacfg
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-domain-resolver.service b/src/systemd/vyos-domain-resolver.service
new file mode 100644
index 0000000..c56b51f
--- /dev/null
+++ b/src/systemd/vyos-domain-resolver.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=VyOS firewall domain resolver
+After=vyos-router.service
+
+[Service]
+Type=simple
+Restart=always
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/vyos-domain-resolver.py
+StandardError=journal
+StandardOutput=journal
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-event-handler.service b/src/systemd/vyos-event-handler.service
new file mode 100644
index 0000000..6afe4f9
--- /dev/null
+++ b/src/systemd/vyos-event-handler.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=VyOS event handler
+After=network.target vyos-router.service
+
+[Service]
+Type=simple
+Restart=always
+ExecStart=/usr/bin/python3 /usr/libexec/vyos/system/vyos-event-handler.py --config /run/vyos-event-handler.conf
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-grub-update.service b/src/systemd/vyos-grub-update.service
new file mode 100644
index 0000000..7b67ae1
--- /dev/null
+++ b/src/systemd/vyos-grub-update.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Update GRUB loader configuration structure
+After=local-fs.target
+Before=vyos-router.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/libexec/vyos/system/grub_update.py
+TimeoutSec=60
+KillMode=process
+StandardOutput=journal+console
+
+[Install]
+WantedBy=vyos-router.service
diff --git a/src/systemd/vyos-hostsd.service b/src/systemd/vyos-hostsd.service
new file mode 100644
index 0000000..4da55f5
--- /dev/null
+++ b/src/systemd/vyos-hostsd.service
@@ -0,0 +1,34 @@
+[Unit]
+Description=VyOS DNS configuration keeper
+
+# Without this option, lots of default dependencies are added,
+# among them network.target, which creates a dependency cycle
+DefaultDependencies=no
+
+# Seemingly sensible way to say "as early as the system is ready"
+# All vyos-hostsd needs is read/write mounted root
+After=systemd-remount-fs.service cloud-init.service
+
+[Service]
+WorkingDirectory=/run/vyos-hostsd
+RuntimeDirectory=vyos-hostsd
+RuntimeDirectoryPreserve=yes
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-hostsd
+Type=idle
+KillMode=process
+
+SyslogIdentifier=vyos-hostsd
+SyslogFacility=daemon
+
+Restart=on-failure
+
+# Does't work in Jessie but leave it here
+User=root
+Group=hostsd
+
+[Install]
+
+# Note: After= doesn't actually create a dependency,
+# it just sets order for the case when both services are to start,
+# and without RequiredBy it *does not* set vyos-hostsd to start.
+RequiredBy=cloud-init-local.service vyos-router.service
diff --git a/src/systemd/vyos-router.service b/src/systemd/vyos-router.service
new file mode 100644
index 0000000..7a1638f
--- /dev/null
+++ b/src/systemd/vyos-router.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=VyOS Router
+After=systemd-journald-dev-log.socket time-sync.target local-fs.target cloud-config.service
+Conflicts=shutdown.target
+Before=systemd-user-sessions.service
+
+[Service]
+Type=simple
+Restart=no
+TimeoutSec=20min
+KillMode=process
+RemainAfterExit=yes
+ExecStart=/usr/libexec/vyos/init/vyos-router start
+ExecStop=/usr/libexec/vyos/init/vyos-router stop
+StandardOutput=journal+console
+
+[Install]
+WantedBy=vyos.target
diff --git a/src/systemd/vyos-system-update.service b/src/systemd/vyos-system-update.service
new file mode 100644
index 0000000..032e5a1
--- /dev/null
+++ b/src/systemd/vyos-system-update.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=VyOS system udpate-check service
+After=network.target vyos-router.service
+
+[Service]
+Type=simple
+Restart=always
+ExecStart=/usr/bin/python3 /usr/libexec/vyos/system/vyos-system-update-check.py --config /run/vyos-system-update.conf
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service
new file mode 100644
index 0000000..7d62a2f
--- /dev/null
+++ b/src/systemd/vyos-wan-load-balance.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=VyOS WAN load-balancing service
+After=vyos-router.service
+
+[Service]
+ExecStart=/opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
+ExecReload=/bin/kill -s SIGTERM $MAINPID && sleep 5 && /opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
+ExecStop=/bin/kill -s SIGTERM $MAINPID
+PIDFile=/var/run/vyatta/wlb.pid
+KillMode=process
+Restart=on-failure
+RestartSec=5s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos.target b/src/systemd/vyos.target
new file mode 100644
index 0000000..47c91c1
--- /dev/null
+++ b/src/systemd/vyos.target
@@ -0,0 +1,3 @@
+[Unit]
+Description=VyOS target
+After=multi-user.target
diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service
new file mode 100644
index 0000000..ffb4fe3
--- /dev/null
+++ b/src/systemd/wpa_supplicant-macsec@.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=WPA supplicant daemon (MACsec-specific version)
+Requires=sys-subsystem-net-devices-%i.device
+ConditionPathExists=/run/wpa_supplicant/%I.conf
+After=vyos-router.service
+RequiresMountsFor=/run
+
+[Service]
+Type=simple
+WorkingDirectory=/run/wpa_supplicant
+PIDFile=/run/wpa_supplicant/%I.pid
+ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/tests/test_configd_inspect.py b/src/tests/test_configd_inspect.py
new file mode 100644
index 0000000..ccd6318
--- /dev/null
+++ b/src/tests/test_configd_inspect.py
@@ -0,0 +1,104 @@
+# Copyright (C) 2020-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 re
+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
+
+class TestConfigdInspect(TestCase):
+ def setUp(self):
+ 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
+ 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")
diff --git a/src/validators/port-range-exclude b/src/validators/port-range-exclude
new file mode 100644
index 0000000..4c049e9
--- /dev/null
+++ b/src/validators/port-range-exclude
@@ -0,0 +1,7 @@
+#!/bin/sh
+arg="$1"
+if [ "${arg:0:1}" != "!" ]; then
+ exit 1
+fi
+path=$(dirname "$0")
+${path}/port-range "${arg:1}"
diff --git a/src/validators/psk-secret b/src/validators/psk-secret
new file mode 100644
index 0000000..c91aa95
--- /dev/null
+++ b/src/validators/psk-secret
@@ -0,0 +1,39 @@
+#!/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 re
+from sys import argv,exit
+
+if __name__ == '__main__':
+ if len(argv) != 2:
+ exit(1)
+
+ input = argv[1]
+ is_valid = True
+ try:
+ # Convert hexadecimal input to binary form
+ key_bytes = bytes.fromhex(input)
+ except ValueError:
+ is_valid = False
+
+ if is_valid and len(key_bytes) < 16:
+ is_valid = False
+
+ if not is_valid:
+ print(f'Error: {input} is not valid psk secret.')
+ exit(1)
+
+ exit(0) \ No newline at end of file