diff options
114 files changed, 2553 insertions, 485 deletions
| @@ -6,6 +6,7 @@ SHIM_DIR := src/shim  XDP_DIR := src/xdp  LIBS := -lzmq  CFLAGS := +BUILD_ARCH := $(shell dpkg-architecture -q DEB_BUILD_ARCH)  J2LINT := $(shell command -v j2lint 2> /dev/null) @@ -49,6 +50,11 @@ interface_definitions: $(config_xml_obj)  	# could mask help strings or mandatory priority statements  	find $(TMPL_DIR) -name node.def -type f -empty -exec false {} + || sh -c 'echo "There are empty node.def files! Check your interface definitions." && exit 1' +ifeq ($(BUILD_ARCH),arm64) +	# There is currently no telegraf support in VyOS for ARM64, remove CLI definitions +	rm -rf $(TMPL_DIR)/service/monitoring/telegraf +endif +  .PHONY: op_mode_definitions  .ONESHELL:  op_mode_definitions: $(op_xml_obj) diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json new file mode 100644 index 000000000..4dccbba7f --- /dev/null +++ b/data/op-mode-standardized.json @@ -0,0 +1,7 @@ +[ +"cpu.py", +"memory.py", +"neighbor.py", +"route.py", +"version.py" +] diff --git a/data/templates/ids/fastnetmon.j2 b/data/templates/ids/fastnetmon.j2 index c482002fa..b9f77a257 100644 --- a/data/templates/ids/fastnetmon.j2 +++ b/data/templates/ids/fastnetmon.j2 @@ -1,21 +1,25 @@  # enable this option if you want to send logs to local syslog facility +logging:logging_level = debug  logging:local_syslog_logging = on  # list of all your networks in CIDR format -networks_list_path = /etc/networks_list +networks_list_path = /run/fastnetmon/networks_list  # list networks in CIDR format which will be not monitored for attacks -white_list_path = /etc/networks_whitelist +white_list_path = /run/fastnetmon/excluded_networks_list  # Enable/Disable any actions in case of attack  enable_ban = on +enable_ban_ipv6 = on  ## How many packets will be collected from attack traffic  ban_details_records_count = 500  ## How long (in seconds) we should keep an IP in blocked state  ## If you set 0 here it completely disables unban capability -ban_time = 1900 +{% if ban_time is vyos_defined %} +ban_time = {{ ban_time }} +{% endif %}  # Check if the attack is still active, before triggering an unban callback with this option  # If the attack is still active, check each run of the unban watchdog diff --git a/data/templates/ids/fastnetmon_excluded_networks_list.j2 b/data/templates/ids/fastnetmon_excluded_networks_list.j2 new file mode 100644 index 000000000..c88a1c527 --- /dev/null +++ b/data/templates/ids/fastnetmon_excluded_networks_list.j2 @@ -0,0 +1,5 @@ +{% if excluded_network is vyos_defined %} +{%     for net in excluded_network %} +{{ net }} +{%     endfor %} +{% endif %} diff --git a/data/templates/ids/fastnetmon_networks_list.j2 b/data/templates/ids/fastnetmon_networks_list.j2 index 1c81180be..5f1b3ba4d 100644 --- a/data/templates/ids/fastnetmon_networks_list.j2 +++ b/data/templates/ids/fastnetmon_networks_list.j2 @@ -1,6 +1,4 @@ -{% if network is vyos_defined(var_type=str) %} -{{ network }} -{% else %} +{% if network is vyos_defined() %}  {%     for net in network %}  {{ net }}  {%     endfor %} diff --git a/data/templates/pmacct/uacctd.conf.j2 b/data/templates/pmacct/uacctd.conf.j2 index a5016691f..8fbc09e83 100644 --- a/data/templates/pmacct/uacctd.conf.j2 +++ b/data/templates/pmacct/uacctd.conf.j2 @@ -21,13 +21,13 @@ imt_mem_pools_number: 169  {% set plugin = [] %}  {% if netflow.server is vyos_defined %}  {%     for server in netflow.server %} -{%         set nf_server_key = 'nf_' ~ server | replace(':', '.') %} +{%         set nf_server_key = 'nf_' ~ server | dot_colon_to_dash %}  {%         set _ = plugin.append('nfprobe['~ nf_server_key ~ ']') %}  {%     endfor %}  {% endif %}  {% if sflow.server is vyos_defined %}  {%     for server in sflow.server %} -{%         set sf_server_key = 'sf_' ~ server | replace(':', '.') %} +{%         set sf_server_key = 'sf_' ~ server | dot_colon_to_dash %}  {%         set _ = plugin.append('sfprobe[' ~ sf_server_key ~ ']') %}  {%     endfor %}  {% endif %} @@ -40,7 +40,7 @@ plugins: {{ plugin | join(',') }}  # NetFlow servers  {%     for server, server_config in netflow.server.items() %}  {#         # prevent pmacct syntax error when using IPv6 flow collectors #} -{%         set nf_server_key = 'nf_' ~ server | replace(':', '.') %} +{%         set nf_server_key = 'nf_' ~ server | dot_colon_to_dash %}  nfprobe_receiver[{{ nf_server_key }}]: {{ server | bracketize_ipv6 }}:{{ server_config.port }}  nfprobe_version[{{ nf_server_key }}]: {{ netflow.version }}  {%         if netflow.engine_id is vyos_defined %} @@ -66,7 +66,7 @@ nfprobe_timeouts[{{ nf_server_key }}]: expint={{ netflow.timeout.expiry_interval  # sFlow servers  {%     for server, server_config in sflow.server.items() %}  {#         # prevent pmacct syntax error when using IPv6 flow collectors #} -{%         set sf_server_key = 'sf_' ~ server | replace(':', '.') %} +{%         set sf_server_key = 'sf_' ~ server | dot_colon_to_dash %}  sfprobe_receiver[{{ sf_server_key }}]: {{ server | bracketize_ipv6 }}:{{ server_config.port }}  sfprobe_agentip[{{ sf_server_key }}]: {{ sflow.agent_address }}  {%         if sflow.sampling_rate is vyos_defined %} diff --git a/data/templates/router-advert/radvd.conf.j2 b/data/templates/router-advert/radvd.conf.j2 index ed15b32f0..a464795ad 100644 --- a/data/templates/router-advert/radvd.conf.j2 +++ b/data/templates/router-advert/radvd.conf.j2 @@ -50,6 +50,8 @@ interface {{ iface }} {          AdvValidLifetime {{ prefix_options.valid_lifetime }};          AdvOnLink {{ 'off' if prefix_options.no_on_link_flag is vyos_defined else 'on' }};          AdvPreferredLifetime {{ prefix_options.preferred_lifetime }}; +        DeprecatePrefix {{ 'on' if prefix_options.deprecate_prefix is vyos_defined else 'off' }}; +        DecrementLifetimes {{ 'on' if prefix_options.decrement_lifetime is vyos_defined else 'off' }};      };  {%             endfor %}  {%         endif %} diff --git a/data/templates/ssh/override.conf.j2 b/data/templates/ssh/override.conf.j2 index e4d6f51cb..4454ad1b8 100644 --- a/data/templates/ssh/override.conf.j2 +++ b/data/templates/ssh/override.conf.j2 @@ -5,8 +5,9 @@ After=vyos-router.service  ConditionPathExists={{ config_file }}  [Service] +EnvironmentFile=  ExecStart= -ExecStart={{ vrf_command }}/usr/sbin/sshd -f {{ config_file }} -D $SSHD_OPTS +ExecStart={{ vrf_command }}/usr/sbin/sshd -f {{ config_file }}  Restart=always  RestartPreventExitStatus=  RestartSec=10 diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index 1ca6687a3..6879b6e4f 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -13,6 +13,7 @@ if ! grep -q '^minion' /etc/passwd; then      adduser --quiet minion dip      adduser --quiet minion disk      adduser --quiet minion users +    adduser --quiet minion frr  fi  # OpenVPN should get its own user @@ -45,6 +46,7 @@ if ! grep -q '^radius_priv_user' /etc/passwd; then      adduser --quiet radius_priv_user dip      adduser --quiet radius_priv_user disk      adduser --quiet radius_priv_user users +    adduser --quiet radius_priv_user frr  fi  # add hostsd group for vyos-hostsd @@ -86,11 +88,17 @@ fi  # Remove unwanted daemon files from /etc  # conntackd +# pmacct +# fastnetmon +# ntp  DELETE="/etc/logrotate.d/conntrackd.distrib /etc/init.d/conntrackd /etc/default/conntrackd -        /etc/default/pmacctd /etc/pmacct" -for file in $DELETE; do -    if [ -f ${file} ]; then -        rm -f ${file} +        /etc/default/pmacctd /etc/pmacct +        /etc/networks_list /etc/networks_whitelist /etc/fastnetmon.conf +        /etc/ntp.conf /etc/default/ssh +        /etc/powerdns /etc/default/pdns-recursor" +for tmp in $DELETE; do +    if [ -e ${tmp} ]; then +        rm -rf ${tmp}      fi  done diff --git a/interface-definitions/https.xml.in b/interface-definitions/https.xml.in index d2c393036..d096c4ff1 100644 --- a/interface-definitions/https.xml.in +++ b/interface-definitions/https.xml.in @@ -107,6 +107,19 @@                    <valueless/>                  </properties>                </leafNode> +              <node name="gql"> +                <properties> +                  <help>GraphQL support</help> +                </properties> +                <children> +                  <leafNode name="introspection"> +                    <properties> +                      <help>Schema introspection</help> +                      <valueless/> +                    </properties> +                  </leafNode> +                </children> +              </node>                <node name="cors">                  <properties>                    <help>Set CORS options</help> diff --git a/interface-definitions/include/interface/enable-directed-broadcast.xml.i b/interface-definitions/include/interface/enable-directed-broadcast.xml.i new file mode 100644 index 000000000..a87395806 --- /dev/null +++ b/interface-definitions/include/interface/enable-directed-broadcast.xml.i @@ -0,0 +1,8 @@ +<!-- include start from interface/enable-directed-broadcast.xml.i --> +<leafNode name="enable-directed-broadcast"> +  <properties> +    <help>Enable directed broadcast forwarding on this interface</help> +    <valueless/> +  </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/interface/ipv4-options.xml.i b/interface-definitions/include/interface/ipv4-options.xml.i index bca1229c6..eda77e851 100644 --- a/interface-definitions/include/interface/ipv4-options.xml.i +++ b/interface-definitions/include/interface/ipv4-options.xml.i @@ -8,6 +8,7 @@      #include <include/interface/arp-cache-timeout.xml.i>      #include <include/interface/disable-arp-filter.xml.i>      #include <include/interface/disable-forwarding.xml.i> +    #include <include/interface/enable-directed-broadcast.xml.i>      #include <include/interface/enable-arp-accept.xml.i>      #include <include/interface/enable-arp-announce.xml.i>      #include <include/interface/enable-arp-ignore.xml.i> diff --git a/interface-definitions/interfaces-bonding.xml.in b/interface-definitions/interfaces-bonding.xml.in index 96dede723..8b6c6ef62 100644 --- a/interface-definitions/interfaces-bonding.xml.in +++ b/interface-definitions/interfaces-bonding.xml.in @@ -94,6 +94,23 @@            #include <include/interface/ipv4-options.xml.i>            #include <include/interface/ipv6-options.xml.i>            #include <include/interface/mac.xml.i> +          <leafNode name="mii-mon-interval"> +            <properties> +              <help>Specifies the MII link monitoring frequency in milliseconds</help> +              <valueHelp> +                <format>u32:0</format> +                <description>Disable MII link monitoring</description> +              </valueHelp> +              <valueHelp> +                <format>u32:50-1000</format> +                <description>MII link monitoring frequency in milliseconds</description> +              </valueHelp> +              <constraint> +                <validator name="numeric" argument="--range 0-0 --range 50-1000"/> +              </constraint> +            </properties> +            <defaultValue>100</defaultValue> +          </leafNode>            <leafNode name="min-links">              <properties>                <help>Minimum number of member interfaces required up before enabling bond</help> diff --git a/interface-definitions/interfaces-macsec.xml.in b/interface-definitions/interfaces-macsec.xml.in index dbb989588..adb48813f 100644 --- a/interface-definitions/interfaces-macsec.xml.in +++ b/interface-definitions/interfaces-macsec.xml.in @@ -63,11 +63,12 @@                      <properties>                        <help>Secure Connectivity Association Key</help>                        <valueHelp> -                        <format>key</format> -                        <description>16-byte (128-bit) hex-string (32 hex-digits)</description> +                        <format>txt</format> +                        <description>16-byte (128-bit) hex-string (32 hex-digits) for gcm-aes-128 or 32-byte (256-bit) hex-string (64 hex-digits) for gcm-aes-256</description>                        </valueHelp>                        <constraint>                          <regex>[A-Fa-f0-9]{32}</regex> +                        <regex>[A-Fa-f0-9]{64}</regex>                        </constraint>                      </properties>                    </leafNode> @@ -75,7 +76,7 @@                      <properties>                        <help>Secure Connectivity Association Key Name</help>                        <valueHelp> -                        <format>key</format> +                        <format>txt</format>                          <description>32-byte (256-bit) hex-string (64 hex-digits)</description>                        </valueHelp>                        <constraint> diff --git a/interface-definitions/policy.xml.in b/interface-definitions/policy.xml.in index 0d0ada591..cc1de609d 100644 --- a/interface-definitions/policy.xml.in +++ b/interface-definitions/policy.xml.in @@ -639,7 +639,7 @@                            </leafNode>                            <leafNode name="prefix-len">                              <properties> -                              <help>IP prefix-length to match</help> +                              <help>IP prefix-length to match (can be used for kernel routes only)</help>                                <valueHelp>                                  <format>u32:0-32</format>                                  <description>Prefix length</description> @@ -809,7 +809,7 @@                            </leafNode>                            <leafNode name="prefix-len">                              <properties> -                              <help>IPv6 prefix-length to match</help> +                              <help>IPv6 prefix-length to match (can be used for kernel routes only)</help>                                <valueHelp>                                  <format>u32:0-128</format>                                  <description>Prefix length</description> diff --git a/interface-definitions/service-ids-ddos-protection.xml.in b/interface-definitions/service-ids-ddos-protection.xml.in index 5e65d3106..86fc4dffa 100644 --- a/interface-definitions/service-ids-ddos-protection.xml.in +++ b/interface-definitions/service-ids-ddos-protection.xml.in @@ -18,6 +18,19 @@                    <help>Path to fastnetmon alert script</help>                  </properties>                </leafNode> +              <leafNode name="ban-time"> +                <properties> +                  <help>How long we should keep an IP in blocked state</help> +                  <valueHelp> +                    <format>u32:1-4294967294</format> +                    <description>Time in seconds</description> +                  </valueHelp> +                  <constraint> +                    <validator name="numeric" argument="--range 1-4294967294"/> +                  </constraint> +                </properties> +                <defaultValue>1900</defaultValue> +              </leafNode>                <leafNode name="direction">                  <properties>                    <help>Direction for processing traffic</help> @@ -30,6 +43,24 @@                    <multi/>                  </properties>                </leafNode> +              <leafNode name="excluded-network"> +                <properties> +                  <help>Specify IPv4 and IPv6 networks which are going to be excluded from protection</help> +                  <valueHelp> +                    <format>ipv4net</format> +                    <description>IPv4 prefix(es) to exclude</description> +                  </valueHelp> +                  <valueHelp> +                    <format>ipv6net</format> +                    <description>IPv6 prefix(es) to exclude</description> +                  </valueHelp> +                  <constraint> +                    <validator name="ipv4-prefix"/> +                    <validator name="ipv6-prefix"/> +                  </constraint> +                  <multi/> +                </properties> +              </leafNode>                <leafNode name="listen-interface">                  <properties>                    <help>Listen interface for mirroring traffic</help> @@ -55,13 +86,18 @@                </node>                <leafNode name="network">                  <properties> -                  <help>Define monitoring networks</help> +                  <help>Specify IPv4 and IPv6 networks which belong to you</help>                    <valueHelp>                      <format>ipv4net</format> -                    <description>Processed network</description> +                    <description>Your IPv4 prefix(es)</description> +                  </valueHelp> +                  <valueHelp> +                    <format>ipv6net</format> +                    <description>Your IPv6 prefix(es)</description>                    </valueHelp>                    <constraint>                      <validator name="ipv4-prefix"/> +                    <validator name="ipv6-prefix"/>                    </constraint>                    <multi/>                  </properties> diff --git a/interface-definitions/service-router-advert.xml.in b/interface-definitions/service-router-advert.xml.in index 258b7b749..87ec512d6 100644 --- a/interface-definitions/service-router-advert.xml.in +++ b/interface-definitions/service-router-advert.xml.in @@ -249,6 +249,18 @@                        <valueless/>                      </properties>                    </leafNode> +                  <leafNode name="deprecate-prefix"> +                    <properties> +                      <help>Upon shutdown, this option will deprecate the prefix by announcing it in the shutdown RA</help> +                      <valueless/> +                    </properties> +                  </leafNode> +                  <leafNode name="decrement-lifetime"> +                    <properties> +                      <help>Lifetime is decremented by the number of seconds since the last RA - use in conjunction with a DHCPv6-PD prefix</help> +                      <valueless/> +                    </properties> +                  </leafNode>                    <leafNode name="preferred-lifetime">                      <properties>                        <help>Time in seconds that the prefix will remain preferred</help> diff --git a/interface-definitions/system-ip.xml.in b/interface-definitions/system-ip.xml.in index 21d70694b..e00dbf252 100644 --- a/interface-definitions/system-ip.xml.in +++ b/interface-definitions/system-ip.xml.in @@ -23,6 +23,12 @@                <valueless/>              </properties>            </leafNode> +          <leafNode name="disable-directed-broadcast"> +            <properties> +              <help>Disable IPv4 directed broadcast forwarding on all interfaces</help> +              <valueless/> +            </properties> +          </leafNode>            <node name="multipath">              <properties>                <help>IPv4 multipath settings</help> diff --git a/interface-definitions/xml-component-version.xml.in b/interface-definitions/xml-component-version.xml.in index b7f063a6c..cf86f83d6 100644 --- a/interface-definitions/xml-component-version.xml.in +++ b/interface-definitions/xml-component-version.xml.in @@ -20,6 +20,7 @@    #include <include/version/l2tp-version.xml.i>    #include <include/version/lldp-version.xml.i>    #include <include/version/mdns-version.xml.i> +  #include <include/version/monitoring-version.xml.i>    #include <include/version/nat66-version.xml.i>    #include <include/version/nat-version.xml.i>    #include <include/version/ntp-version.xml.i> diff --git a/op-mode-definitions/include/bgp/afi-ipv4-ipv6-common.xml.i b/op-mode-definitions/include/bgp/afi-ipv4-ipv6-common.xml.i index 084f5da83..d2804e3b3 100644 --- a/op-mode-definitions/include/bgp/afi-ipv4-ipv6-common.xml.i +++ b/op-mode-definitions/include/bgp/afi-ipv4-ipv6-common.xml.i @@ -151,7 +151,7 @@  </leafNode>  <tagNode name="neighbors">    <properties> -    <help>Show detailed BGP IPv4 unicast neighbor information</help> +    <help>Show BGP information for specified neighbor</help>      <completionHelp>        <script>vtysh -c 'show bgp summary' | awk '{print $1'} | grep -e '^[0-9a-f]'</script>      </completionHelp> diff --git a/op-mode-definitions/ipv4-route.xml.in b/op-mode-definitions/ipv4-route.xml.in index 8f001d5bb..660b34496 100644 --- a/op-mode-definitions/ipv4-route.xml.in +++ b/op-mode-definitions/ipv4-route.xml.in @@ -39,7 +39,7 @@                      <list><x.x.x.x></list>                    </completionHelp>                  </properties> -                <command>sudo ip neigh flush to "$5"</command> +                <command>sudo ${vyos_op_scripts_dir}/neighbor.py reset --family inet --address "$5"</command>                </tagNode>                <tagNode name="interface">                  <properties> @@ -48,8 +48,14 @@                      <script>${vyos_completion_dir}/list_interfaces.py</script>                    </completionHelp>                  </properties> -                <command>sudo ip neigh flush dev "$5"</command> +                <command>sudo ${vyos_op_scripts_dir}/neighbor.py reset --family inet --interface "$5"</command>                </tagNode> +              <node name="table"> +                <properties> +                  <help>Flush the ARP cache completely</help> +                </properties> +                <command>sudo ${vyos_op_scripts_dir}/neighbor.py reset --family inet</command> +              </node>              </children>            </node>            <node name="route"> diff --git a/op-mode-definitions/ipv6-route.xml.in b/op-mode-definitions/ipv6-route.xml.in index 4f8792f9f..d75caf308 100644 --- a/op-mode-definitions/ipv6-route.xml.in +++ b/op-mode-definitions/ipv6-route.xml.in @@ -20,7 +20,7 @@              <properties>                <help>Show IPv6 neighbor (NDP) table</help>              </properties> -            <command>${vyos_op_scripts_dir}/show_neigh.py --family inet6</command> +            <command>${vyos_op_scripts_dir}/neighbor.py show --family inet6</command>              <children>                <tagNode name="interface">                  <properties> @@ -29,7 +29,7 @@                      <script>${vyos_completion_dir}/list_interfaces.py -b</script>                    </completionHelp>                  </properties> -                <command>${vyos_op_scripts_dir}/show_neigh.py --family inet6 --interface "$5"</command> +                <command>${vyos_op_scripts_dir}/neighbor.py show --family inet6 --interface "$5"</command>                </tagNode>                <tagNode name="state">                  <properties> @@ -38,7 +38,7 @@                      <list>reachable stale failed permanent</list>                    </completionHelp>                  </properties> -                <command>${vyos_op_scripts_dir}/show_neigh.py --family inet6 --state "$5"</command> +                <command>${vyos_op_scripts_dir}/neighbor.py show --family inet6 --state "$5"</command>                </tagNode>              </children>            </node> diff --git a/op-mode-definitions/monitor-log.xml.in b/op-mode-definitions/monitor-log.xml.in index f5e0ede59..fbe37c9db 100644 --- a/op-mode-definitions/monitor-log.xml.in +++ b/op-mode-definitions/monitor-log.xml.in @@ -14,6 +14,19 @@              </properties>              <command>grc journalctl --no-hostname --follow --boot</command>            </node> +          <node name="ids"> +            <properties> +              <help>Monitor log for Intrusion Detection System</help> +            </properties> +            <children> +              <leafNode name="ddos-protection"> +                <properties> +                  <help>Monitor last lines of DDOS protection</help> +                </properties> +                <command>journalctl --no-hostname --follow --boot --unit fastnetmon.service</command> +              </leafNode> +            </children> +          </node>            <node name="dhcp">              <properties>                <help>Monitor last lines of Dynamic Host Control Protocol (DHCP)</help> @@ -111,7 +124,7 @@            </node>            <node name="protocol">              <properties> -              <help>Monitor log for Routing Protocols</help> +              <help>Monitor log for Routing Protocol</help>              </properties>              <children>                <leafNode name="ospf"> @@ -182,6 +195,18 @@                </leafNode>              </children>            </node> +          <leafNode name="snmp"> +            <properties> +              <help>Monitor last lines of Simple Network Monitoring Protocol (SNMP)</help> +            </properties> +            <command>journalctl --no-hostname --boot --follow --unit snmpd.service</command> +          </leafNode> +          <leafNode name="ssh"> +            <properties> +              <help>Monitor last lines of Secure Shell (SSH)</help> +            </properties> +            <command>journalctl --no-hostname --boot --follow --unit ssh.service</command> +          </leafNode>          </children>        </node>      </children> diff --git a/op-mode-definitions/nat.xml.in b/op-mode-definitions/nat.xml.in index 084e2e7e3..b0ec8989f 100644 --- a/op-mode-definitions/nat.xml.in +++ b/op-mode-definitions/nat.xml.in @@ -16,13 +16,13 @@                  <properties>                    <help>Show configured source NAT rules</help>                  </properties> -                <command>${vyos_op_scripts_dir}/show_nat_rules.py --source</command> +                <command>${vyos_op_scripts_dir}/nat.py show_rules --direction source</command>                </node>                <node name="statistics">                  <properties>                    <help>Show statistics for configured source NAT rules</help>                  </properties> -                <command>${vyos_op_scripts_dir}/show_nat_statistics.py --source</command> +                <command>${vyos_op_scripts_dir}/nat.py show_statistics --direction source</command>                </node>                <node name="translations">                  <properties> diff --git a/op-mode-definitions/show-arp.xml.in b/op-mode-definitions/show-arp.xml.in index 58cc6e45e..8662549fc 100644 --- a/op-mode-definitions/show-arp.xml.in +++ b/op-mode-definitions/show-arp.xml.in @@ -6,7 +6,7 @@          <properties>            <help>Show Address Resolution Protocol (ARP) information</help>          </properties> -        <command>${vyos_op_scripts_dir}/show_neigh.py --family inet</command> +        <command>${vyos_op_scripts_dir}/neighbor.py show --family inet</command>          <children>            <tagNode name="interface">              <properties> @@ -15,7 +15,7 @@                  <script>${vyos_completion_dir}/list_interfaces.py -b</script>                </completionHelp>              </properties> -            <command>${vyos_op_scripts_dir}/show_neigh.py --family inet --interface "$4"</command> +            <command>${vyos_op_scripts_dir}/neighbor.py show --family inet --interface "$4"</command>            </tagNode>          </children>        </node> diff --git a/op-mode-definitions/show-bridge.xml.in b/op-mode-definitions/show-bridge.xml.in index 0f8d3064d..dd2a28931 100644 --- a/op-mode-definitions/show-bridge.xml.in +++ b/op-mode-definitions/show-bridge.xml.in @@ -11,7 +11,7 @@              <properties>                <help>View the VLAN filter settings of the bridge</help>              </properties> -            <command>bridge -c vlan show</command> +            <command>${vyos_op_scripts_dir}/bridge.py show_vlan</command>            </leafNode>          </children>        </node> @@ -19,7 +19,7 @@          <properties>            <help>Show bridging information</help>          </properties> -        <command>bridge -c link show</command> +        <command>${vyos_op_scripts_dir}/bridge.py show</command>        </leafNode>        <tagNode name="bridge">          <properties> @@ -34,13 +34,13 @@              <properties>                <help>Displays the multicast group database for the bridge</help>              </properties> -            <command>bridge -c mdb show dev $3</command> +            <command>${vyos_op_scripts_dir}/bridge.py show_mdb --interface=$3</command>            </leafNode>            <leafNode name="fdb">              <properties>                <help>Show the forwarding database of the bridge</help>              </properties> -            <command>bridge -c fdb show br $3</command> +            <command>${vyos_op_scripts_dir}/bridge.py show_fdb --interface=$3</command>            </leafNode>          </children>        </tagNode> diff --git a/op-mode-definitions/show-conntrack.xml.in b/op-mode-definitions/show-conntrack.xml.in new file mode 100644 index 000000000..8d921e6a5 --- /dev/null +++ b/op-mode-definitions/show-conntrack.xml.in @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<interfaceDefinition> +  <node name="show"> +    <children> +      <node name="conntrack"> +        <properties> +          <help>Show conntrack tables entries</help> +        </properties> +        <children> +          <node name="table"> +            <properties> +              <help>Show conntrack entries for table</help> +            </properties> +            <children> +              <node name="ipv4"> +                <properties> +                  <help>Show conntrack entries for IPv4 protocol</help> +                </properties> +                <command>sudo ${vyos_op_scripts_dir}/conntrack.py show --family inet</command> +              </node> +              <node name="ipv6"> +                <properties> +                  <help>Show conntrack entries for IPv6 protocol</help> +                </properties> +                <command>sudo ${vyos_op_scripts_dir}/conntrack.py show --family inet6</command> +              </node> +            </children> +          </node> +        </children> +      </node> +    </children> +  </node> +</interfaceDefinition> diff --git a/op-mode-definitions/show-hardware.xml.in b/op-mode-definitions/show-hardware.xml.in index 20fdd753d..ebd806ba5 100644 --- a/op-mode-definitions/show-hardware.xml.in +++ b/op-mode-definitions/show-hardware.xml.in @@ -9,21 +9,21 @@          <children>            <node name="cpu">              <properties> -              <help>Show CPU info</help> +              <help>Show CPU informaion</help>              </properties> -            <command>lscpu</command> +            <command>${vyos_op_scripts_dir}/cpu.py show</command>              <children>                <node name="detail">                  <properties> -                  <help> Show system CPU details</help> +                  <help>Show system CPU details</help>                  </properties>                  <command>cat /proc/cpuinfo</command>                </node>                <node name="summary">                  <properties> -                  <help>Show system CPUs</help> +                  <help>Show system CPUs summary</help>                  </properties> -                <command>${vyos_op_scripts_dir}/cpu_summary.py</command> +                <command>${vyos_op_scripts_dir}/cpu.py show_summary</command>                </node>              </children>            </node> diff --git a/op-mode-definitions/show-ip.xml.in b/op-mode-definitions/show-ip.xml.in index d342ac192..0751c50cb 100644 --- a/op-mode-definitions/show-ip.xml.in +++ b/op-mode-definitions/show-ip.xml.in @@ -11,7 +11,7 @@              <properties>                <help>Show IPv4 neighbor (ARP) table</help>              </properties> -            <command>${vyos_op_scripts_dir}/show_neigh.py --family inet</command> +            <command>${vyos_op_scripts_dir}/neighbor.py show --family inet</command>              <children>                <tagNode name="interface">                  <properties> @@ -20,7 +20,7 @@                      <script>${vyos_completion_dir}/list_interfaces.py -b</script>                    </completionHelp>                  </properties> -                <command>${vyos_op_scripts_dir}/show_neigh.py --family inet --interface "$5"</command> +                <command>${vyos_op_scripts_dir}/neighbor.py show --family inet --interface "$5"</command>                </tagNode>                <tagNode name="state">                  <properties> @@ -29,7 +29,7 @@                      <list>reachable stale failed permanent</list>                    </completionHelp>                  </properties> -                <command>${vyos_op_scripts_dir}/show_neigh.py --family inet --state "$5"</command> +                <command>${vyos_op_scripts_dir}/neighbor.py show --family inet --state "$5"</command>                </tagNode>              </children>            </node> diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index 76879e5d6..7769c5f52 100644 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -32,6 +32,19 @@              </properties>              <command>journalctl --no-hostname --boot --unit conntrackd.service</command>            </leafNode> +          <node name="ids"> +            <properties> +              <help>Show log for for Intrusion Detection System</help> +            </properties> +            <children> +              <leafNode name="ddos-protection"> +                <properties> +                  <help>Show log for DDOS protection</help> +                </properties> +                <command>journalctl --no-hostname --boot --unit fastnetmon.service</command> +              </leafNode> +            </children> +          </node>            <node name="dhcp">              <properties>                <help>Show log for Dynamic Host Control Protocol (DHCP)</help> @@ -243,7 +256,7 @@            </node>            <node name="protocol">              <properties> -              <help>Show log for Routing Protocols</help> +              <help>Show log for Routing Protocol</help>              </properties>              <children>                <leafNode name="ospf"> @@ -320,6 +333,12 @@              </properties>              <command>journalctl --no-hostname --boot --unit snmpd.service</command>            </leafNode> +          <leafNode name="ssh"> +            <properties> +              <help>Show log for Secure Shell (SSH)</help> +            </properties> +            <command>journalctl --no-hostname --boot --unit ssh.service</command> +          </leafNode>            <tagNode name="tail">              <properties>                <help>Show last n changes to messages</help> diff --git a/op-mode-definitions/show-system.xml.in b/op-mode-definitions/show-system.xml.in index 68b473bc1..6f05d0c12 100644 --- a/op-mode-definitions/show-system.xml.in +++ b/op-mode-definitions/show-system.xml.in @@ -104,7 +104,7 @@              <properties>                <help>Show system memory usage</help>              </properties> -            <command>${vyos_op_scripts_dir}/show_ram.py</command> +            <command>${vyos_op_scripts_dir}/memory.py show</command>              <children>                <leafNode name="cache">                  <properties> diff --git a/op-mode-definitions/show-version.xml.in b/op-mode-definitions/show-version.xml.in index 8b7cc7e58..d9c4738af 100644 --- a/op-mode-definitions/show-version.xml.in +++ b/op-mode-definitions/show-version.xml.in @@ -6,13 +6,13 @@          <properties>            <help>Show system version information</help>          </properties> -        <command>sudo ${vyos_op_scripts_dir}/show_version.py</command> +        <command>sudo ${vyos_op_scripts_dir}/version.py show</command>          <children>            <leafNode name="funny">              <properties>                <help>Show system version and some fun stuff</help>              </properties> -            <command>sudo ${vyos_op_scripts_dir}/show_version.py --funny</command> +            <command>sudo ${vyos_op_scripts_dir}/version.py show --funny</command>            </leafNode>            <leafNode name="all">               <properties> diff --git a/op-mode-definitions/show-vrf.xml.in b/op-mode-definitions/show-vrf.xml.in index 9c38c30fe..0e0370445 100644 --- a/op-mode-definitions/show-vrf.xml.in +++ b/op-mode-definitions/show-vrf.xml.in @@ -6,7 +6,7 @@          <properties>            <help>Show VRF information</help>          </properties> -        <command>${vyos_op_scripts_dir}/show_vrf.py -e</command> +        <command>${vyos_op_scripts_dir}/vrf.py show</command>        </node>        <tagNode name="vrf">          <properties> @@ -15,7 +15,7 @@              <path>vrf name</path>            </completionHelp>          </properties> -        <command>${vyos_op_scripts_dir}/show_vrf.py -e "$3"</command> +        <command>${vyos_op_scripts_dir}/vrf.py show --name="$3"</command>          <children>            <leafNode name="processes">              <properties> diff --git a/op-mode-definitions/vpn-ipsec.xml.in b/op-mode-definitions/vpn-ipsec.xml.in index 3d997c143..a98cf8ff2 100644 --- a/op-mode-definitions/vpn-ipsec.xml.in +++ b/op-mode-definitions/vpn-ipsec.xml.in @@ -19,16 +19,16 @@                  <properties>                    <help>Reset a specific tunnel for given peer</help>                  </properties> -                <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="$6"</command> +                <command>sudo ${vyos_op_scripts_dir}/ipsec.py reset_peer --peer="$4" --tunnel="$6"</command>                </tagNode>                <node name="vti">                  <properties>                    <help>Reset the VTI tunnel for given peer</help>                  </properties> -                <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="vti"</command> +                <command>sudo ${vyos_op_scripts_dir}/ipsec.py reset_peer --peer="$4" --tunnel="vti"</command>                </node>              </children> -            <command>sudo ${vyos_op_scripts_dir}/vpn_ipsec.py --action="reset-peer" --name="$4" --tunnel="all"</command> +            <command>sudo ${vyos_op_scripts_dir}/ipsec.py reset_peer --peer="$4" --tunnel="all"</command>            </tagNode>            <tagNode name="ipsec-profile">              <properties> @@ -76,6 +76,9 @@                <tagNode name="peer">                  <properties>                    <help>Show debugging information for a peer</help> +                  <completionHelp> +                    <path>vpn ipsec site-to-site peer</path> +                  </completionHelp>                  </properties>                  <children>                    <tagNode name="tunnel"> diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 78225f8d4..a61666afc 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -201,11 +201,12 @@ def is_member(conf, interface, intftype=None):      intftype is optional, if not passed it will search all known types      (currently bridge and bonding) -    Returns: -    None -> Interface is not a member -    interface name -> Interface is a member of this interface -    False -> interface type cannot have members +    Returns: dict +    empty -> Interface is not a member +    key -> Interface is a member of this interface      """ +    from vyos.ifconfig import Section +      ret_val = {}      intftypes = ['bonding', 'bridge'] @@ -221,9 +222,24 @@ def is_member(conf, interface, intftype=None):          for intf in conf.list_nodes(base):              member = base + [intf, 'member', 'interface', interface]              if conf.exists(member): -                tmp = conf.get_config_dict(member, key_mangling=('-', '_'), -                                           get_first_key=True, no_tag_node_value_mangle=True) -                ret_val.update({intf : tmp}) +                member_type = Section.section(interface) +                # Check if it's a VLAN (QinQ) interface +                interface = interface.split('.') +                if len(interface) == 3: +                    if conf.exists(['interfaces', member_type, interface[0], 'vif-s', interface[1], 'vif-c', interface[2]]): +                        tmp = conf.get_config_dict(['interfaces', member_type, interface[0]], +                                                   key_mangling=('-', '_'), get_first_key=True) +                        ret_val.update({intf : tmp}) +                elif len(interface) == 2: +                    if conf.exists(['interfaces', member_type, interface[0], 'vif', interface[1]]): +                        tmp = conf.get_config_dict(['interfaces', member_type, interface[0]], +                                                   key_mangling=('-', '_'), get_first_key=True) +                        ret_val.update({intf : tmp}) +                else: +                    if conf.exists(['interfaces', member_type, interface[0]]): +                        tmp = conf.get_config_dict(['interfaces', member_type, interface[0]], +                                                   key_mangling=('-', '_'), get_first_key=True) +                        ret_val.update({intf : tmp})      return ret_val diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 438485d98..137eb9f79 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -99,6 +99,18 @@ def verify_vrf(config):                  'Interface "{ifname}" cannot be both a member of VRF "{vrf}" '                  'and bridge "{is_bridge_member}"!'.format(**config)) +def verify_bond_bridge_member(config): +    """ +    Checks if interface has a VRF configured and is also part of a bond or +    bridge, which is not allowed! +    """ +    if 'vrf' in config: +        ifname = config['ifname'] +        if 'is_bond_member' in config: +            raise ConfigError(f'Can not add interface "{ifname}" to bond, it has a VRF assigned!') +        if 'is_bridge_member' in config: +            raise ConfigError(f'Can not add interface "{ifname}" to bridge, it has a VRF assigned!') +  def verify_tunnel(config):      """      This helper is used to verify the common part of the tunnel diff --git a/python/vyos/cpu.py b/python/vyos/cpu.py index a0ef864be..488ae79fb 100644 --- a/python/vyos/cpu.py +++ b/python/vyos/cpu.py @@ -32,7 +32,8 @@ import re  def _read_cpuinfo():      with open('/proc/cpuinfo', 'r') as f: -        return f.readlines() +        lines = f.read().strip() +        return re.split(r'\n+', lines)  def _split_line(l):      l = l.strip() diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index fcb6a7fbc..09ae73eac 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -18,6 +18,7 @@ import os  directories = {    "data": "/usr/share/vyos/",    "conf_mode": "/usr/libexec/vyos/conf_mode", +  "op_mode": "/usr/libexec/vyos/op_mode",    "config": "/opt/vyatta/etc/config",    "current": "/opt/vyatta/etc/config-migrate/current",    "migrate": "/opt/vyatta/etc/config-migrate/migrate", @@ -49,6 +50,7 @@ api_data = {      'socket' : False,      'strict' : False,      'gql' : False, +    'introspection' : False,      'debug' : False,      'api_keys' : [ {"id": "testapp", "key": "qwerty"} ]  } diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py index 2b9afe109..98bf6162b 100644 --- a/python/vyos/ifconfig/bond.py +++ b/python/vyos/ifconfig/bond.py @@ -179,6 +179,21 @@ class BondIf(Interface):          """          self.set_interface('bond_lacp_rate', slow_fast) +    def set_miimon_interval(self, interval): +        """ +        Specifies the MII link monitoring frequency in milliseconds. This +        determines how often the link state of each slave is inspected for link +        failures. A value of zero disables MII link monitoring. A value of 100 +        is a good starting point. + +        The default value is 0. + +        Example: +        >>> from vyos.ifconfig import BondIf +        >>> BondIf('bond0').set_miimon_interval('100') +        """ +        return self.set_interface('bond_miimon', interval) +      def set_arp_interval(self, interval):          """          Specifies the ARP link monitoring frequency in milliseconds. @@ -202,16 +217,7 @@ class BondIf(Interface):          >>> from vyos.ifconfig import BondIf          >>> BondIf('bond0').set_arp_interval('100')          """ -        if int(interval) == 0: -            """ -            Specifies the MII link monitoring frequency in milliseconds. -            This determines how often the link state of each slave is -            inspected for link failures. A value of zero disables MII -            link monitoring. A value of 100 is a good starting point. -            """ -            return self.set_interface('bond_miimon', interval) -        else: -            return self.set_interface('bond_arp_interval', interval) +        return self.set_interface('bond_arp_interval', interval)      def get_arp_ip_target(self):          """ @@ -381,26 +387,9 @@ class BondIf(Interface):          if 'shutdown_required' in config:              self.set_admin_state('down') -        # ARP monitor targets need to be synchronized between sysfs and CLI. -        # Unfortunately an address can't be send twice to sysfs as this will -        # result in the following exception:  OSError: [Errno 22] Invalid argument. -        # -        # We remove ALL addresses prior to adding new ones, this will remove -        # addresses manually added by the user too - but as we are limited to 16 adresses -        # from the kernel side this looks valid to me. We won't run into an error -        # when a user added manual adresses which would result in having more -        # then 16 adresses in total. -        arp_tgt_addr = list(map(str, self.get_arp_ip_target().split())) -        for addr in arp_tgt_addr: -            self.set_arp_ip_target('-' + addr) - -        # Add configured ARP target addresses -        value = dict_search('arp_monitor.target', config) -        if isinstance(value, str): -            value = [value] -        if value: -            for addr in value: -                self.set_arp_ip_target('+' + addr) +        # Specifies the MII link monitoring frequency in milliseconds +        value = config.get('mii_mon_interval') +        self.set_miimon_interval(value)          # Bonding transmit hash policy          value = config.get('hash_policy') @@ -430,6 +419,32 @@ class BondIf(Interface):              if mode == '802.3ad':                  self.set_lacp_rate(config.get('lacp_rate')) +            if mode not in ['802.3ad', 'balance-tlb', 'balance-alb']: +                tmp = dict_search('arp_monitor.interval', config) +                value = tmp if (tmp != None) else '0' +                self.set_arp_interval(value) + +                # ARP monitor targets need to be synchronized between sysfs and CLI. +                # Unfortunately an address can't be send twice to sysfs as this will +                # result in the following exception:  OSError: [Errno 22] Invalid argument. +                # +                # We remove ALL addresses prior to adding new ones, this will remove +                # addresses manually added by the user too - but as we are limited to 16 adresses +                # from the kernel side this looks valid to me. We won't run into an error +                # when a user added manual adresses which would result in having more +                # then 16 adresses in total. +                arp_tgt_addr = list(map(str, self.get_arp_ip_target().split())) +                for addr in arp_tgt_addr: +                    self.set_arp_ip_target('-' + addr) + +                # Add configured ARP target addresses +                value = dict_search('arp_monitor.target', config) +                if isinstance(value, str): +                    value = [value] +                if value: +                    for addr in value: +                        self.set_arp_ip_target('+' + addr) +              # Add (enslave) interfaces to bond              value = dict_search('member.interface', config)              for interface in (value or []): diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 22441d1d2..555494f80 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -168,6 +168,10 @@ class Interface(Control):              'validate': assert_boolean,              'location': '/proc/sys/net/ipv4/conf/{ifname}/forwarding',          }, +        'ipv4_directed_broadcast': { +            'validate': assert_boolean, +            'location': '/proc/sys/net/ipv4/conf/{ifname}/bc_forwarding', +        },          'rp_filter': {              'validate': lambda flt: assert_range(flt,0,3),              'location': '/proc/sys/net/ipv4/conf/{ifname}/rp_filter', @@ -234,6 +238,9 @@ class Interface(Control):          'ipv4_forwarding': {              'location': '/proc/sys/net/ipv4/conf/{ifname}/forwarding',          }, +        'ipv4_directed_broadcast': { +            'location': '/proc/sys/net/ipv4/conf/{ifname}/bc_forwarding', +        },          'rp_filter': {              'location': '/proc/sys/net/ipv4/conf/{ifname}/rp_filter',          }, @@ -713,6 +720,13 @@ class Interface(Control):              return None          return self.set_interface('ipv4_forwarding', forwarding) +    def set_ipv4_directed_broadcast(self, forwarding): +        """ Configure IPv4 directed broadcast forwarding. """ +        tmp = self.get_interface('ipv4_directed_broadcast') +        if tmp == forwarding: +            return None +        return self.set_interface('ipv4_directed_broadcast', forwarding) +      def set_ipv4_source_validation(self, value):          """          Help prevent attacks used by Spoofing IP Addresses. Reverse path @@ -1305,8 +1319,9 @@ class Interface(Control):          # clear existing ingess - ignore errors (e.g. "Error: Cannot find specified          # qdisc on specified device") - we simply cleanup all stuff here -        self._popen(f'tc qdisc del dev {source_if} parent ffff: 2>/dev/null'); -        self._popen(f'tc qdisc del dev {source_if} parent 1: 2>/dev/null'); +        if not 'traffic_policy' in self._config: +            self._popen(f'tc qdisc del dev {source_if} parent ffff: 2>/dev/null'); +            self._popen(f'tc qdisc del dev {source_if} parent 1: 2>/dev/null');          # Apply interface mirror policy          if mirror_config: @@ -1439,14 +1454,22 @@ class Interface(Control):          if dhcpv6pd:              self.set_dhcpv6(True) -        # There are some items in the configuration which can only be applied -        # if this instance is not bound to a bridge. This should be checked -        # by the caller but better save then sorry! -        if not any(k in ['is_bond_member', 'is_bridge_member'] for k in config): -            # Bind interface to given VRF or unbind it if vrf node is not set. -            # unbinding will call 'ip link set dev eth0 nomaster' which will -            # also drop the interface out of a bridge or bond - thus this is -            # checked before +        # XXX: Bind interface to given VRF or unbind it if vrf is not set. Unbinding +        # will call 'ip link set dev eth0 nomaster' which will also drop the +        # interface out of any bridge or bond - thus this is checked before. +        if 'is_bond_member' in config: +            bond_if = next(iter(config['is_bond_member'])) +            tmp = get_interface_config(config['ifname']) +            if 'master' in tmp and tmp['master'] != bond_if: +                self.set_vrf('') + +        elif 'is_bridge_member' in config: +            bridge_if = next(iter(config['is_bridge_member'])) +            tmp = get_interface_config(config['ifname']) +            if 'master' in tmp and tmp['master'] != bridge_if: +                self.set_vrf('') + +        else:              self.set_vrf(config.get('vrf', ''))          # Add this section after vrf T4331 @@ -1498,6 +1521,11 @@ class Interface(Control):          value = '0' if (tmp != None) else '1'          self.set_ipv4_forwarding(value) +        # IPv4 directed broadcast forwarding +        tmp = dict_search('ip.enable_directed_broadcast', config) +        value = '1' if (tmp != None) else '0' +        self.set_ipv4_directed_broadcast(value) +          # IPv4 source-validation          tmp = dict_search('ip.source_validation', config)          value = tmp if (tmp != None) else '0' @@ -1555,8 +1583,8 @@ class Interface(Control):          # re-add ourselves to any bridge we might have fallen out of          if 'is_bridge_member' in config: -            bridge_dict = config.get('is_bridge_member') -            self.add_to_bridge(bridge_dict) +            tmp = config.get('is_bridge_member') +            self.add_to_bridge(tmp)          # eXpress Data Path - highly experimental          self.set_xdp('xdp' in config) diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py new file mode 100644 index 000000000..0af4359c6 --- /dev/null +++ b/python/vyos/opmode.py @@ -0,0 +1,128 @@ +# Copyright 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 re +import sys +import typing + + +def _is_op_mode_function_name(name): +    if re.match(r"^(show|clear|reset|restart)", name): +        return True +    else: +        return False + +def _is_show(name): +    if re.match(r"^show", name): +        return True +    else: +        return False + +def _get_op_mode_functions(module): +    from inspect import getmembers, isfunction + +    # Get all functions in that module +    funcs = getmembers(module, isfunction) + +    # getmembers returns (name, func) tuples +    funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs)) + +    funcs_dict = {} +    for (name, thunk) in funcs: +        funcs_dict[name] = thunk + +    return funcs_dict + +def _is_optional_type(t): +    # Optional[t] is internally an alias for Union[t, NoneType] +    # and there's no easy way to get union members it seems +    if (type(t) == typing._UnionGenericAlias): +        if (len(t.__args__) == 2): +            if t.__args__[1] == type(None): +                return True + +    return False + +def _get_arg_type(t): +    """ Returns the type itself if it's a primitive type, +        or the "real" type of typing.Optional + +       Doesn't work with anything else at the moment! +    """ +    if _is_optional_type(t): +        return t.__args__[0] +    else: +        return t + +def run(module): +    from argparse import ArgumentParser + +    functions = _get_op_mode_functions(module) + +    parser = ArgumentParser() +    subparsers = parser.add_subparsers(dest="subcommand") + +    for function_name in functions: +        subparser = subparsers.add_parser(function_name, help=functions[function_name].__doc__) + +        type_hints = typing.get_type_hints(functions[function_name]) +        for opt in type_hints: +            th = type_hints[opt] + +            if _get_arg_type(th) == bool: +                subparser.add_argument(f"--{opt}", action='store_true') +            else: +                if _is_optional_type(th): +                    subparser.add_argument(f"--{opt}", type=_get_arg_type(th), default=None) +                else: +                    subparser.add_argument(f"--{opt}", type=_get_arg_type(th), required=True) + +    # Get options as a dict rather than a namespace, +    # so that we can modify it and pack for passing to functions +    args = vars(parser.parse_args()) + +    if not args["subcommand"]: +        print("Subcommand required!") +        parser.print_usage() +        sys.exit(1) + +    function_name = args["subcommand"] +    func = functions[function_name] + +    # Remove the subcommand from the arguments, +    # it would cause an extra argument error when we pass the dict to a function +    del args["subcommand"] + +    # Show commands must always get the "raw" argument, +    # but other commands (clear/reset/restart) should not, +    # because they produce no output and it makes no sense for them. +    if ("raw" not in args) and _is_show(function_name): +        args["raw"] = False + +    if re.match(r"^show", function_name): +        # Show commands are slightly special: +        # they may return human-formatted output +        # or a raw dict that we need to serialize in JSON for printing +        res = func(**args) +        if not args["raw"]: +            return res +        else: +            from json import dumps +            return dumps(res, indent=4) +    else: +        # Other functions should not return anything, +        # although they may print their own warnings or status messages +        func(**args) + diff --git a/python/vyos/util.py b/python/vyos/util.py index bee5d7aec..b86b1949c 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -164,6 +164,27 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None,      return decoded +def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None, +           stdout=PIPE, stderr=STDOUT, decode='utf-8'): +    """ +    A wrapper around popen, which returns the return code +    of a command and stdout + +    % rc_cmd('uname') +    (0, 'Linux') +    % rc_cmd('ip link show dev eth99') +    (1, 'Device "eth99" does not exist.') +    """ +    out, code = popen( +        command, flag, +        stdout=stdout, stderr=stderr, +        input=input, timeout=timeout, +        env=env, shell=shell, +        decode=decode, +    ) +    return code, out + +  def call(command, flag='', shell=None, input=None, timeout=None, env=None,           stdout=PIPE, stderr=PIPE, decode='utf-8'):      """ diff --git a/python/vyos/validate.py b/python/vyos/validate.py index e005da0e4..a83193363 100644 --- a/python/vyos/validate.py +++ b/python/vyos/validate.py @@ -264,3 +264,22 @@ def has_address_configured(conf, intf):      conf.set_level(old_level)      return ret + +def has_vrf_configured(conf, intf): +    """ +    Checks if interface has a VRF configured. + +    Returns True if interface has VRF configured, False if it doesn't. +    """ +    from vyos.ifconfig import Section +    ret = False + +    old_level = conf.get_level() +    conf.set_level([]) + +    tmp = ['interfaces', Section.get_config_path(intf), 'vrf'] +    if conf.exists(tmp): +        ret = True + +    conf.set_level(old_level) +    return ret diff --git a/smoketest/scripts/cli/base_interfaces_test.py b/smoketest/scripts/cli/base_interfaces_test.py index 8acf52243..55343b893 100644 --- a/smoketest/scripts/cli/base_interfaces_test.py +++ b/smoketest/scripts/cli/base_interfaces_test.py @@ -638,6 +638,7 @@ class BasicInterfaceTest:                  self.cli_set(path + ['ip', 'arp-cache-timeout', arp_tmo])                  self.cli_set(path + ['ip', 'disable-arp-filter'])                  self.cli_set(path + ['ip', 'disable-forwarding']) +                self.cli_set(path + ['ip', 'enable-directed-broadcast'])                  self.cli_set(path + ['ip', 'enable-arp-accept'])                  self.cli_set(path + ['ip', 'enable-arp-announce'])                  self.cli_set(path + ['ip', 'enable-arp-ignore']) @@ -674,6 +675,9 @@ class BasicInterfaceTest:                  tmp = read_file(f'{proc_base}/forwarding')                  self.assertEqual('0', tmp) +                tmp = read_file(f'{proc_base}/bc_forwarding') +                self.assertEqual('1', tmp) +                  tmp = read_file(f'{proc_base}/proxy_arp')                  self.assertEqual('1', tmp) diff --git a/smoketest/scripts/cli/test_component_version.py b/smoketest/scripts/cli/test_component_version.py index 777379bdd..1355c1f94 100755 --- a/smoketest/scripts/cli/test_component_version.py +++ b/smoketest/scripts/cli/test_component_version.py @@ -26,11 +26,25 @@ class TestComponentVersion(unittest.TestCase):      def setUp(self):          self.legacy_d = get_system_versions()          self.xml_d = get_system_component_version() +        self.set_legacy_d = set(self.legacy_d) +        self.set_xml_d = set(self.xml_d)      def test_component_version(self): -        self.assertTrue(set(self.legacy_d).issubset(set(self.xml_d))) +        bool_issubset = (self.set_legacy_d.issubset(self.set_xml_d)) +        if not bool_issubset: +            missing = self.set_legacy_d.difference(self.set_xml_d) +            print(f'\n\ncomponents in legacy but not in XML: {missing}') +            print('new components must be listed in xml-component-version.xml.in') +        self.assertTrue(bool_issubset) + +        bad_component_version = False          for k, v in self.legacy_d.items(): -            self.assertTrue(v <= self.xml_d[k]) +            bool_inequality = (v <= self.xml_d[k]) +            if not bool_inequality: +                print(f'\n\n{k} has not been updated in XML component versions:') +                print(f'legacy version {v}; XML version {self.xml_d[k]}') +                bad_component_version = True +        self.assertFalse(bad_component_version)  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_interfaces_bonding.py b/smoketest/scripts/cli/test_interfaces_bonding.py index 237abb487..cd3995ed9 100755 --- a/smoketest/scripts/cli/test_interfaces_bonding.py +++ b/smoketest/scripts/cli/test_interfaces_bonding.py @@ -49,7 +49,7 @@ class BondingInterfaceTest(BasicInterfaceTest.TestCase):                  if not '.' in tmp:                      cls._members.append(tmp) -        cls._options['bond0'] = [] +        cls._options = {'bond0' : []}          for member in cls._members:              cls._options['bond0'].append(f'member interface {member}')          cls._interfaces = list(cls._options) @@ -136,7 +136,7 @@ class BondingInterfaceTest(BasicInterfaceTest.TestCase):      def test_bonding_hash_policy(self):          # Define available bonding hash policies -        hash_policies = ['layer2', 'layer2+3', 'layer2+3', 'encap2+3', 'encap3+4'] +        hash_policies = ['layer2', 'layer2+3', 'encap2+3', 'encap3+4']          for hash_policy in hash_policies:              for interface in self._interfaces:                  for option in self._options.get(interface, []): @@ -151,6 +151,29 @@ class BondingInterfaceTest(BasicInterfaceTest.TestCase):                  defined_policy = read_file(f'/sys/class/net/{interface}/bonding/xmit_hash_policy').split()                  self.assertEqual(defined_policy[0], hash_policy) +    def test_bonding_mii_monitoring_interval(self): +        for interface in self._interfaces: +            for option in self._options.get(interface, []): +                self.cli_set(self._base_path + [interface] + option.split()) + +        self.cli_commit() + +        # verify default +        for interface in self._interfaces: +            tmp = read_file(f'/sys/class/net/{interface}/bonding/miimon').split() +            self.assertIn('100', tmp) + +        mii_mon = '250' +        for interface in self._interfaces: +            self.cli_set(self._base_path + [interface, 'mii-mon-interval', mii_mon]) + +        self.cli_commit() + +        # verify new CLI value +        for interface in self._interfaces: +            tmp = read_file(f'/sys/class/net/{interface}/bonding/miimon').split() +            self.assertIn(mii_mon, tmp) +      def test_bonding_multi_use_member(self):          # Define available bonding hash policies          for interface in ['bond10', 'bond20']: @@ -165,6 +188,46 @@ class BondingInterfaceTest(BasicInterfaceTest.TestCase):          self.cli_commit() +    def test_bonding_source_interface(self): +        # Re-use member interface that is already a source-interface +        bond = 'bond99' +        pppoe = 'pppoe98756' +        member = next(iter(self._members)) + +        self.cli_set(self._base_path + [bond, 'member', 'interface', member]) +        self.cli_set(['interfaces', 'pppoe', pppoe, 'source-interface', member]) + +        # check validate() - can not add interface to bond, it is the source-interface of ... +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() + +        self.cli_delete(['interfaces', 'pppoe', pppoe]) +        self.cli_commit() + +        # verify config +        slaves = read_file(f'/sys/class/net/{bond}/bonding/slaves').split() +        self.assertIn(member, slaves) + +    def test_bonding_source_bridge_interface(self): +        # Re-use member interface that is already a source-interface +        bond = 'bond1097' +        bridge = 'br6327' +        member = next(iter(self._members)) + +        self.cli_set(self._base_path + [bond, 'member', 'interface', member]) +        self.cli_set(['interfaces', 'bridge', bridge, 'member', 'interface', member]) + +        # check validate() - can not add interface to bond, it is a member of bridge ... +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() + +        self.cli_delete(['interfaces', 'bridge', bridge]) +        self.cli_commit() + +        # verify config +        slaves = read_file(f'/sys/class/net/{bond}/bonding/slaves').split() +        self.assertIn(member, slaves) +      def test_bonding_uniq_member_description(self):          ethernet_path = ['interfaces', 'ethernet']          for interface in self._interfaces: diff --git a/smoketest/scripts/cli/test_interfaces_bridge.py b/smoketest/scripts/cli/test_interfaces_bridge.py index 26d3a23c9..664dc48bc 100755 --- a/smoketest/scripts/cli/test_interfaces_bridge.py +++ b/smoketest/scripts/cli/test_interfaces_bridge.py @@ -304,7 +304,7 @@ class BridgeInterfaceTest(BasicInterfaceTest.TestCase):              self.cli_delete(self._base_path + [interface, 'member']) -    def test_bridge_vlan_members(self): +    def test_bridge_vif_members(self):          # T2945: ensure that VIFs are not dropped from bridge          vifs = ['300', '400']          for interface in self._interfaces: @@ -329,5 +329,34 @@ class BridgeInterfaceTest(BasicInterfaceTest.TestCase):                      self.cli_delete(['interfaces', 'ethernet', member, 'vif', vif])                      self.cli_delete(['interfaces', 'bridge', interface, 'member', 'interface', f'{member}.{vif}']) +    def test_bridge_vif_s_vif_c_members(self): +        # T2945: ensure that VIFs are not dropped from bridge +        vifs = ['300', '400'] +        vifc = ['301', '401'] +        for interface in self._interfaces: +            for member in self._members: +                for vif_s in vifs: +                    for vif_c in vifc: +                        self.cli_set(['interfaces', 'ethernet', member, 'vif-s', vif_s, 'vif-c', vif_c]) +                        self.cli_set(['interfaces', 'bridge', interface, 'member', 'interface', f'{member}.{vif_s}.{vif_c}']) + +        self.cli_commit() + +        # Verify config +        for interface in self._interfaces: +            for member in self._members: +                for vif_s in vifs: +                    for vif_c in vifc: +                        # member interface must be assigned to the bridge +                        self.assertTrue(os.path.exists(f'/sys/class/net/{interface}/lower_{member}.{vif_s}.{vif_c}')) + +        # delete all members +        for interface in self._interfaces: +            for member in self._members: +                for vif_s in vifs: +                    self.cli_delete(['interfaces', 'ethernet', member, 'vif-s', vif_s]) +                    for vif_c in vifc: +                        self.cli_delete(['interfaces', 'bridge', interface, 'member', 'interface', f'{member}.{vif_s}.{vif_c}']) +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_protocols_bgp.py b/smoketest/scripts/cli/test_protocols_bgp.py index 9c0c93779..009dbc803 100755 --- a/smoketest/scripts/cli/test_protocols_bgp.py +++ b/smoketest/scripts/cli/test_protocols_bgp.py @@ -921,5 +921,31 @@ class TestProtocolsBGP(VyOSUnitTestSHIM.TestCase):          self.assertIn(f' neighbor {peer_group} peer-group', frrconfig)          self.assertIn(f' neighbor {peer_group} remote-as {remote_asn}', frrconfig) +    def test_bgp_15_local_as_ebgp(self): +        # https://phabricator.vyos.net/T4560 +        # local-as allowed only for ebgp peers + +        neighbor = '192.0.2.99' +        remote_asn = '500' +        local_asn = '400' + +        self.cli_set(base_path + ['local-as', ASN]) +        self.cli_set(base_path + ['neighbor', neighbor, 'remote-as', ASN]) +        self.cli_set(base_path + ['neighbor', neighbor, 'local-as', local_asn]) + +        # check validate() - local-as allowed only for ebgp peers +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() + +        self.cli_set(base_path + ['neighbor', neighbor, 'remote-as', remote_asn]) + +        self.cli_commit() + +        frrconfig = self.getFRRconfig(f'router bgp {ASN}') +        self.assertIn(f'router bgp {ASN}', frrconfig) +        self.assertIn(f' neighbor {neighbor} remote-as {remote_asn}', frrconfig) +        self.assertIn(f' neighbor {neighbor} local-as {local_asn}', frrconfig) + +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_https.py b/smoketest/scripts/cli/test_service_https.py index 71fb3e177..72c1d4e43 100755 --- a/smoketest/scripts/cli/test_service_https.py +++ b/smoketest/scripts/cli/test_service_https.py @@ -138,5 +138,62 @@ class TestHTTPSService(VyOSUnitTestSHIM.TestCase):          # Must get HTTP code 401 on missing key (Unauthorized)          self.assertEqual(r.status_code, 401) +        # GraphQL auth test: a missing key will return status code 400, as +        # 'key' is a non-nullable field in the schema; an incorrect key is +        # caught by the resolver, and returns success 'False', so one must +        # check the return value. + +        self.cli_set(base_path + ['api', 'gql']) +        self.cli_commit() + +        gql_url = f'https://{address}/graphql' + +        query_valid_key = f""" +        {{ +          SystemStatus (data: {{key: "{key}"}}) {{ +            success +            errors +            data {{ +              result +            }} +          }} +        }} +        """ + +        r = request('POST', gql_url, verify=False, headers=headers, json={'query': query_valid_key}) +        success = r.json()['data']['SystemStatus']['success'] +        self.assertTrue(success) + +        query_invalid_key = """ +        { +          SystemStatus (data: {key: "invalid"}) { +            success +            errors +            data { +              result +            } +          } +        } +        """ + +        r = request('POST', gql_url, verify=False, headers=headers, json={'query': query_invalid_key}) +        success = r.json()['data']['SystemStatus']['success'] +        self.assertFalse(success) + +        query_no_key = """ +        { +          SystemStatus (data: {}) { +            success +            errors +            data { +              result +            } +          } +        } +        """ + +        r = request('POST', gql_url, verify=False, headers=headers, json={'query': query_no_key}) +        self.assertEqual(r.status_code, 400) +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_ids.py b/smoketest/scripts/cli/test_service_ids.py index 18f1b8ec5..d471eeaed 100755 --- a/smoketest/scripts/cli/test_service_ids.py +++ b/smoketest/scripts/cli/test_service_ids.py @@ -24,7 +24,9 @@ from vyos.util import process_named_running  from vyos.util import read_file  PROCESS_NAME = 'fastnetmon' -FASTNETMON_CONF = '/etc/fastnetmon.conf' +FASTNETMON_CONF = '/run/fastnetmon/fastnetmon.conf' +NETWORKS_CONF = '/run/fastnetmon/networks_list' +EXCLUDED_NETWORKS_CONF = '/run/fastnetmon/excluded_networks_list'  base_path = ['service', 'ids', 'ddos-protection']  class TestServiceIDS(VyOSUnitTestSHIM.TestCase): @@ -48,7 +50,8 @@ class TestServiceIDS(VyOSUnitTestSHIM.TestCase):          self.assertFalse(process_named_running(PROCESS_NAME))      def test_fastnetmon(self): -        networks = ['10.0.0.0/24', '10.5.5.0/24'] +        networks = ['10.0.0.0/24', '10.5.5.0/24', '2001:db8:10::/64', '2001:db8:20::/64'] +        excluded_networks = ['10.0.0.1/32', '2001:db8:10::1/128']          interfaces = ['eth0', 'eth1']          fps = '3500'          mbps = '300' @@ -61,6 +64,12 @@ class TestServiceIDS(VyOSUnitTestSHIM.TestCase):          for tmp in networks:              self.cli_set(base_path + ['network', tmp]) +        # optional excluded-network! +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() +        for tmp in excluded_networks: +            self.cli_set(base_path + ['excluded-network', tmp]) +          # Required interface(s)!          with self.assertRaises(ConfigSessionError):              self.cli_commit() @@ -86,9 +95,22 @@ class TestServiceIDS(VyOSUnitTestSHIM.TestCase):          self.assertIn(f'threshold_mbps = {mbps}', config)          self.assertIn(f'ban_for_pps = on', config)          self.assertIn(f'threshold_pps = {pps}', config) +        # default +        self.assertIn(f'enable_ban = on', config) +        self.assertIn(f'enable_ban_ipv6 = on', config) +        self.assertIn(f'ban_time = 1900', config)          tmp = ','.join(interfaces)          self.assertIn(f'interfaces = {tmp}', config) + +        network_config = read_file(NETWORKS_CONF) +        for tmp in networks: +            self.assertIn(f'{tmp}', network_config) + +        excluded_network_config = read_file(EXCLUDED_NETWORKS_CONF) +        for tmp in excluded_networks: +            self.assertIn(f'{tmp}', excluded_network_config) +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_monitoring_telegraf.py b/smoketest/scripts/cli/test_service_monitoring_telegraf.py index f9d875e83..1c8cc9759 100755 --- a/smoketest/scripts/cli/test_service_monitoring_telegraf.py +++ b/smoketest/scripts/cli/test_service_monitoring_telegraf.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# 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 @@ -35,9 +35,15 @@ inputs = ['cpu', 'disk', 'mem', 'net', 'system', 'kernel', 'interrupts', 'syslog  class TestMonitoringTelegraf(VyOSUnitTestSHIM.TestCase):      def tearDown(self): +        # Check for running process +        self.assertTrue(process_named_running(PROCESS_NAME)) +          self.cli_delete(base_path)          self.cli_commit() +        # Check for not longer running process +        self.assertFalse(process_named_running(PROCESS_NAME)) +      def test_01_basic_config(self):          self.cli_set(base_path + ['influxdb', 'authentication', 'organization', org])          self.cli_set(base_path + ['influxdb', 'authentication', 'token', token]) @@ -47,9 +53,6 @@ class TestMonitoringTelegraf(VyOSUnitTestSHIM.TestCase):          # commit changes          self.cli_commit() -        # Check for running process -        self.assertTrue(process_named_running(PROCESS_NAME)) -          config = read_file(TELEGRAF_CONF)          # Check telegraf config diff --git a/smoketest/scripts/cli/test_service_router-advert.py b/smoketest/scripts/cli/test_service_router-advert.py index 1168c05cd..873be7df0 100755 --- a/smoketest/scripts/cli/test_service_router-advert.py +++ b/smoketest/scripts/cli/test_service_router-advert.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# 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 @@ -23,11 +23,13 @@ from base_vyostest_shim import VyOSUnitTestSHIM  from vyos.util import read_file  from vyos.util import process_named_running +PROCESS_NAME = 'radvd'  RADVD_CONF = '/run/radvd/radvd.conf'  interface = 'eth1'  base_path = ['service', 'router-advert', 'interface', interface]  address_base = ['interfaces', 'ethernet', interface, 'address'] +prefix = '::/64'  def get_config_value(key):      tmp = read_file(RADVD_CONF) @@ -35,18 +37,36 @@ def get_config_value(key):      return tmp[0].split()[0].replace(';','')  class TestServiceRADVD(VyOSUnitTestSHIM.TestCase): -    def setUp(self): -        self.cli_set(address_base + ['2001:db8::1/64']) + +    @classmethod +    def setUpClass(cls): +        super(TestServiceRADVD, cls).setUpClass() + +        # ensure we can also run this test on a live system - so lets clean +        # out the current configuration :) +        cls.cli_delete(cls, ['service', 'router-advert']) + +        cls.cli_set(cls, address_base + ['2001:db8::1/64']) + +    @classmethod +    def tearDownClass(cls): +        cls.cli_delete(cls, address_base) +        super(TestServiceRADVD, cls).tearDownClass()      def tearDown(self): -        self.cli_delete(address_base) +        # Check for running process +        self.assertTrue(process_named_running(PROCESS_NAME)) +          self.cli_delete(base_path)          self.cli_commit() +        # Check for no longer running process +        self.assertFalse(process_named_running(PROCESS_NAME)) +      def test_common(self): -        self.cli_set(base_path + ['prefix', '::/64', 'no-on-link-flag']) -        self.cli_set(base_path + ['prefix', '::/64', 'no-autonomous-flag']) -        self.cli_set(base_path + ['prefix', '::/64', 'valid-lifetime', 'infinity']) +        self.cli_set(base_path + ['prefix', prefix, 'no-on-link-flag']) +        self.cli_set(base_path + ['prefix', prefix, 'no-autonomous-flag']) +        self.cli_set(base_path + ['prefix', prefix, 'valid-lifetime', 'infinity'])          self.cli_set(base_path + ['other-config-flag'])          # commit changes @@ -57,7 +77,7 @@ class TestServiceRADVD(VyOSUnitTestSHIM.TestCase):          self.assertEqual(tmp, interface)          tmp = get_config_value('prefix') -        self.assertEqual(tmp, '::/64') +        self.assertEqual(tmp, prefix)          tmp = get_config_value('AdvOtherConfigFlag')          self.assertEqual(tmp, 'on') @@ -88,15 +108,19 @@ class TestServiceRADVD(VyOSUnitTestSHIM.TestCase):          tmp = get_config_value('AdvOnLink')          self.assertEqual(tmp, 'off') -        # Check for running process -        self.assertTrue(process_named_running('radvd')) +        tmp = get_config_value('DeprecatePrefix') +        self.assertEqual(tmp, 'off') + +        tmp = get_config_value('DecrementLifetimes') +        self.assertEqual(tmp, 'off') +      def test_dns(self):          nameserver = ['2001:db8::1', '2001:db8::2']          dnssl = ['vyos.net', 'vyos.io']          ns_lifetime = '599' -        self.cli_set(base_path + ['prefix', '::/64', 'valid-lifetime', 'infinity']) +        self.cli_set(base_path + ['prefix', prefix, 'valid-lifetime', 'infinity'])          self.cli_set(base_path + ['other-config-flag'])          for ns in nameserver: @@ -127,5 +151,21 @@ class TestServiceRADVD(VyOSUnitTestSHIM.TestCase):          self.assertIn(tmp, config) +    def test_deprecate_prefix(self): +        self.cli_set(base_path + ['prefix', prefix, 'valid-lifetime', 'infinity']) +        self.cli_set(base_path + ['prefix', prefix, 'deprecate-prefix']) +        self.cli_set(base_path + ['prefix', prefix, 'decrement-lifetime']) + +        # commit changes +        self.cli_commit() + +        config = read_file(RADVD_CONF) + +        tmp = get_config_value('DeprecatePrefix') +        self.assertEqual(tmp, 'on') + +        tmp = get_config_value('DecrementLifetimes') +        self.assertEqual(tmp, 'on') +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_system_flow-accounting.py b/smoketest/scripts/cli/test_system_flow-accounting.py index a6eef3fb6..df60b9613 100755 --- a/smoketest/scripts/cli/test_system_flow-accounting.py +++ b/smoketest/scripts/cli/test_system_flow-accounting.py @@ -144,14 +144,15 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase):          self.assertNotIn(f'plugins: memory', uacctd)          for server, server_config in sflow_server.items(): +            plugin_name = server.replace('.', '-')              if 'port' in server_config: -                self.assertIn(f'sfprobe_receiver[sf_{server}]: {server}', uacctd) +                self.assertIn(f'sfprobe_receiver[sf_{plugin_name}]: {server}', uacctd)              else: -                self.assertIn(f'sfprobe_receiver[sf_{server}]: {server}:6343', uacctd) +                self.assertIn(f'sfprobe_receiver[sf_{plugin_name}]: {server}:6343', uacctd) -            self.assertIn(f'sfprobe_agentip[sf_{server}]: {agent_address}', uacctd) -            self.assertIn(f'sampling_rate[sf_{server}]: {sampling_rate}', uacctd) -            self.assertIn(f'sfprobe_source_ip[sf_{server}]: {source_address}', uacctd) +            self.assertIn(f'sfprobe_agentip[sf_{plugin_name}]: {agent_address}', uacctd) +            self.assertIn(f'sampling_rate[sf_{plugin_name}]: {sampling_rate}', uacctd) +            self.assertIn(f'sfprobe_source_ip[sf_{plugin_name}]: {source_address}', uacctd)          self.cli_delete(['interfaces', 'dummy', dummy_if]) @@ -194,8 +195,7 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase):          for server, server_config in sflow_server.items():              tmp_srv = server -            if is_ipv6(tmp_srv): -                tmp_srv = tmp_srv.replace(':', '.') +            tmp_srv = tmp_srv.replace(':', '-')              if 'port' in server_config:                  self.assertIn(f'sfprobe_receiver[sf_{tmp_srv}]: {bracketize_ipv6(server)}', uacctd) @@ -265,16 +265,16 @@ class TestSystemFlowAccounting(VyOSUnitTestSHIM.TestCase):          tmp = []          for server, server_config in netflow_server.items():              tmp_srv = server -            if is_ipv6(tmp_srv): -                tmp_srv = tmp_srv.replace(':', '.') +            tmp_srv = tmp_srv.replace('.', '-') +            tmp_srv = tmp_srv.replace(':', '-')              tmp.append(f'nfprobe[nf_{tmp_srv}]')          tmp.append('memory')          self.assertIn('plugins: ' + ','.join(tmp), uacctd)          for server, server_config in netflow_server.items():              tmp_srv = server -            if is_ipv6(tmp_srv): -                tmp_srv = tmp_srv.replace(':', '.') +            tmp_srv = tmp_srv.replace('.', '-') +            tmp_srv = tmp_srv.replace(':', '-')              self.assertIn(f'nfprobe_engine[nf_{tmp_srv}]: {engine_id}', uacctd)              self.assertIn(f'nfprobe_maxflows[nf_{tmp_srv}]: {max_flows}', uacctd) diff --git a/smoketest/scripts/cli/test_system_ip.py b/smoketest/scripts/cli/test_system_ip.py index 83df9d99e..f71ef5b3f 100755 --- a/smoketest/scripts/cli/test_system_ip.py +++ b/smoketest/scripts/cli/test_system_ip.py @@ -28,7 +28,7 @@ class TestSystemIP(VyOSUnitTestSHIM.TestCase):      def test_system_ip_forwarding(self):          # Test if IPv4 forwarding can be disabled globally, default is '1' -        # which means forwearding enabled +        # which means forwarding enabled          all_forwarding = '/proc/sys/net/ipv4/conf/all/forwarding'          self.assertEqual(read_file(all_forwarding), '1') @@ -37,6 +37,17 @@ class TestSystemIP(VyOSUnitTestSHIM.TestCase):          self.assertEqual(read_file(all_forwarding), '0') +    def test_system_ip_directed_broadcast_forwarding(self): +        # Test if IPv4 directed broadcast forwarding can be disabled globally, +        # default is '1' which means forwarding enabled +        bc_forwarding = '/proc/sys/net/ipv4/conf/all/bc_forwarding' +        self.assertEqual(read_file(bc_forwarding), '1') + +        self.cli_set(base_path + ['disable-directed-broadcast']) +        self.cli_commit() + +        self.assertEqual(read_file(bc_forwarding), '0') +      def test_system_ip_multipath(self):          # Test IPv4 multipathing options, options default to off -> '0'          use_neigh = '/proc/sys/net/ipv4/fib_multipath_use_neigh' diff --git a/src/conf_mode/flow_accounting_conf.py b/src/conf_mode/flow_accounting_conf.py index 7750c1247..7e16235c1 100755 --- a/src/conf_mode/flow_accounting_conf.py +++ b/src/conf_mode/flow_accounting_conf.py @@ -192,6 +192,11 @@ def verify(flow_config):                      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 +        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']: @@ -203,12 +208,12 @@ def verify(flow_config):          if 'agent_address' in flow_config['sflow']:              tmp = flow_config['sflow']['agent_address'] -            if not is_addr_assigned(tmp): +            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 netflow source-address exist in the system          if 'source_address' in flow_config['sflow']: -            if not is_addr_assigned(flow_config['sflow']['source_address']): +            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!') diff --git a/src/conf_mode/http-api.py b/src/conf_mode/http-api.py index 4a7906c17..04113fc09 100755 --- a/src/conf_mode/http-api.py +++ b/src/conf_mode/http-api.py @@ -66,14 +66,10 @@ def get_config(config=None):      if conf.exists('debug'):          http_api['debug'] = True -    # this node is not available by CLI by default, and is reserved for -    # the graphql tools. One can enable it for testing, with the warning -    # that this will open an unauthenticated server. To do so -    # mkdir /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql -    # touch /opt/vyatta/share/vyatta-cfg/templates/service/https/api/gql/node.def -    # and configure; editing the config alone is insufficient.      if conf.exists('gql'):          http_api['gql'] = True +        if conf.exists('gql introspection'): +            http_api['introspection'] = True      if conf.exists('socket'):          http_api['socket'] = True diff --git a/src/conf_mode/interfaces-bonding.py b/src/conf_mode/interfaces-bonding.py index 4167594e3..7e146f446 100755 --- a/src/conf_mode/interfaces-bonding.py +++ b/src/conf_mode/interfaces-bonding.py @@ -36,6 +36,7 @@ from vyos.ifconfig import BondIf  from vyos.ifconfig import Section  from vyos.util import dict_search  from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -109,20 +110,26 @@ def get_config(config=None):          for interface, interface_config in bond['member']['interface'].items():              # Check if member interface is already member of another bridge              tmp = is_member(conf, interface, 'bridge') -            if tmp: interface_config.update({'is_bridge_member' : tmp}) +            if tmp: bond['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 and bond['ifname'] not in tmp: -                interface_config.update({'is_bond_member' : tmp}) +            for tmp in is_member(conf, interface, 'bonding'): +                if bond['ifname'] == tmp: +                    continue +                bond['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: interface_config.update({'is_source_interface' : tmp}) +            if tmp: bond['member']['interface'][interface].update({'is_source_interface' : tmp})              # bond members must not have an assigned address              tmp = has_address_configured(conf, interface) -            if tmp: interface_config.update({'has_address' : ''}) +            if tmp: bond['member']['interface'][interface].update({'has_address' : {}}) + +            # bond members must not have a VRF attached +            tmp = has_vrf_configured(conf, interface) +            if tmp: bond['member']['interface'][interface].update({'has_vrf' : {}})      return bond @@ -167,11 +174,11 @@ def verify(bond):                  raise ConfigError(error_msg + 'it does not exist!')              if 'is_bridge_member' in interface_config: -                tmp = next(iter(interface_config['is_bridge_member'])) +                tmp = 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'])) +                tmp = 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: @@ -181,6 +188,8 @@ def verify(bond):              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 'primary' in bond:          if bond['primary'] not in bond['member']['interface']: diff --git a/src/conf_mode/interfaces-bridge.py b/src/conf_mode/interfaces-bridge.py index 38ae727c1..cd0d9003b 100755 --- a/src/conf_mode/interfaces-bridge.py +++ b/src/conf_mode/interfaces-bridge.py @@ -31,6 +31,7 @@ from vyos.configverify import verify_mirror_redirect  from vyos.configverify import verify_vrf  from vyos.ifconfig import BridgeIf  from vyos.validate import has_address_configured +from vyos.validate import has_vrf_configured  from vyos.xml import defaults  from vyos.util import cmd @@ -93,6 +94,10 @@ def get_config(config=None):              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: @@ -118,11 +123,11 @@ def verify(bridge):                  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'])) +                tmp = 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'])) +                tmp = 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: @@ -132,9 +137,12 @@ def verify(bridge):              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 an VLAN subinterface assigned!') +                    raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!')                  if 'wlan' in interface:                      raise ConfigError(error_msg + 'VLAN aware cannot be set!') diff --git a/src/conf_mode/interfaces-ethernet.py b/src/conf_mode/interfaces-ethernet.py index fec4456fb..30e7a2af7 100755 --- a/src/conf_mode/interfaces-ethernet.py +++ b/src/conf_mode/interfaces-ethernet.py @@ -31,6 +31,7 @@ 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.ethtool import Ethtool  from vyos.ifconfig import EthernetIf  from vyos.pki import find_chain @@ -83,6 +84,7 @@ def verify(ethernet):      verify_dhcpv6(ethernet)      verify_address(ethernet)      verify_vrf(ethernet) +    verify_bond_bridge_member(ethernet)      verify_eapol(ethernet)      verify_mirror_redirect(ethernet) diff --git a/src/conf_mode/interfaces-geneve.py b/src/conf_mode/interfaces-geneve.py index b9cf2fa3c..08cc3a48d 100755 --- a/src/conf_mode/interfaces-geneve.py +++ b/src/conf_mode/interfaces-geneve.py @@ -27,6 +27,7 @@ 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.ifconfig import GeneveIf  from vyos import ConfigError @@ -64,6 +65,7 @@ def verify(geneve):      verify_mtu_ipv6(geneve)      verify_address(geneve) +    verify_bond_bridge_member(geneve)      verify_mirror_redirect(geneve)      if 'remote' not in geneve: diff --git a/src/conf_mode/interfaces-l2tpv3.py b/src/conf_mode/interfaces-l2tpv3.py index 6a486f969..ca321e01d 100755 --- a/src/conf_mode/interfaces-l2tpv3.py +++ b/src/conf_mode/interfaces-l2tpv3.py @@ -26,6 +26,7 @@ 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 L2TPv3If  from vyos.util import check_kmod  from vyos.validate import is_addr_assigned @@ -77,6 +78,7 @@ def verify(l2tpv3):      verify_mtu_ipv6(l2tpv3)      verify_address(l2tpv3) +    verify_bond_bridge_member(l2tpv3)      verify_mirror_redirect(l2tpv3)      return None diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index 279dd119b..03a010086 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# 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 @@ -21,16 +21,20 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict -from vyos.ifconfig import MACsecIf -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.util import call +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_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.util import call +from vyos.util import dict_search +from vyos.util import is_systemd_service_running  from vyos import ConfigError  from vyos import airbag  airbag.enable() @@ -55,6 +59,12 @@ def get_config(config=None):          source_interface = conf.return_effective_value(['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': {}}) +      return macsec @@ -67,22 +77,15 @@ def verify(macsec):      verify_vrf(macsec)      verify_mtu_ipv6(macsec)      verify_address(macsec) +    verify_bond_bridge_member(macsec)      verify_mirror_redirect(macsec) -    if not (('security' in macsec) and -            ('cipher' in macsec['security'])): -        raise ConfigError( -            'Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) +    if dict_search('security.cipher', macsec) == None: +        raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) -    if (('security' in macsec) and -        ('encrypt' in macsec['security'])): -        tmp = macsec.get('security') - -        if not (('mka' in tmp) and -                ('cak' in tmp['mka']) and -                ('ckn' in tmp['mka'])): -            raise ConfigError('Missing mandatory MACsec security ' -                              'keys as encryption is enabled!') +    if dict_search('security.encrypt', macsec) != None: +        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!')      if 'source_interface' in macsec:          # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad @@ -97,33 +100,35 @@ def verify(macsec):  def generate(macsec): -    render(wpa_suppl_conf.format(**macsec), -           'macsec/wpa_supplicant.conf.j2', macsec) +    render(wpa_suppl_conf.format(**macsec), 'macsec/wpa_supplicant.conf.j2', macsec)      return None  def apply(macsec): -    # Remove macsec interface -    if 'deleted' in macsec: -        call('systemctl stop wpa_supplicant-macsec@{source_interface}' -             .format(**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 macsec['ifname'] in interfaces():              tmp = MACsecIf(macsec['ifname'])              tmp.remove() -        # delete configuration on interface removal -        if os.path.isfile(wpa_suppl_conf.format(**macsec)): -            os.unlink(wpa_suppl_conf.format(**macsec)) +        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)) -    else: -        # 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) +            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) -        call('systemctl restart wpa_supplicant-macsec@{source_interface}' -             .format(**macsec)) +    if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: +        call(f'systemctl reload-or-restart {systemd_service}')      return None diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 280a62b9a..ef745d737 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -36,6 +36,7 @@ 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 @@ -503,6 +504,7 @@ def verify(openvpn):              raise ConfigError('Username for authentication is missing')      verify_vrf(openvpn) +    verify_bond_bridge_member(openvpn)      verify_mirror_redirect(openvpn)      return None diff --git a/src/conf_mode/interfaces-pseudo-ethernet.py b/src/conf_mode/interfaces-pseudo-ethernet.py index 1cd3fe276..f26a50a0e 100755 --- a/src/conf_mode/interfaces-pseudo-ethernet.py +++ b/src/conf_mode/interfaces-pseudo-ethernet.py @@ -26,6 +26,7 @@ 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.configverify import verify_bond_bridge_member  from vyos.ifconfig import MACVLANIf  from vyos import ConfigError diff --git a/src/conf_mode/interfaces-tunnel.py b/src/conf_mode/interfaces-tunnel.py index eff7f373c..acef1fda7 100755 --- a/src/conf_mode/interfaces-tunnel.py +++ b/src/conf_mode/interfaces-tunnel.py @@ -29,6 +29,7 @@ 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 Section  from vyos.ifconfig import TunnelIf @@ -158,6 +159,7 @@ def verify(tunnel):      verify_mtu_ipv6(tunnel)      verify_address(tunnel)      verify_vrf(tunnel) +    verify_bond_bridge_member(tunnel)      verify_mirror_redirect(tunnel)      if 'source_interface' in tunnel: diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index f44d754ba..af2d0588d 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -29,6 +29,7 @@ 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 Interface  from vyos.ifconfig import VXLANIf  from vyos.template import is_ipv6 @@ -117,6 +118,11 @@ def verify(vxlan):              # 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} '\ @@ -144,6 +150,7 @@ def verify(vxlan):      verify_mtu_ipv6(vxlan)      verify_address(vxlan) +    verify_bond_bridge_member(vxlan)      verify_mirror_redirect(vxlan)      return None diff --git a/src/conf_mode/interfaces-wireguard.py b/src/conf_mode/interfaces-wireguard.py index 180ffa507..61bab2feb 100755 --- a/src/conf_mode/interfaces-wireguard.py +++ b/src/conf_mode/interfaces-wireguard.py @@ -29,6 +29,7 @@ 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.util import check_kmod  from vyos.util import check_port_availability @@ -71,6 +72,7 @@ def verify(wireguard):      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: diff --git a/src/conf_mode/interfaces-wireless.py b/src/conf_mode/interfaces-wireless.py index d34297063..dd798b5a2 100755 --- a/src/conf_mode/interfaces-wireless.py +++ b/src/conf_mode/interfaces-wireless.py @@ -30,6 +30,7 @@ from vyos.configverify import verify_source_interface  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.util import call @@ -194,6 +195,7 @@ def verify(wifi):      verify_address(wifi)      verify_vrf(wifi) +    verify_bond_bridge_member(wifi)      verify_mirror_redirect(wifi)      # use common function to verify VLAN configuration diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index e275ace84..97b3a6396 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -76,7 +76,7 @@ def get_config(config=None):      # We need to know the amount of other WWAN interfaces as ModemManager needs      # to be started or stopped.      conf.set_level(base) -    _, wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'), +    wwan['other_interfaces'] = conf.get_config_dict([], key_mangling=('-', '_'),                                                         get_first_key=True,                                                         no_tag_node_value_mangle=True) diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 01f14df61..7d3687094 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -101,6 +101,17 @@ def verify_remote_as(peer_config, bgp_config):      return None +def verify_afi(peer_config, bgp_config): +    if 'address_family' in peer_config: +        return True + +    if 'peer_group' in peer_config: +        peer_group_name = peer_config['peer_group'] +        tmp = dict_search(f'peer_group.{peer_group_name}.address_family', bgp_config) +        if tmp: return True + +    return False +  def verify(bgp):      if not bgp or 'deleted' in bgp:          if 'dependent_vrfs' in bgp: @@ -165,6 +176,9 @@ def verify(bgp):                  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'] @@ -199,8 +213,11 @@ def verify(bgp):                      if 'source_interface' in peer_config['interface']:                          raise ConfigError(f'"source-interface" option not allowed for neighbor "{peer}"') -            if 'address_family' not in peer_config: -                Warning(f'BGP neighbor "{peer}" requires address-family!') +            # 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['local_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', diff --git a/src/conf_mode/service_ids_fastnetmon.py b/src/conf_mode/service_ids_fastnetmon.py index ae7e582ec..c58f8db9a 100755 --- a/src/conf_mode/service_ids_fastnetmon.py +++ b/src/conf_mode/service_ids_fastnetmon.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# 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 @@ -19,14 +19,17 @@ import os  from sys import exit  from vyos.config import Config -from vyos import ConfigError -from vyos.util import call +from vyos.configdict import dict_merge  from vyos.template import render +from vyos.util import call +from vyos.xml import defaults +from vyos import ConfigError  from vyos import airbag  airbag.enable() -config_file = r'/etc/fastnetmon.conf' -networks_list = r'/etc/networks_list' +config_file = r'/run/fastnetmon/fastnetmon.conf' +networks_list = r'/run/fastnetmon/networks_list' +excluded_networks_list = r'/run/fastnetmon/excluded_networks_list'  def get_config(config=None):      if config: @@ -34,50 +37,55 @@ def get_config(config=None):      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) +    # 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 = defaults(base) +    fastnetmon = dict_merge(default_values, fastnetmon) +      return fastnetmon  def verify(fastnetmon):      if not fastnetmon:          return None -    if not "mode" in fastnetmon: -        raise ConfigError('ddos-protection mode is mandatory!') - -    if not "network" in fastnetmon: -        raise ConfigError('Required define network!') +    if 'mode' not in fastnetmon: +        raise ConfigError('Specify operating mode!') -    if not "listen_interface" in fastnetmon: -        raise ConfigError('Define listen-interface is mandatory!') +    if 'listen_interface' not in fastnetmon: +        raise ConfigError('Specify interface(s) for traffic capture') -    if "alert_script" in fastnetmon: -        if os.path.isfile(fastnetmon["alert_script"]): +    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 {0} does not have permissions for execution'.format(fastnetmon["alert_script"])) +            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 {0} does not exists!'.format(fastnetmon["alert_script"])) +           raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon))  def generate(fastnetmon):      if not fastnetmon: -        if os.path.isfile(config_file): -            os.unlink(config_file) -        if os.path.isfile(networks_list): -            os.unlink(networks_list) +        for file in [config_file, networks_list]: +            if os.path.isfile(file): +                os.unlink(file) -        return +        return None      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('systemctl stop fastnetmon.service') +        call(f'systemctl stop {systemd_service}')      else: -        call('systemctl restart fastnetmon.service') +        call(f'systemctl reload-or-restart {systemd_service}')      return None diff --git a/src/conf_mode/system-ip.py b/src/conf_mode/system-ip.py index 05fc3a97a..0c5063ed3 100755 --- a/src/conf_mode/system-ip.py +++ b/src/conf_mode/system-ip.py @@ -64,6 +64,11 @@ def apply(opt):      value = '0' if (tmp != None) else '1'      write_file('/proc/sys/net/ipv4/conf/all/forwarding', value) +    # enable/disable IPv4 directed broadcast forwarding +    tmp = dict_search('disable_directed_broadcast', opt) +    value = '0' if (tmp != None) else '1' +    write_file('/proc/sys/net/ipv4/conf/all/bc_forwarding', value) +      # configure multipath      tmp = dict_search('multipath.ignore_unreachable_nexthops', opt)      value = '1' if (tmp != None) else '0' diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index c717286ae..3dcbc995c 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -231,7 +231,7 @@ def apply(login):              if tmp: command += f" --home '{tmp}'"              else: command += f" --home '/home/{user}'" -            command += f' --groups frrvty,vyattacfg,sudo,adm,dip,disk {user}' +            command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}'              try:                  cmd(command) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 972d0289b..1b4156895 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -113,8 +113,14 @@ def verify(vrf):                                    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, 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 config:                  raise ConfigError(f'VRF "{name}" table id is mandatory!') diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py index f7487ee5f..8274e6564 100755 --- a/src/etc/opennhrp/opennhrp-script.py +++ b/src/etc/opennhrp/opennhrp-script.py @@ -14,16 +14,17 @@  # 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 pprint import pprint  import os  import re  import sys  import vici +from json import loads  from vyos.util import cmd  from vyos.util import process_named_running -NHRP_CONFIG="/run/opennhrp/opennhrp.conf" +NHRP_CONFIG = "/run/opennhrp/opennhrp.conf" +  def parse_type_ipsec(interface):      with open(NHRP_CONFIG, 'r') as f: @@ -35,6 +36,50 @@ def parse_type_ipsec(interface):                  return m[1], m[2]      return None, None + +def add_peer_route(nbma_src: str, nbma_dst: str, mtu: str) -> None: +    """Add a route to a NBMA peer + +    Args: +        nmba_src (str): a local IP address +        nbma_dst (str): a remote IP address +        mtu (str): a MTU for a route +    """ +    # Find routes to a peer +    route_get_cmd = f'sudo ip -j route get {nbma_dst} from {nbma_src}' +    try: +        route_info_data = loads(cmd(route_get_cmd)) +    except Exception as err: +        print(f'Unable to find a route to {nbma_dst}: {err}') + +    # Check if an output has an expected format +    if not isinstance(route_info_data, list): +        print(f'Garbage returned from the "{route_get_cmd}" 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: +            print(f'Unable to add a route using command "{route_add_cmd}": \ +                    {err}') + +  def vici_initiate(conn, child_sa, src_addr, dest_addr):      try:          session = vici.Session() @@ -52,6 +97,7 @@ def vici_initiate(conn, child_sa, src_addr, dest_addr):      except:          return None +  def vici_terminate(conn, child_sa, src_addr, dest_addr):      try:          session = vici.Session() @@ -69,25 +115,27 @@ def vici_terminate(conn, child_sa, src_addr, dest_addr):      except:          return None +  def iface_up(interface):      cmd(f'sudo ip route flush proto 42 dev {interface}')      cmd(f'sudo ip neigh flush dev {interface}') +  def peer_up(dmvpn_type, conn): -    src_addr = os.getenv('NHRP_SRCADDR') +    # src_addr = os.getenv('NHRP_SRCADDR')      src_nbma = os.getenv('NHRP_SRCNBMA') -    dest_addr = os.getenv('NHRP_DESTADDR') +    # dest_addr = os.getenv('NHRP_DESTADDR')      dest_nbma = os.getenv('NHRP_DESTNBMA')      dest_mtu = os.getenv('NHRP_DESTMTU')      if dest_mtu: -        args = cmd(f'sudo ip route get {dest_nbma} from {src_nbma}') -        cmd(f'sudo ip route add {args} proto 42 mtu {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, 'dmvpn', src_nbma, dest_nbma)          vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma) +  def peer_down(dmvpn_type, conn):      src_nbma = os.getenv('NHRP_SRCNBMA')      dest_nbma = os.getenv('NHRP_DESTNBMA') @@ -97,14 +145,17 @@ def peer_down(dmvpn_type, conn):      cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') +  def route_up(interface):      dest_addr = os.getenv('NHRP_DESTADDR')      dest_prefix = os.getenv('NHRP_DESTPREFIX')      next_hop = os.getenv('NHRP_NEXTHOP') -    cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 via {next_hop} dev {interface}') +    cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 \ +        via {next_hop} dev {interface}')      cmd('sudo ip route flush cache') +  def route_down(interface):      dest_addr = os.getenv('NHRP_DESTADDR')      dest_prefix = os.getenv('NHRP_DESTPREFIX') @@ -112,6 +163,7 @@ def route_down(interface):      cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42')      cmd('sudo ip route flush cache') +  if __name__ == '__main__':      action = sys.argv[1]      interface = os.getenv('NHRP_INTERFACE') diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index e03d3a29c..4feb7e09a 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -27,6 +27,12 @@ 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 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 000000000..841666070 --- /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 000000000..69eb1a86a --- /dev/null +++ b/src/etc/systemd/system/frr.service.d/override.conf @@ -0,0 +1,11 @@ +[Unit] +Before= +Before=vyos-router.service + +[Service] +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/op_mode/bridge.py b/src/op_mode/bridge.py new file mode 100755 index 000000000..411aa06d1 --- /dev/null +++ b/src/op_mode/bridge.py @@ -0,0 +1,202 @@ +#!/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 typing + +from sys import exit +from tabulate import tabulate + +from vyos.util import cmd +from vyos.util import dict_search + +import vyos.opmode + + +def _get_json_data(): +    """ +    Get bridge data format JSON +    """ +    return cmd(f'sudo 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(): +    """ +    :returns dict +    """ +    json_data = cmd('sudo bridge --json --compressvlans vlan 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 +    """ +    json_data = cmd(f'sudo bridge --json fdb show br {bridge}') +    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 = ', '.join(vlan_entry.get('flags')).lower() +            data_entries.append([interface, vlan, flags]) + +    headers = ["Interface", "Vlan", "Flags"] +    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 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): +    bridge_vlan = _get_raw_data_vlan() +    if raw: +        return bridge_vlan +    else: +        return _get_formatted_output_vlan(bridge_vlan) + + +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) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py new file mode 100755 index 000000000..1441d110f --- /dev/null +++ b/src/op_mode/conntrack.py @@ -0,0 +1,121 @@ +#!/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 xmltodict + +from tabulate import tabulate +from vyos.util import cmd +from vyos.util import run + +import vyos.opmode + + +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) +    return _xml_to_dict(xml) + + +def get_formatted_output(dict_data): +    """ +    :param xml: +    :return: formatted output +    """ +    data_entries = [] +    #dict_data = _get_raw_data(family) +    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['timeout'] +                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'] +                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: str): +    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) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/cpu.py b/src/op_mode/cpu.py new file mode 100755 index 000000000..f9c425826 --- /dev/null +++ b/src/op_mode/cpu.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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.cpu +import vyos.opmode + +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 vyos.cpu.get_cpus() + +def _format_cpus(cpu_data): +    env = {'cpus': cpu_data} +    return cpu_template.render(env).strip() + +def _get_summary_data(): +    count = vyos.cpu.get_core_count() +    cpu_data = vyos.cpu.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 as e: +        print(e) +        sys.exit(1) + diff --git a/src/op_mode/cpu_summary.py b/src/op_mode/cpu_summary.py deleted file mode 100755 index 3bdf5a718..000000000 --- a/src/op_mode/cpu_summary.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/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 re -from vyos.util import colon_separated_to_dict - -FILE_NAME = '/proc/cpuinfo' - -def get_raw_data(): -    with open(FILE_NAME, 'r') as f: -        data_raw = f.read() - -    data = colon_separated_to_dict(data_raw) - -    # Accumulate all data in a dict for future support for machine-readable output -    cpu_data = {} -    cpu_data['cpu_number'] = len(data['processor']) -    cpu_data['models'] = list(set(data['model name'])) - -    # Strip extra whitespace from CPU model names, /proc/cpuinfo is prone to that -    cpu_data['models'] = list(map(lambda s: re.sub(r'\s+', ' ', s), cpu_data['models'])) - -    return cpu_data - -def get_formatted_output(): -    cpu_data = get_raw_data() - -    out = "CPU(s): {0}\n".format(cpu_data['cpu_number']) -    out += "CPU model(s): {0}".format(",".join(cpu_data['models'])) - -    return out - -if __name__ == '__main__': -    print(get_formatted_output()) - diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py new file mode 100755 index 000000000..432856585 --- /dev/null +++ b/src/op_mode/ipsec.py @@ -0,0 +1,71 @@ +#!/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 re +import sys +from vyos.util import call +import vyos.opmode + + +SWANCTL_CONF = '/etc/swanctl/swanctl.conf' + + +def get_peer_connections(peer, tunnel, return_all = False): +    peer = peer.replace(':', '-') +    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 reset_peer(peer: str, tunnel:str): +    if not peer: +        print('Invalid peer, aborting') +        return + +    conns = get_peer_connections(peer, tunnel, return_all = (not tunnel or tunnel == 'all')) + +    if not conns: +        print('Tunnel(s) not found, aborting') +        return + +    result = True +    for conn in conns: +        try: +            call(f'sudo /usr/sbin/ipsec down {conn}{{*}}', timeout = 10) +            call(f'sudo /usr/sbin/ipsec up {conn}', timeout = 10) +        except TimeoutExpired as e: +            print(f'Timed out while resetting {conn}') +            result = False + + +    print('Peer reset result: ' + ('success' if result else 'failed')) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/show_ram.py b/src/op_mode/memory.py index 2b0be3965..a3870e498 100755 --- a/src/op_mode/show_ram.py +++ b/src/op_mode/memory.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2022 VyOS maintainers and contributors +# 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 @@ -15,7 +15,12 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  # -def get_system_memory(): +import sys + +import vyos.opmode + + +def _get_system_memory():      from re import search as re_search      def find_value(keyword, mem_data): @@ -43,10 +48,10 @@ def get_system_memory():      return res -def get_system_memory_human(): +def _get_system_memory_human():      from vyos.util import bytes_to_human -    mem = get_system_memory() +    mem = _get_system_memory()      for key in mem:          # The Linux kernel exposes memory values in kilobytes, @@ -55,17 +60,31 @@ def get_system_memory_human():      return mem -def get_raw_data(): -    return get_system_memory_human() - -def get_formatted_output(): -    mem = get_raw_data() +def _get_raw_data(): +    return _get_system_memory_human() +def _get_formatted_output(mem):      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__': -    print(get_formatted_output()) +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) + diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py new file mode 100755 index 000000000..4b54ecf31 --- /dev/null +++ b/src/op_mode/nat.py @@ -0,0 +1,191 @@ +#!/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 + +from sys import exit +from tabulate import tabulate + +from vyos.util import cmd +from vyos.util import dict_search + +import vyos.opmode + + +def _get_json_data(direction): +    """ +    Get NAT format JSON +    """ +    if direction == 'source': +        chain = 'POSTROUTING' +    if direction == 'destination': +        chain = 'PREROUTING' +    return cmd(f'sudo nft --json list chain ip nat {chain}') + + +def _get_raw_data_rules(direction): +    """Get interested rules +    :returns dict +    """ +    data = _get_json_data(direction) +    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_formatted_output_rules(data, direction): +    # Add default values before loop +    sport, dport, proto = 'any', 'any', 'any' +    saddr, daddr = '0.0.0.0/0', '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']: +                if 'prefix' in match['right'] or 'set' in match['right']: +                    # Merge dict src/dst l3_l4 parameters +                    my_dict = {**match['left']['payload'], **match['right']} +                    proto = my_dict.get('protocol').upper() +                    if my_dict['field'] == 'saddr': +                        saddr = f'{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' +                    elif my_dict['field'] == 'daddr': +                        daddr = f'{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' +                    elif my_dict['field'] == 'sport': +                        # Port range or single port +                        if jmespath.search('set[*].range', my_dict): +                            sport = my_dict['set'][0]['range'] +                            sport = '-'.join(map(str, sport)) +                        else: +                            sport = my_dict.get('set') +                            sport = ','.join(map(str, sport)) +                    elif my_dict['field'] == 'dport': +                        # Port range or single port +                        if jmespath.search('set[*].range', my_dict): +                            dport = my_dict["set"][0]["range"] +                            dport = '-'.join(map(str, dport)) +                        else: +                            dport = my_dict.get('set') +                            dport = ','.join(map(str, dport)) +                else: +                    if jmespath.search('left.payload.field', match) == 'saddr': +                        saddr = match.get('right') +                    if jmespath.search('left.payload.field', match) == 'daddr': +                        daddr = match.get('right') +            else: +                saddr = '0.0.0.0/0' +                daddr = '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}''' +                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 show_rules(raw: bool, direction: str): +    nat_rules = _get_raw_data_rules(direction) +    if raw: +        return nat_rules +    else: +        return _get_formatted_output_rules(nat_rules, direction) + + +def show_statistics(raw: bool, direction: str): +    nat_statistics = _get_raw_data_rules(direction) +    if raw: +        return nat_statistics +    else: +        return _get_formatted_output_statistics(nat_statistics, direction) + + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/show_neigh.py b/src/op_mode/neighbor.py index d874bd544..d86a372ac 100755 --- a/src/op_mode/show_neigh.py +++ b/src/op_mode/neighbor.py @@ -28,29 +28,37 @@  # ]  import sys +import typing +import vyos.opmode -def get_raw_data(family, device=None, state=None): +def interface_exists(interface): +    import os +    return os.path.exists(f'/sys/class/net/{interface}') + +def get_raw_data(family, interface=None, state=None):      from json import loads      from vyos.util import cmd -    if device: -        device = f"dev {device}" +    if interface: +        if not interface_exists(interface): +            raise ValueError(f"Interface '{interface}' does not exist in the system") +        interface = f"dev {interface}"      else: -        device = "" +        interface = ""      if state:          state = f"nud {state}"      else:          state = "" -    neigh_cmd = f"ip --family {family} --json neighbor list {device} {state}" +    neigh_cmd = f"ip --family {family} --json neighbor list {interface} {state}"      data = loads(cmd(neigh_cmd))      return data -def get_formatted_output(family, device=None, state=None): +def format_neighbors(neighs, interface=None):      from tabulate import tabulate      def entry_to_list(e, intf=None): @@ -68,35 +76,47 @@ def get_formatted_output(family, device=None, state=None):          # Device field is absent from outputs of `ip neigh list dev ...`          if "dev" in e:              dev = e["dev"] -        elif device: -            dev = device +        elif interface: +            dev = interface          else:              raise ValueError("interface is not defined")          return [dst, dev, lladdr, state] -    neighs = get_raw_data(family, device=device, state=state)      neighs = map(entry_to_list, neighs)      headers = ["Address", "Interface", "Link layer address",  "State"]      return tabulate(neighs, headers) -if __name__ == '__main__': -    from argparse import ArgumentParser +def show(raw: bool, family: str, interface: typing.Optional[str], state: typing.Optional[str]): +    """ Display neighbor table contents """ +    data = get_raw_data(family, interface, state=state) -    parser = ArgumentParser() -    parser.add_argument("-f", "--family", type=str, default="inet", help="Address family") -    parser.add_argument("-i", "--interface", type=str, help="Network interface") -    parser.add_argument("-s", "--state", type=str, help="Neighbor table entry state") +    if raw: +        return data +    else: +        return format_neighbors(data, interface) -    args = parser.parse_args() +def reset(family: str, interface: typing.Optional[str], address: typing.Optional[str]): +    from vyos.util import run -    if args.state: -        if args.state not in ["reachable", "failed", "stale", "permanent"]: -            raise ValueError(f"""Incorrect state "{args.state}"! Must be one of: reachable, stale, failed, permanent""") +    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: -        print(get_formatted_output(args.family, device=args.interface, state=args.state)) +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res)      except ValueError 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 000000000..3bb06adac --- /dev/null +++ b/src/op_mode/route.py @@ -0,0 +1,98 @@ +#!/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 %} +""") + +def show(raw: bool, +         family: str, +         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.util import cmd +        output = cmd(f"vtysh -c '{frr_command}'") + +        if raw: +            from json import loads +            return loads(output) +        else: +            return output + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) + diff --git a/src/op_mode/show_cpu.py b/src/op_mode/show_cpu.py deleted file mode 100755 index 9973d9789..000000000 --- a/src/op_mode/show_cpu.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2016-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 json - -from jinja2 import Template -from sys import exit -from vyos.util import popen, DEVNULL - -OUT_TMPL_SRC = """ -{%- if cpu -%} -{% if 'vendor' in cpu %}CPU Vendor:       {{cpu.vendor}}{% endif %} -{% if 'model' in cpu %}Model:            {{cpu.model}}{% endif %} -{% if 'cpus' in cpu %}Total CPUs:       {{cpu.cpus}}{% endif %} -{% if 'sockets' in cpu %}Sockets:          {{cpu.sockets}}{% endif %} -{% if 'cores' in cpu %}Cores:            {{cpu.cores}}{% endif %} -{% if 'threads' in cpu %}Threads:          {{cpu.threads}}{% endif %} -{% if 'mhz' in cpu %}Current MHz:      {{cpu.mhz}}{% endif %} -{% if 'mhz_min' in cpu %}Minimum MHz:      {{cpu.mhz_min}}{% endif %} -{% if 'mhz_max' in cpu %}Maximum MHz:      {{cpu.mhz_max}}{% endif %} -{%- endif -%} -""" - -def get_raw_data(): -    cpu = {} -    cpu_json, code = popen('lscpu -J', stderr=DEVNULL) - -    if code == 0: -        cpu_info = json.loads(cpu_json) -        if len(cpu_info) > 0 and 'lscpu' in cpu_info: -            for prop in cpu_info['lscpu']: -                if (prop['field'].find('Thread(s)') > -1): cpu['threads'] = prop['data'] -                if (prop['field'].find('Core(s)')) > -1: cpu['cores'] = prop['data'] -                if (prop['field'].find('Socket(s)')) > -1: cpu['sockets'] = prop['data'] -                if (prop['field'].find('CPU(s):')) > -1: cpu['cpus'] = prop['data'] -                if (prop['field'].find('CPU MHz')) > -1: cpu['mhz'] = prop['data'] -                if (prop['field'].find('CPU min MHz')) > -1: cpu['mhz_min'] = prop['data'] -                if (prop['field'].find('CPU max MHz')) > -1: cpu['mhz_max'] = prop['data'] -                if (prop['field'].find('Vendor ID')) > -1: cpu['vendor'] = prop['data'] -                if (prop['field'].find('Model name')) > -1: cpu['model'] = prop['data'] - -    return cpu - -def get_formatted_output(): -    cpu = get_raw_data() - -    tmp = {'cpu':cpu} -    tmpl = Template(OUT_TMPL_SRC) -    return tmpl.render(tmp) - -if __name__ == '__main__': -    cpu = get_raw_data() - -    if len(cpu) > 0: -        print(get_formatted_output()) -    else: -        print('CPU information could not be determined\n') -        exit(1) - diff --git a/src/op_mode/show_nat_rules.py b/src/op_mode/show_nat_rules.py index 98adb31dd..60a4bdd13 100755 --- a/src/op_mode/show_nat_rules.py +++ b/src/op_mode/show_nat_rules.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# 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 @@ -99,8 +99,9 @@ if args.source or args.destination:                      if addr_tmp and len_tmp:                          tran_addr += addr_tmp + '/' + str(len_tmp) + ' ' -                if isinstance(tran_addr_json['port'],int): -                    tran_addr += 'port ' + str(tran_addr_json['port']) +                if tran_addr_json.get('port'): +                    if isinstance(tran_addr_json['port'],int): +                        tran_addr += 'port ' + str(tran_addr_json['port'])              else:                  if 'masquerade' in data['expr'][i]: @@ -111,6 +112,8 @@ if args.source or args.destination:          if srcdest != '':              srcdests.append(srcdest)              srcdest = '' +        else: +            srcdests.append('any')          print(format_nat_rule.format(rule, srcdests[0], tran_addr, interface))          for i in range(1, len(srcdests)): diff --git a/src/op_mode/show_nat_translations.py b/src/op_mode/show_nat_translations.py index 25091e9fc..508845e23 100755 --- a/src/op_mode/show_nat_translations.py +++ b/src/op_mode/show_nat_translations.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# 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 @@ -83,11 +83,23 @@ def pipe():      return xml +def xml_to_dict(xml): +    """ +    Convert XML to dictionary +    Return: dictionary +    """ +    parse = xmltodict.parse(xml) +    # If only one NAT entry we must change dict T4499 +    if 'meta' in parse['conntrack']['flow']: +        return dict(conntrack={'flow': [parse['conntrack']['flow']]}) +    return parse + +  def process(data, stats, protocol, pipe, verbose, flowtype=''):      if not data:          return -    parsed = xmltodict.parse(data) +    parsed = xml_to_dict(data)      print(headers(verbose, pipe)) diff --git a/src/op_mode/show_vrf.py b/src/op_mode/show_vrf.py deleted file mode 100755 index 3c7a90205..000000000 --- a/src/op_mode/show_vrf.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/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 argparse -import jinja2 -from json import loads - -from vyos.util import cmd - -vrf_out_tmpl = """VRF name          state     mac address        flags                     interfaces ---------          -----     -----------        -----                     ---------- -{%- for v in vrf %} -{{"%-16s"|format(v.ifname)}}  {{ "%-8s"|format(v.operstate | lower())}}  {{"%-17s"|format(v.address | lower())}}  {{ v.flags|join(',')|lower()}}  {{v.members|join(',')|lower()}} -{%- endfor %} - -""" - -def list_vrfs(): -    command = 'ip -j -br link show type vrf' -    answer = loads(cmd(command)) -    return [_ for _ in answer if _] - -def list_vrf_members(vrf): -    command = f'ip -j -br link show master {vrf}' -    answer = loads(cmd(command)) -    return [_ for _ in answer if _] - -parser = argparse.ArgumentParser() -group = parser.add_mutually_exclusive_group() -group.add_argument("-e", "--extensive", action="store_true", -                   help="provide detailed vrf informatio") -parser.add_argument('interface', metavar='I', type=str, nargs='?', -                    help='interface to display') - -args = parser.parse_args() - -if args.extensive: -    data = { 'vrf': [] } -    for vrf in list_vrfs(): -        name = vrf['ifname'] -        if args.interface and name != args.interface: -            continue - -        vrf['members'] = [] -        for member in list_vrf_members(name): -            vrf['members'].append(member['ifname']) -        data['vrf'].append(vrf) - -    tmpl = jinja2.Template(vrf_out_tmpl) -    print(tmpl.render(data)) - -else: -    print(" ".join([vrf['ifname'] for vrf in list_vrfs()])) diff --git a/src/op_mode/show_version.py b/src/op_mode/version.py index b82ab6eca..06208c3e5 100755 --- a/src/op_mode/show_version.py +++ b/src/op_mode/version.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2016-2020 VyOS maintainers and contributors +# Copyright (C) 2016-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 @@ -18,13 +18,14 @@  #    Displays image version and system information.  #    Used by the "run show version" command. -import argparse +import sys +import typing + +import vyos.opmode  import vyos.version  import vyos.limericks  from jinja2 import Template -from sys import exit -from vyos.util import call  version_output_tmpl = """  Version:          VyOS {{version}} @@ -45,32 +46,39 @@ Hardware S/N:     {{hardware_serial}}  Hardware UUID:    {{hardware_uuid}}  Copyright:        VyOS maintainers and contributors +{%- if limerick %} +{{limerick}} +{% endif -%}  """ -def get_raw_data(): +def _get_raw_data(funny=False):      version_data = vyos.version.get_full_version_data() + +    if funny: +        version_data["limerick"] = vyos.limericks.get_random() +      return version_data -def get_formatted_output(): -    version_data = get_raw_data() +def _get_formatted_output(version_data):      tmpl = Template(version_output_tmpl) -    return tmpl.render(version_data) +    return tmpl.render(version_data).strip() -if __name__ == '__main__': -    parser = argparse.ArgumentParser() -    parser.add_argument("-f", "--funny", action="store_true", help="Add something funny to the output") -    parser.add_argument("-j", "--json", action="store_true", help="Produce JSON output") +def show(raw: bool, funny: typing.Optional[bool]): +    """ Display neighbor table contents """ +    version_data = _get_raw_data(funny=funny) -    args = parser.parse_args() +    if raw: +        return version_data +    else: +        return _get_formatted_output(version_data) -    version_data = vyos.version.get_full_version_data() -    if args.json: -        import json -        print(json.dumps(version_data)) -        exit(0) -    else: -        print(get_formatted_output()) +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except ValueError as e: +        print(e) +        sys.exit(1) -    if args.funny: -        print(vyos.limericks.get_random()) diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index 8955e5a59..68dc5bc45 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2021 VyOS maintainers and contributors +# 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 @@ -87,6 +87,7 @@ def reset_profile(profile, tunnel):      print('Profile reset result: ' + ('success' if result == 0 else 'failed'))  def debug_peer(peer, tunnel): +    peer = peer.replace(':', '-')      if not peer or peer == "all":          debug_commands = [              "sudo ipsec statusall", @@ -109,7 +110,7 @@ def debug_peer(peer, tunnel):      if not tunnel or tunnel == 'all':          tunnel = '' -    conn = get_peer_connections(peer, tunnel) +    conns = get_peer_connections(peer, tunnel, return_all = (tunnel == '' or tunnel == 'all'))      if not conns:          print('Peer not found, aborting') diff --git a/src/op_mode/vrf.py b/src/op_mode/vrf.py new file mode 100755 index 000000000..f86516786 --- /dev/null +++ b/src/op_mode/vrf.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 +import sys +import typing + +from tabulate import tabulate +from vyos.util 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('sudo 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'sudo ip --json --brief link show dev {name}') +            data = json.loads(output) +            return data +        return [] +    return data + + +def _get_vrf_members(vrf: str) -> list: +    """ +    Get list of interface VRF members +    :param vrf: str +    :return: list +    """ +    output = cmd(f'sudo ip --json --brief link show master {vrf}') +    answer = json.loads(output) +    interfaces = [] +    for data in answer: +        if 'ifname' in data: +            interfaces.append(data.get('ifname')) +    return interfaces if len(interfaces) > 0 else ['n/a'] + + +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() +        members = ','.join(_get_vrf_members(name)) +        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 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 as e: +        print(e) +        sys.exit(1) diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index 84d719fda..049d59de7 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -17,11 +17,14 @@ import vyos.defaults  from . graphql.queries import query  from . graphql.mutations import mutation  from . graphql.directives import directives_dict +from . utils.schema_from_op_mode import generate_op_mode_definitions  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'] +    generate_op_mode_definitions() +      type_defs = load_schema_from_path(api_schema_dir)      schema = make_executable_schema(type_defs, query, mutation, snake_case_fallback_resolvers, directives=directives_dict) diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 0a9298f55..d8ceefae6 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -48,6 +48,14 @@ class ShowConfigDirective(VyosDirective):          super().visit_field_definition(field, object_type,                                         make_resolver=make_show_config_resolver) +class SystemStatusDirective(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_system_status_resolver) +  class ConfigFileDirective(VyosDirective):      """      Class providing implementation of 'configfile' directive in schema. @@ -72,8 +80,27 @@ class ImageDirective(VyosDirective):          super().visit_field_definition(field, object_type,                                         make_resolver=make_image_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) +  directives_dict = {"configure": ConfigureDirective,                     "showconfig": ShowConfigDirective, +                   "systemstatus": SystemStatusDirective,                     "configfile": ConfigFileDirective,                     "show": ShowDirective, -                   "image": ImageDirective} +                   "image": ImageDirective, +                   "genopquery": GenOpQueryDirective, +                   "genopmutation": GenOpMutationDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 0c3eb702a..3e89fb239 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -20,6 +20,7 @@ from graphql import GraphQLResolveInfo  from makefun import with_signature  from .. import state +from .. import key_auth  from api.graphql.recipes.session import Session  mutation = ObjectType("Mutation") @@ -53,6 +54,19 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                  }              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'] +              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass @@ -61,7 +75,7 @@ def make_mutation_resolver(mutation_name, class_name, session_func):                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -97,3 +111,7 @@ def make_config_file_resolver(mutation_name):  def make_image_resolver(mutation_name):      return make_prefix_resolver(mutation_name, prefix=['add', 'delete']) + +def make_gen_op_mutation_resolver(mutation_name): +    class_name = mutation_name +    return make_mutation_resolver(mutation_name, class_name, 'gen_op_mutation') diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index e1868091e..f6544709e 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -20,6 +20,7 @@ from graphql import GraphQLResolveInfo  from makefun import with_signature  from .. import state +from .. import key_auth  from api.graphql.recipes.session import Session  query = ObjectType("Query") @@ -53,6 +54,19 @@ def make_query_resolver(query_name, class_name, session_func):                  }              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'] +              session = state.settings['app'].state.vyos_session              # one may override the session functions with a local subclass @@ -61,7 +75,7 @@ def make_query_resolver(query_name, class_name, session_func):                  klass = getattr(mod, class_name)              except ImportError:                  # otherwise, dynamically generate subclass to invoke subclass -                # name based templates +                # name based functions                  klass = type(class_name, (Session,), {})              k = klass(session, data)              method = getattr(k, session_func) @@ -84,6 +98,14 @@ def make_show_config_resolver(query_name):      class_name = query_name      return make_query_resolver(query_name, class_name, 'show_config') +def make_system_status_resolver(query_name): +    class_name = query_name +    return make_query_resolver(query_name, class_name, 'system_status') +  def make_show_resolver(query_name):      class_name = query_name      return make_query_resolver(query_name, class_name, 'show') + +def make_gen_op_query_resolver(query_name): +    class_name = query_name +    return make_query_resolver(query_name, class_name, 'gen_op_query') diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql index 31ab26b9e..a7263114b 100644 --- a/src/services/api/graphql/graphql/schema/config_file.graphql +++ b/src/services/api/graphql/graphql/schema/config_file.graphql @@ -1,4 +1,5 @@  input SaveConfigFileInput { +    key: String!      fileName: String  } @@ -13,6 +14,7 @@ type SaveConfigFileResult {  }  input LoadConfigFileInput { +    key: String!      fileName: String!  } diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql index 25f091bfa..345c349ac 100644 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -1,4 +1,5 @@  input DhcpServerConfigInput { +    key: String!      sharedNetworkName: String      subnet: String      defaultRouter: String diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql index d89904b9e..9454d2997 100644 --- a/src/services/api/graphql/graphql/schema/firewall_group.graphql +++ b/src/services/api/graphql/graphql/schema/firewall_group.graphql @@ -1,4 +1,5 @@  input CreateFirewallAddressGroupInput { +    key: String!      name: String!      address: [String]  } @@ -15,6 +16,7 @@ type CreateFirewallAddressGroupResult {  }  input UpdateFirewallAddressGroupMembersInput { +    key: String!      name: String!      address: [String!]!  } @@ -31,6 +33,7 @@ type UpdateFirewallAddressGroupMembersResult {  }  input RemoveFirewallAddressGroupMembersInput { +    key: String!      name: String!      address: [String!]!  } @@ -47,6 +50,7 @@ type RemoveFirewallAddressGroupMembersResult {  }  input CreateFirewallAddressIpv6GroupInput { +    key: String!      name: String!      address: [String]  } @@ -63,6 +67,7 @@ type CreateFirewallAddressIpv6GroupResult {  }  input UpdateFirewallAddressIpv6GroupMembersInput { +    key: String!      name: String!      address: [String!]!  } @@ -79,6 +84,7 @@ type UpdateFirewallAddressIpv6GroupMembersResult {  }  input RemoveFirewallAddressIpv6GroupMembersInput { +    key: String!      name: String!      address: [String!]!  } diff --git a/src/services/api/graphql/graphql/schema/image.graphql b/src/services/api/graphql/graphql/schema/image.graphql index 7d1b4f9d0..485033875 100644 --- a/src/services/api/graphql/graphql/schema/image.graphql +++ b/src/services/api/graphql/graphql/schema/image.graphql @@ -1,4 +1,5 @@  input AddSystemImageInput { +    key: String!      location: String!  } @@ -14,6 +15,7 @@ type AddSystemImageResult {  }  input DeleteSystemImageInput { +    key: String!      name: String!  } diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql index 32438b315..8a17d919f 100644 --- a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql +++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql @@ -1,4 +1,5 @@  input InterfaceEthernetConfigInput { +    key: String!      interface: String      address: String      replace: Boolean = true diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 952e46f34..624be2620 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -7,11 +7,17 @@ directive @configure on FIELD_DEFINITION  directive @configfile on FIELD_DEFINITION  directive @show on FIELD_DEFINITION  directive @showconfig on FIELD_DEFINITION +directive @systemstatus on FIELD_DEFINITION  directive @image on FIELD_DEFINITION +directive @genopquery on FIELD_DEFINITION +directive @genopmutation on FIELD_DEFINITION + +scalar Generic  type Query {      Show(data: ShowInput) : ShowResult @show      ShowConfig(data: ShowConfigInput) : ShowConfigResult @showconfig +    SystemStatus(data: SystemStatusInput) : SystemStatusResult @systemstatus  }  type Mutation { diff --git a/src/services/api/graphql/graphql/schema/show.graphql b/src/services/api/graphql/graphql/schema/show.graphql index c7709e48b..278ed536b 100644 --- a/src/services/api/graphql/graphql/schema/show.graphql +++ b/src/services/api/graphql/graphql/schema/show.graphql @@ -1,4 +1,5 @@  input ShowInput { +    key: String!      path: [String!]!  } diff --git a/src/services/api/graphql/graphql/schema/show_config.graphql b/src/services/api/graphql/graphql/schema/show_config.graphql index 34afd2aa9..5a1fe43da 100644 --- a/src/services/api/graphql/graphql/schema/show_config.graphql +++ b/src/services/api/graphql/graphql/schema/show_config.graphql @@ -2,9 +2,9 @@  Use 'scalar Generic' for show config output, to avoid attempts to  JSON-serialize in case of JSON output.  """ -scalar Generic  input ShowConfigInput { +    key: String!      path: [String!]!      configFormat: String  } diff --git a/src/services/api/graphql/graphql/schema/system_status.graphql b/src/services/api/graphql/graphql/schema/system_status.graphql new file mode 100644 index 000000000..be8d87535 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/system_status.graphql @@ -0,0 +1,18 @@ +""" +Use 'scalar Generic' for system status output, to avoid attempts to +JSON-serialize in case of JSON output. +""" + +input SystemStatusInput { +    key: String! +} + +type SystemStatus { +    result: Generic +} + +type SystemStatusResult { +    data: SystemStatus +    success: Boolean! +    errors: [String] +} diff --git a/src/services/api/graphql/key_auth.py b/src/services/api/graphql/key_auth.py new file mode 100644 index 000000000..f756ed6d8 --- /dev/null +++ b/src/services/api/graphql/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/recipes/queries/system_status.py b/src/services/api/graphql/recipes/queries/system_status.py new file mode 100755 index 000000000..8dadcc9f3 --- /dev/null +++ b/src/services/api/graphql/recipes/queries/system_status.py @@ -0,0 +1,38 @@ +#!/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 +import sys +import json +import importlib.util + +from vyos.defaults import directories + +from api.graphql.utils.util 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('show_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/recipes/session.py b/src/services/api/graphql/recipes/session.py index 1f844ff70..6b580af01 100644 --- a/src/services/api/graphql/recipes/session.py +++ b/src/services/api/graphql/recipes/session.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2021-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 @@ -13,15 +13,20 @@  # 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 -import vyos.defaults  from vyos.config import Config  from vyos.configtree import ConfigTree +from vyos.defaults import directories  from vyos.template import render +from api.graphql.utils.util import load_op_mode_as_module, split_compound_op_mode_name + +op_mode_include_file = os.path.join(directories['data'], 'op-mode-standardized.json') +  class Session:      """      Wrapper for calling configsession functions based on GraphQL requests. @@ -33,6 +38,12 @@ class 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 = f.read() +        except Exception: +            self._op_mode_list = None +      def configure(self):          session = self._session          data = self._data @@ -40,7 +51,7 @@ class Session:          tmpl_file = f'{func_base_name}.tmpl'          cmd_file = f'/tmp/{func_base_name}.cmds' -        tmpl_dir = vyos.defaults.directories['api_templates'] +        tmpl_dir = directories['api_templates']          try:              render(cmd_file, tmpl_file, data, location=tmpl_dir) @@ -136,3 +147,61 @@ class Session:              raise error          return res + +    def system_status(self): +        import api.graphql.recipes.queries.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, basename) = split_compound_op_mode_name(name, op_mode_list) +        if basename == '': +            raise FileNotFoundError(f"No op-mode file basename in string '{name}'") + +        mod = load_op_mode_as_module(f'{basename}.py') +        func = getattr(mod, func_name) +        if len(list(data)) > 0: +            res = func(True, **data) +        else: +            res = func(True) + +        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, basename) = split_compound_op_mode_name(name, op_mode_list) +        if basename == '': +            raise FileNotFoundError(f"No op-mode file basename in string '{name}'") + +        mod = load_op_mode_as_module(f'{basename}.py') +        func = getattr(mod, func_name) +        if len(list(data)) > 0: +            res = func(**data) +        else: +            res = func() + +        return res diff --git a/src/services/api/graphql/utils/schema_from_op_mode.py b/src/services/api/graphql/utils/schema_from_op_mode.py new file mode 100755 index 000000000..cdde5f187 --- /dev/null +++ b/src/services/api/graphql/utils/schema_from_op_mode.py @@ -0,0 +1,161 @@ +#!/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/>. +# +# +# A utility to generate GraphQL schema defintions from standardized op-mode +# scripts. + +import os +import json +import typing +from inspect import signature, getmembers, isfunction +from jinja2 import Template + +from vyos.defaults import directories +from . util import load_as_module, is_op_mode_function_name, is_show_function_name + +OP_MODE_PATH = directories['op_mode'] +SCHEMA_PATH = directories['api_schema'] +DATA_DIR = directories['data'] + +op_mode_include_file = os.path.join(DATA_DIR, 'op-mode-standardized.json') + +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 @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 }} +    success: Boolean! +    errors: [String] +} + +extend type Mutation { +    {{ schema_name }}(data: {{ schema_name }}Input) : {{ schema_name }}Result @genopmutation +} +""" + +def _snake_to_pascal_case(name: str) -> str: +    res = ''.join(map(str.title, name.split('_'))) +    return res + +def _map_type_name(type_name: type, 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!' if not optional else 'Boolean = false' +    if typing.get_origin(type_name) == list: +        if not optional: +            return f'[{_map_type_name(typing.get_args(type_name)[0])}]!' +        return f'[{_map_type_name(typing.get_args(type_name)[0])}]' +    # 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], optional=True)}' + +    # scalar 'Generic' is defined in schema.graphql +    return 'Generic' + +def create_schema(func_name: str, base_name: str, func: callable) -> str: +    sig = signature(func) + +    field_dict = {} +    for k in sig.parameters: +        field_dict[sig.parameters[k].name] = _map_type_name(sig.parameters[k].annotation) + +    # 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 generate_op_mode_definitions(): +    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] +        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 + +        results = [] +        for name,func in funcs_dict.items(): +            res = create_schema(name, basename, func) +            results.append(res) + +        out = '\n'.join(results) +        with open(f'{SCHEMA_PATH}/{basename}.graphql', 'w') as f: +            f.write(out) + +if __name__ == '__main__': +    generate_op_mode_definitions() diff --git a/src/services/api/graphql/utils/util.py b/src/services/api/graphql/utils/util.py new file mode 100644 index 000000000..e3dea31bf --- /dev/null +++ b/src/services/api/graphql/utils/util.py @@ -0,0 +1,55 @@ +# Copyright 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 os +import re +import importlib.util + +from vyos.defaults import directories + +def load_as_module(name: str, path: str): +    spec = importlib.util.spec_from_file_location(name, path) +    mod = importlib.util.module_from_spec(spec) +    spec.loader.exec_module(mod) +    return mod + +def load_op_mode_as_module(name: str): +    path = os.path.join(directories['op_mode'], name) +    name = os.path.splitext(name)[0] +    return load_as_module(name, path) + +def is_op_mode_function_name(name): +    if re.match(r"^(show|clear|reset|restart)", name): +        return True +    return False + +def is_show_function_name(name): +    if re.match(r"^show", name): +        return True +    return False + +def _nth_rsplit(delim: str, n: int, s: str): +    groups = s.split(delim) +    l = len(groups) +    if n > l-1: +        return ('', s) +    return (delim.join(groups[:l-n]), delim.join(groups[l-n:])) + +def split_compound_op_mode_name(name: str, files: list): +    for i in range(1, name.count('_') + 1): +        pair = _nth_rsplit('_', i, name) +        if pair[1] in files: +            return pair +    return (name, '') diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index e9b904ba8..af8837e1e 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -654,11 +654,13 @@ def graphql_init(fast_api_app):      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, debug=True), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS"))) +        app.add_route('/graphql', CORSMiddleware(GraphQL(schema, debug=True, introspection=in_spec), allow_origins=origins, allow_methods=("GET", "POST", "OPTIONS")))      else: -        app.add_route('/graphql', GraphQL(schema, debug=True)) +        app.add_route('/graphql', GraphQL(schema, debug=True, introspection=in_spec))  ### @@ -684,6 +686,7 @@ if __name__ == '__main__':      app.state.vyos_debug = server_config['debug']      app.state.vyos_gql = server_config['gql'] +    app.state.vyos_introspection = server_config['introspection']      app.state.vyos_strict = server_config['strict']      app.state.vyos_origins = server_config.get('cors', {}).get('origins', []) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 7e0bee8e1..93bebd9d9 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -1,12 +1,10 @@  [Unit] -Description=WPA supplicant daemon (macsec-specific version) +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 -# NetworkManager users will probably want the dbus version instead. -  [Service]  Type=simple  WorkingDirectory=/run/wpa_supplicant | 
