diff options
54 files changed, 1523 insertions, 82 deletions
| diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json index 3205c7d20..abf562984 100644 --- a/data/op-mode-standardized.json +++ b/data/op-mode-standardized.json @@ -2,6 +2,7 @@  "accelppp.py",  "bgp.py",  "bridge.py", +"config_mgmt.py",  "conntrack.py",  "container.py",  "cpu.py", diff --git a/data/templates/high-availability/keepalived.conf.j2 b/data/templates/high-availability/keepalived.conf.j2 index ebff52e1f..828636dc0 100644 --- a/data/templates/high-availability/keepalived.conf.j2 +++ b/data/templates/high-availability/keepalived.conf.j2 @@ -5,6 +5,9 @@  global_defs {      dynamic_interfaces      script_user root +{% if vrrp.global_parameters.startup_delay is vyos_defined %} +    vrrp_startup_delay {{ vrrp.global_parameters.startup_delay }} +{% endif %}      notify_fifo /run/keepalived/keepalived_notify_fifo      notify_fifo_script /usr/libexec/vyos/system/keepalived-fifo.py  } diff --git a/data/templates/pppoe/peer.j2 b/data/templates/pppoe/peer.j2 index 6221abb9b..f433a9b03 100644 --- a/data/templates/pppoe/peer.j2 +++ b/data/templates/pppoe/peer.j2 @@ -36,10 +36,13 @@ maxfail 0  plugin rp-pppoe.so {{ source_interface }}  {% if access_concentrator is vyos_defined %} -rp_pppoe_ac '{{ access_concentrator }}' +pppoe-ac "{{ access_concentrator }}"  {% endif %}  {% if service_name is vyos_defined %} -rp_pppoe_service '{{ service_name }}' +pppoe-service "{{ service_name }}" +{% endif %} +{% if host_uniq is vyos_defined %} +pppoe-host-uniq "{{ host_uniq }}"  {% endif %}  persist diff --git a/data/templates/snmp/etc.snmpd.conf.j2 b/data/templates/snmp/etc.snmpd.conf.j2 index a9bbf68ce..793facc3f 100644 --- a/data/templates/snmp/etc.snmpd.conf.j2 +++ b/data/templates/snmp/etc.snmpd.conf.j2 @@ -62,28 +62,47 @@ agentaddress unix:/run/snmpd.socket{{ ',' ~ options | join(',') if options is vy  {%         if comm_config.client is vyos_defined %}  {%             for client in comm_config.client %}  {%                 if client | is_ipv4 %} -{{ comm_config.authorization }}community {{ comm }} {{ client }} +{{ comm_config.authorization }}community {{ comm }} {{ client }} -V RESTRICTED  {%                 elif client | is_ipv6 %} -{{ comm_config.authorization }}community6 {{ comm }} {{ client }} +{{ comm_config.authorization }}community6 {{ comm }} {{ client }} -V RESTRICTED  {%                 endif %}  {%             endfor %}  {%         endif %}  {%         if comm_config.network is vyos_defined %}  {%             for network in comm_config.network %}  {%                 if network | is_ipv4 %} -{{ comm_config.authorization }}community {{ comm }} {{ network }} +{{ comm_config.authorization }}community {{ comm }} {{ network }} -V RESTRICTED  {%                 elif network | is_ipv6 %} -{{ comm_config.authorization }}community6 {{ comm }} {{ network }} +{{ comm_config.authorization }}community6 {{ comm }} {{ network }} -V RESTRICTED  {%                 endif %}  {%             endfor %}  {%         endif %}  {%         if comm_config.client is not vyos_defined and comm_config.network is not vyos_defined %} -{{ comm_config.authorization }}community {{ comm }} -{{ comm_config.authorization }}community6 {{ comm }} +{{ comm_config.authorization }}community {{ comm }} -V RESTRICTED +{{ comm_config.authorization }}community6 {{ comm }} -V RESTRICTED  {%         endif %}  {%     endfor %}  {% endif %} +# Default RESTRICTED view +view RESTRICTED    included .1 80 +{% if 'ip-route-table' not in oid_enable %} +# ipRouteTable oid: excluded +view RESTRICTED    excluded  .1.3.6.1.2.1.4.21 +{% endif %} +{% if 'ip-net-to-media-table' not in oid_enable %} +# ipNetToMediaTable oid: excluded +view RESTRICTED    excluded  .1.3.6.1.2.1.4.22 +{% endif %} +{% if 'ip-net-to-physical-phys-address' not in oid_enable %} +# ipNetToPhysicalPhysAddress oid: excluded +view RESTRICTED    excluded  .1.3.6.1.2.1.4.35 +{% endif %} +{% if 'ip-forward' not in oid_enable %} +# ipForward oid: excluded +view RESTRICTED    excluded  .1.3.6.1.2.1.4.24 +{% endif %} +  {% if contact is vyos_defined %}  # system contact information  SysContact {{ contact }} diff --git a/data/templates/snmp/override.conf.j2 b/data/templates/snmp/override.conf.j2 index 5d787de86..443ee64db 100644 --- a/data/templates/snmp/override.conf.j2 +++ b/data/templates/snmp/override.conf.j2 @@ -1,5 +1,4 @@  {% set vrf_command = 'ip vrf exec ' ~ vrf ~ ' ' if vrf is vyos_defined else '' %} -{% set oid_route_table = ' ' if oid_enable is vyos_defined('route-table') else '-I -ipCidrRouteTable,inetCidrRouteTable' %}  [Unit]  StartLimitIntervalSec=0  After=vyos-router.service @@ -8,7 +7,7 @@ After=vyos-router.service  Environment=  Environment="MIBDIRS=/usr/share/snmp/mibs:/usr/share/snmp/mibs/iana:/usr/share/snmp/mibs/ietf:/usr/share/vyos/mibs"  ExecStart= -ExecStart={{ vrf_command }}/usr/sbin/snmpd -LS0-5d -Lf /dev/null -u Debian-snmp -g Debian-snmp {{ oid_route_table }} -f -p /run/snmpd.pid +ExecStart={{ vrf_command }}/usr/sbin/snmpd -LS0-5d -Lf /dev/null -u Debian-snmp -g Debian-snmp -f -p /run/snmpd.pid  Restart=always  RestartSec=10 diff --git a/debian/vyos-1x.install b/debian/vyos-1x.install index a54b3b506..11b488b22 100644 --- a/debian/vyos-1x.install +++ b/debian/vyos-1x.install @@ -18,6 +18,7 @@ etc/vyos  lib/  opt/  usr/sbin +usr/bin/config-mgmt  usr/bin/initial-setup  usr/bin/vyos-config-file-query  usr/bin/vyos-config-to-commands diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index 4bac305d1..b61664125 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -274,6 +274,26 @@                    </valueHelp>                  </properties>                </leafNode> +              <leafNode name="mode"> +                <properties> +                  <help>Volume access mode ro/rw</help> +                  <completionHelp> +                    <list>ro rw</list> +                  </completionHelp> +                  <valueHelp> +                    <format>ro</format> +                    <description>Volume mounted into the container as read-only</description> +                  </valueHelp> +                  <valueHelp> +                    <format>rw</format> +                    <description>Volume mounted into the container as read-write</description> +                  </valueHelp> +                  <constraint> +                    <regex>(ro|rw)</regex> +                  </constraint> +                </properties> +                <defaultValue>rw</defaultValue> +              </leafNode>              </children>            </tagNode>          </children> diff --git a/interface-definitions/high-availability.xml.in b/interface-definitions/high-availability.xml.in index d67a142d1..0906356a3 100644 --- a/interface-definitions/high-availability.xml.in +++ b/interface-definitions/high-availability.xml.in @@ -11,6 +11,25 @@            <help>Virtual Router Redundancy Protocol settings</help>          </properties>          <children> +          <node name="global-parameters"> +            <properties> +              <help>VRRP global parameters</help> +            </properties> +            <children> +              <leafNode name="startup-delay"> +                <properties> +                  <help>Time VRRP startup process (in seconds)</help> +                  <valueHelp> +                    <format>u32:1-600</format> +                    <description>Interval in seconds</description> +                  </valueHelp> +                  <constraint> +                    <validator name="numeric" argument="--range 1-600"/> +                  </constraint> +                </properties> +              </leafNode> +            </children> +          </node>            <tagNode name="group">              <properties>                <help>VRRP group</help> @@ -211,16 +230,15 @@                  <properties>                    <help>Virtual IP address</help>                    <valueHelp> -                    <format>ipv4</format> -                    <description>IPv4 virtual address</description> +                    <format>ipv4net</format> +                    <description>IPv4 address and prefix length</description>                    </valueHelp>                    <valueHelp> -                    <format>ipv6</format> -                    <description>IPv6 virtual address</description> +                    <format>ipv6net</format> +                    <description>IPv6 address and prefix length</description>                    </valueHelp>                    <constraint> -                    <validator name="ipv4-host"/> -                    <validator name="ipv6-host"/> +                    <validator name="ip-host"/>                    </constraint>                  </properties>                  <children> diff --git a/interface-definitions/include/listen-address-ipv4-single.xml.i b/interface-definitions/include/listen-address-ipv4-single.xml.i new file mode 100644 index 000000000..81e947953 --- /dev/null +++ b/interface-definitions/include/listen-address-ipv4-single.xml.i @@ -0,0 +1,17 @@ +<!-- include start from listen-address-ipv4-single.xml.i --> +<leafNode name="listen-address"> +  <properties> +    <help>Local IPv4 addresses to listen on</help> +    <completionHelp> +      <script>${vyos_completion_dir}/list_local_ips.sh --ipv4</script> +    </completionHelp> +    <valueHelp> +      <format>ipv4</format> +      <description>IPv4 address to listen for incoming connections</description> +    </valueHelp> +    <constraint> +      <validator name="ipv4-address"/> +    </constraint> +  </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/version/snmp-version.xml.i b/interface-definitions/include/version/snmp-version.xml.i index 0416288f0..fa58672a5 100644 --- a/interface-definitions/include/version/snmp-version.xml.i +++ b/interface-definitions/include/version/snmp-version.xml.i @@ -1,3 +1,3 @@  <!-- include start from include/version/snmp-version.xml.i --> -<syntaxVersion component='snmp' version='2'></syntaxVersion> +<syntaxVersion component='snmp' version='3'></syntaxVersion>  <!-- include end --> diff --git a/interface-definitions/interfaces-pppoe.xml.in b/interface-definitions/interfaces-pppoe.xml.in index 490f41471..c6fd7096b 100644 --- a/interface-definitions/interfaces-pppoe.xml.in +++ b/interface-definitions/interfaces-pppoe.xml.in @@ -37,6 +37,19 @@                <constraintErrorMessage>Timeout must be in range 0 to 86400</constraintErrorMessage>              </properties>            </leafNode> +          <leafNode name="host-uniq"> +            <properties> +              <help>PPPoE RFC2516 host-uniq tag</help> +              <valueHelp> +                <format>txt</format> +                <description>Host-uniq tag as byte string in HEX</description> +              </valueHelp> +              <constraint> +                <regex>([a-fA-F0-9][a-fA-F0-9]){1,18}</regex> +              </constraint> +              <constraintErrorMessage>Host-uniq must be specified as hex-adecimal byte-string (even number of HEX characters)</constraintErrorMessage> +            </properties> +          </leafNode>            <node name="ip">              <properties>                <help>IPv4 routing parameters</help> diff --git a/interface-definitions/snmp.xml.in b/interface-definitions/snmp.xml.in index 7ec60b2e7..10dd828a5 100644 --- a/interface-definitions/snmp.xml.in +++ b/interface-definitions/snmp.xml.in @@ -123,18 +123,31 @@            </leafNode>            <leafNode name="oid-enable">              <properties> -              <help>Enable specific OIDs</help> +              <help>Enable specific OIDs that by default are disable</help>                <completionHelp> -                <list>route-table</list> +                <list>ip-forward ip-route-table ip-net-to-media-table ip-net-to-physical-phys-address</list>                </completionHelp>                <valueHelp> -                <format>route-table</format> -                <description>Enable routing table OIDs (ipCidrRouteTable inetCidrRouteTable)</description> +                <format>ip-forward</format> +                <description>Enable ipForward: .1.3.6.1.2.1.4.24</description> +              </valueHelp> +              <valueHelp> +                <format>ip-route-table</format> +                <description>Enable ipRouteTable: .1.3.6.1.2.1.4.21</description> +              </valueHelp> +              <valueHelp> +                <format>ip-net-to-media-table</format> +                <description>Enable ipNetToMediaTable: .1.3.6.1.2.1.4.22</description> +              </valueHelp> +              <valueHelp> +                <format>ip-net-to-physical-phys-address</format> +                <description>Enable ipNetToPhysicalPhysAddress: .1.3.6.1.2.1.4.35</description>                </valueHelp>                <constraint> -                <regex>(route-table)</regex> +                <regex>(ip-forward|ip-route-table|ip-net-to-media-table|ip-net-to-physical-phys-address)</regex>                </constraint> -              <constraintErrorMessage>OID must be 'route-table'</constraintErrorMessage> +              <constraintErrorMessage>OID must be one of the liste options</constraintErrorMessage> +              <multi/>              </properties>            </leafNode>            #include <include/snmp/protocol.xml.i> diff --git a/interface-definitions/system-config-mgmt.xml.in b/interface-definitions/system-config-mgmt.xml.in new file mode 100644 index 000000000..91caed01a --- /dev/null +++ b/interface-definitions/system-config-mgmt.xml.in @@ -0,0 +1,57 @@ +<?xml version="1.0"?> +<interfaceDefinition> +  <node name="system"> +    <children> +      <node name="config-management" owner="${vyos_conf_scripts_dir}/config_mgmt.py"> +        <properties> +          <help>Configuration management settings</help> +        </properties> +        <children> +          <node name="commit-archive"> +            <properties> +              <help>Commit archive settings</help> +            </properties> +            <children> +              <leafNode name="location"> +                <properties> +                  <help>Commit archive location</help> +                  <valueHelp> +                    <format>uri</format> +                    <description>Uniform Resource Identifier</description> +                  </valueHelp> +                  <constraint> +                    <validator name="url --file-transport"/> +                  </constraint> +                  <multi/> +                </properties> +              </leafNode> +              <leafNode name="source-address"> +                <properties> +                  <help>Source address or interface for archive server connections</help> +                  <constraint> +                    <validator name="ipv4-address"/> +                    <validator name="ipv6-address"/> +                    #include <include/constraint/interface-name.xml.in> +                  </constraint> +                </properties> +              </leafNode> +            </children> +          </node> +          <leafNode name="commit-revisions"> +            <properties> +              <help>Commit revisions</help> +              <valueHelp> +                <format>u32:1-65535</format> +                <description>Number of config backups to keep</description> +              </valueHelp> +              <constraint> +                <validator name="numeric" argument="--range 1-65535"/> +              </constraint> +              <constraintErrorMessage>Number of revisions must be between 0 and 65535</constraintErrorMessage> +            </properties> +          </leafNode> +        </children> +      </node> +    </children> +  </node> +</interfaceDefinition> diff --git a/interface-definitions/vpn-ipsec.xml.in b/interface-definitions/vpn-ipsec.xml.in index fd74a51d7..fa12d999c 100644 --- a/interface-definitions/vpn-ipsec.xml.in +++ b/interface-definitions/vpn-ipsec.xml.in @@ -465,6 +465,45 @@                      </properties>                      <defaultValue>2</defaultValue>                    </leafNode> +                  <leafNode name="prf"> +                    <properties> +                      <help>Pseudo-Random Functions</help> +                      <completionHelp> +                        <list>prfmd5 prfsha1 prfaesxcbc prfaescmac prfsha256 prfsha384 prfsha512</list> +                      </completionHelp> +                      <valueHelp> +                        <format>prfmd5</format> +                        <description>MD5 PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfsha1</format> +                        <description>SHA1 PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfaesxcbc</format> +                        <description>AES XCBC PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfaescmac</format> +                        <description>AES CMAC PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfsha256</format> +                        <description>SHA2_256 PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfsha384</format> +                        <description>SHA2_384 PRF</description> +                      </valueHelp> +                      <valueHelp> +                        <format>prfsha512</format> +                        <description>SHA2_512 PRF</description> +                      </valueHelp> +                      <constraint> +                        <regex>(prfmd5|prfsha1|prfaesxcbc|prfaescmac|prfsha256|prfsha384|prfsha512)</regex> +                      </constraint> +                    </properties> +                  </leafNode>                    #include <include/vpn-ipsec-encryption.xml.i>                    #include <include/vpn-ipsec-hash.xml.i>                  </children> diff --git a/interface-definitions/vpn-openconnect.xml.in b/interface-definitions/vpn-openconnect.xml.in index 8b60f2e6e..82fe2bbc9 100644 --- a/interface-definitions/vpn-openconnect.xml.in +++ b/interface-definitions/vpn-openconnect.xml.in @@ -150,7 +150,7 @@                </node>              </children>            </node> -          #include <include/listen-address-ipv4.xml.i> +          #include <include/listen-address-ipv4-single.xml.i>            <leafNode name="listen-address">              <defaultValue>0.0.0.0</defaultValue>            </leafNode> diff --git a/op-mode-definitions/generate-interfaces-debug-archive.xml.in b/op-mode-definitions/generate-interfaces-debug-archive.xml.in new file mode 100644 index 000000000..68eefc348 --- /dev/null +++ b/op-mode-definitions/generate-interfaces-debug-archive.xml.in @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<interfaceDefinition> +  <node name="generate"> +    <children> +      <node name="interfaces"> +        <children> +          <node name="debug-archive"> +            <properties> +              <help>Generate interfaces debug-archive</help> +            </properties> +            <command>${vyos_op_scripts_dir}/generate_interfaces_debug_archive.py</command> +          </node> +        </children> +      </node> +    </children> +  </node> +</interfaceDefinition> diff --git a/op-mode-definitions/generate-openvpn-config-client.xml.in b/op-mode-definitions/generate-openvpn-config-client.xml.in index 4f9f31bfe..baec0842b 100644 --- a/op-mode-definitions/generate-openvpn-config-client.xml.in +++ b/op-mode-definitions/generate-openvpn-config-client.xml.in @@ -16,7 +16,7 @@                  <properties>                    <help>Local interface used for connection</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py --type openvpn</script> +                    <path>interfaces openvpn</path>                    </completionHelp>                  </properties>                  <children> diff --git a/op-mode-definitions/generate-wireguard.xml.in b/op-mode-definitions/generate-wireguard.xml.in index 0ef983cd2..6c01619be 100644 --- a/op-mode-definitions/generate-wireguard.xml.in +++ b/op-mode-definitions/generate-wireguard.xml.in @@ -19,7 +19,7 @@                  <properties>                    <help>Local interface used for connection</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py --type wireguard</script> +                    <path>interfaces wireguard</path>                    </completionHelp>                  </properties>                  <children> diff --git a/op-mode-definitions/ipv6-route.xml.in b/op-mode-definitions/ipv6-route.xml.in index d75caf308..46e416a8a 100644 --- a/op-mode-definitions/ipv6-route.xml.in +++ b/op-mode-definitions/ipv6-route.xml.in @@ -26,7 +26,7 @@                  <properties>                    <help>Show IPv6 neighbor table for specified interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -b</script> +                    <script>${vyos_completion_dir}/list_interfaces.py --broadcast</script>                    </completionHelp>                  </properties>                  <command>${vyos_op_scripts_dir}/neighbor.py show --family inet6 --interface "$5"</command> diff --git a/op-mode-definitions/monitor-log.xml.in b/op-mode-definitions/monitor-log.xml.in index b68047bb9..ec428a676 100644 --- a/op-mode-definitions/monitor-log.xml.in +++ b/op-mode-definitions/monitor-log.xml.in @@ -93,6 +93,12 @@              </properties>              <command>journalctl --no-hostname --boot --follow --unit uacctd.service</command>            </leafNode> +          <leafNode name="ipoe-server"> +            <properties> +              <help>Monitor last lines of IPoE server log</help> +            </properties> +            <command>journalctl --no-hostname --boot --follow --unit accel-ppp@ipoe.service</command> +          </leafNode>            <leafNode name="kernel">              <properties>                <help>Monitor last lines of Linux Kernel log</help> @@ -113,7 +119,7 @@            </leafNode>            <node name="pppoe">              <properties> -              <help>Monitor last lines of PPPoE log</help> +              <help>Monitor last lines of PPPoE interface log</help>              </properties>              <command>journalctl --no-hostname --boot --follow --unit "ppp@pppoe*.service"</command>              <children> @@ -121,13 +127,19 @@                  <properties>                    <help>Monitor last lines of PPPoE log for specific interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t pppoe</script> +                    <path>interfaces pppoe</path>                    </completionHelp>                  </properties>                  <command>journalctl --no-hostname --boot --follow --unit "ppp@$5.service"</command>                </tagNode>              </children>            </node> +          <leafNode name="pppoe-server"> +            <properties> +              <help>Monitor last lines of PPPoE server log</help> +            </properties> +            <command>journalctl --no-hostname --boot --follow --unit accel-ppp@pppoe.service</command> +          </leafNode>            <node name="protocol">              <properties>                <help>Monitor log for Routing Protocol</help> @@ -211,7 +223,7 @@                  <properties>                    <help>Monitor last lines of specific MACsec interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t macsec</script> +                    <path>interfaces macsec</path>                    </completionHelp>                  </properties>                  <command>SRC=$(cli-shell-api returnValue interfaces macsec "$5" source-interface); journalctl --no-hostname --boot --follow --unit "wpa_supplicant-macsec@$SRC.service"</command> @@ -246,7 +258,7 @@                  <properties>                    <help>Monitor last lines of SSTP client log for specific interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t sstpc</script> +                    <path>interfaces sstpc</path>                    </completionHelp>                  </properties>                  <command>journalctl --no-hostname --boot --follow --unit "ppp@$5.service"</command> diff --git a/op-mode-definitions/openvpn.xml.in b/op-mode-definitions/openvpn.xml.in index b2763da81..0a2657398 100644 --- a/op-mode-definitions/openvpn.xml.in +++ b/op-mode-definitions/openvpn.xml.in @@ -20,7 +20,7 @@              <properties>                <help>Reset OpenVPN process on interface</help>                <completionHelp> -                <script>sudo ${vyos_completion_dir}/list_interfaces.py --type openvpn</script> +                <path>interfaces openvpn</path>                </completionHelp>              </properties>              <command>sudo ${vyos_op_scripts_dir}/openvpn.py reset --interface $4</command> @@ -51,7 +51,7 @@              <properties>                <help>Show OpenVPN interface information</help>                <completionHelp> -                <script>sudo ${vyos_completion_dir}/list_interfaces.py --type openvpn</script> +                <path>interfaces openvpn</path>                </completionHelp>              </properties>              <command>${vyos_op_scripts_dir}/interfaces.py show --intf_name=$4</command> diff --git a/op-mode-definitions/show-arp.xml.in b/op-mode-definitions/show-arp.xml.in index 8662549fc..3680c20c6 100644 --- a/op-mode-definitions/show-arp.xml.in +++ b/op-mode-definitions/show-arp.xml.in @@ -12,7 +12,7 @@              <properties>                <help>Show Address Resolution Protocol (ARP) cache for specified interface</help>                <completionHelp> -                <script>${vyos_completion_dir}/list_interfaces.py -b</script> +                <script>${vyos_completion_dir}/list_interfaces.py --broadcast</script>                </completionHelp>              </properties>              <command>${vyos_op_scripts_dir}/neighbor.py show --family inet --interface "$4"</command> diff --git a/op-mode-definitions/show-bridge.xml.in b/op-mode-definitions/show-bridge.xml.in index dd2a28931..e7a646fdc 100644 --- a/op-mode-definitions/show-bridge.xml.in +++ b/op-mode-definitions/show-bridge.xml.in @@ -25,7 +25,7 @@          <properties>            <help>Show bridge information for a given bridge interface</help>            <completionHelp> -            <script>${vyos_completion_dir}/list_interfaces.py --type bridge</script> +            <path>interfaces bridge</path>            </completionHelp>          </properties>          <command>bridge -c link show | grep "master $3"</command> diff --git a/op-mode-definitions/show-interfaces-wireguard.xml.in b/op-mode-definitions/show-interfaces-wireguard.xml.in index eba8de568..75b0cc88e 100644 --- a/op-mode-definitions/show-interfaces-wireguard.xml.in +++ b/op-mode-definitions/show-interfaces-wireguard.xml.in @@ -8,7 +8,7 @@              <properties>                <help>Show specified WireGuard interface information</help>                <completionHelp> -                <script>${vyos_completion_dir}/list_interfaces.py --type wireguard</script> +                <path>interfaces wireguard</path>                </completionHelp>              </properties>  	        <command>${vyos_op_scripts_dir}/interfaces.py show --intf_name="$4" --intf_type=wireguard</command> diff --git a/op-mode-definitions/show-interfaces-wireless.xml.in b/op-mode-definitions/show-interfaces-wireless.xml.in index b0a272225..cdd591f82 100644 --- a/op-mode-definitions/show-interfaces-wireless.xml.in +++ b/op-mode-definitions/show-interfaces-wireless.xml.in @@ -28,7 +28,7 @@              <properties>                <help>Show specified wireless interface information</help>                <completionHelp> -                <script>${vyos_completion_dir}/list_interfaces.py --type wireless</script> +                <path>interfaces wireless</path>                </completionHelp>              </properties>              <command>${vyos_op_scripts_dir}/interfaces.py show --intf_name="$4" --intf_type=wireless</command> diff --git a/op-mode-definitions/show-ip.xml.in b/op-mode-definitions/show-ip.xml.in index 0751c50cb..a710e33d2 100644 --- a/op-mode-definitions/show-ip.xml.in +++ b/op-mode-definitions/show-ip.xml.in @@ -17,7 +17,7 @@                  <properties>                    <help>Show IPv4 neighbor table for specified interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -b</script> +                    <script>${vyos_completion_dir}/list_interfaces.py --broadcast</script>                    </completionHelp>                  </properties>                  <command>${vyos_op_scripts_dir}/neighbor.py show --family inet --interface "$5"</command> diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index 8114f7377..f5e5b1493 100644 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -196,6 +196,12 @@                </tagNode>              </children>            </tagNode> +          <leafNode name="ipoe-server"> +            <properties> +              <help>Show log for IPoE server</help> +            </properties> +            <command>journalctl --no-hostname --boot --unit accel-ppp@ipoe.service</command> +          </leafNode>            <leafNode name="kernel">              <properties>                <help>Show log for Linux Kernel</help> @@ -236,7 +242,7 @@                  <properties>                    <help>Show MACsec log on specific interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t macsec</script> +                    <path>interfaces macsec</path>                    </completionHelp>                  </properties>                  <command>SRC=$(cli-shell-api returnValue interfaces macsec "$5" source-interface); journalctl --no-hostname --boot --unit "wpa_supplicant-macsec@$SRC.service"</command> @@ -262,7 +268,7 @@            </node>            <node name="pppoe">              <properties> -              <help>Show log for PPPoE</help> +              <help>Show log for PPPoE interface</help>              </properties>              <command>journalctl --no-hostname --boot --unit "ppp@pppoe*.service"</command>              <children> @@ -270,13 +276,19 @@                  <properties>                    <help>Show PPPoE log on specific interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t pppoe</script> +                    <path>interfaces pppoe</path>                    </completionHelp>                  </properties>                  <command>journalctl --no-hostname --boot --unit "ppp@$5.service"</command>                </tagNode>              </children>            </node> +          <leafNode name="pppoe-server"> +            <properties> +              <help>Show log for PPPoE server</help> +            </properties> +            <command>journalctl --no-hostname --boot --unit accel-ppp@pppoe.service</command> +          </leafNode>            <node name="protocol">              <properties>                <help>Show log for Routing Protocol</help> @@ -378,7 +390,7 @@                  <properties>                    <help>Show SSTP client log on specific interface</help>                    <completionHelp> -                    <script>${vyos_completion_dir}/list_interfaces.py -t sstpc</script> +                    <path>interfaces sstpc</path>                    </completionHelp>                  </properties>                  <command>journalctl --no-hostname --boot --unit "ppp@$5.service"</command> diff --git a/op-mode-definitions/show-protocols.xml.in b/op-mode-definitions/show-protocols.xml.in index 698001b76..27146f90d 100644 --- a/op-mode-definitions/show-protocols.xml.in +++ b/op-mode-definitions/show-protocols.xml.in @@ -22,7 +22,7 @@                      <properties>                        <help>Show Address Resolution Protocol (ARP) cache for specified interface</help>                        <completionHelp> -                        <script>${vyos_completion_dir}/list_interfaces.py -b</script> +                        <script>${vyos_completion_dir}/list_interfaces.py --broadcast</script>                        </completionHelp>                      </properties>                      <command>/usr/sbin/arp -e -n -i "$6"</command> diff --git a/op-mode-definitions/show-system.xml.in b/op-mode-definitions/show-system.xml.in index 4a0e6c3b2..85bfdcdba 100644 --- a/op-mode-definitions/show-system.xml.in +++ b/op-mode-definitions/show-system.xml.in @@ -7,6 +7,42 @@            <help>Show system information</help>          </properties>          <children> +          <node name="commit"> +            <properties> +              <help>Show commit revision log</help> +            </properties> +            <command>${vyos_op_scripts_dir}/config_mgmt.py show_commit_log</command> +            <children> +              <tagNode name="diff"> +                <properties> +                  <help>Show commit revision diff</help> +                </properties> +                <command>${vyos_op_scripts_dir}/config_mgmt.py show_commit_diff --rev "$5"</command> +              </tagNode> +              <tagNode name="file"> +                <properties> +                  <help>Show commit revision file</help> +                </properties> +                <command>${vyos_op_scripts_dir}/config_mgmt.py show_commit_file --rev "$5"</command> +                <children> +                  <tagNode name="compare"> +                    <properties> +                      <help>Compare config file revisions</help> +                    </properties> +                    <command>${vyos_op_scripts_dir}/config_mgmt.py show_commit_diff --rev "$5" --rev2 "$7"</command> +                    <children> +                      <leafNode name="commands"> +                        <properties> +                          <help>Compare config file revision commands</help> +                        </properties> +                        <command>${vyos_op_scripts_dir}/config_mgmt.py show_commit_diff --rev "$5" --rev2 "$7" --commands</command> +                      </leafNode> +                    </children> +                  </tagNode> +                </children> +              </tagNode> +            </children> +          </node>            <node name="connections">              <properties>                <help>Show active network connections on the system</help> diff --git a/op-mode-definitions/wireless.xml.in b/op-mode-definitions/wireless.xml.in index 5d9db1544..f8e53ad21 100644 --- a/op-mode-definitions/wireless.xml.in +++ b/op-mode-definitions/wireless.xml.in @@ -21,7 +21,7 @@              <properties>                <help>Clear interface information for a given wireless interface</help>                <completionHelp> -                <script>${vyos_completion_dir}/list_interfaces.py --type wireless</script> +                <path>interfaces wireless</path>                </completionHelp>              </properties>              <children> diff --git a/python/setup.py b/python/setup.py index e2d28bd6b..2d614e724 100644 --- a/python/setup.py +++ b/python/setup.py @@ -24,4 +24,9 @@ setup(          "Topic :: Utilities",          "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)",      ], +    entry_points={ +        "console_scripts": [ +            "config-mgmt = vyos.config_mgmt:run", +        ], +    },  ) diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py new file mode 100644 index 000000000..8ec73ac28 --- /dev/null +++ b/python/vyos/config_mgmt.py @@ -0,0 +1,674 @@ +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library.  If not, see <http://www.gnu.org/licenses/>. + +import os +import re +import sys +import gzip +import logging +from typing import Optional, Tuple, Union +from filecmp import cmp +from datetime import datetime +from tabulate import tabulate + +from vyos.config import Config +from vyos.configtree import ConfigTree +from vyos.defaults import directories +from vyos.util import is_systemd_service_active, ask_yes_no, rc_cmd + +SAVE_CONFIG = '/opt/vyatta/sbin/vyatta-save-config.pl' + +# created by vyatta-cfg-postinst +commit_post_hook_dir = '/etc/commit/post-hooks.d' + +commit_hooks = {'commit_revision': '01vyos-commit-revision', +                'commit_archive': '02vyos-commit-archive'} + +DEFAULT_TIME_MINUTES = 10 +timer_name = 'commit-confirm' + +config_file = os.path.join(directories['config'], 'config.boot') +archive_dir = os.path.join(directories['config'], 'archive') +archive_config_file = os.path.join(archive_dir, 'config.boot') +commit_log_file = os.path.join(archive_dir, 'commits') +logrotate_conf = os.path.join(archive_dir, 'lr.conf') +logrotate_state = os.path.join(archive_dir, 'lr.state') +rollback_config = os.path.join(archive_dir, 'config.boot-rollback') +prerollback_config = os.path.join(archive_dir, 'config.boot-prerollback') +tmp_log_entry = '/tmp/commit-rev-entry' + +logger = logging.getLogger('config_mgmt') +logger.setLevel(logging.INFO) +ch = logging.StreamHandler() +formatter = logging.Formatter('%(funcName)s: %(levelname)s:%(message)s') +ch.setFormatter(formatter) +logger.addHandler(ch) + +class ConfigMgmtError(Exception): +    pass + +class ConfigMgmt: +    def __init__(self, session_env=None, config=None): +        if session_env: +            self._session_env = session_env +        else: +            self._session_env = None + +        if config is None: +            config = Config() + +        d = config.get_config_dict(['system', 'config-management'], +                                   key_mangling=('-', '_'), +                                   get_first_key=True) + +        self.max_revisions = int(d.get('commit_revisions', 0)) +        self.locations = d.get('commit_archive', {}).get('location', []) +        self.source_address = d.get('commit_archive', +                                    {}).get('source_address', '') +        if config.exists(['system', 'host-name']): +            self.hostname = config.return_value(['system', 'host-name']) +        else: +            self.hostname = 'vyos' + +        # a call to compare without args is edit_level aware +        edit_level = os.getenv('VYATTA_EDIT_LEVEL', '') +        edit_path = [l for l in edit_level.split('/') if l] +        if edit_path: +            eff_conf = config.show_config(edit_path, effective=True) +            self.edit_level_active_config = ConfigTree(eff_conf) +            conf = config.show_config(edit_path) +            self.edit_level_working_config = ConfigTree(conf) +        else: +            self.edit_level_active_config = None +            self.edit_level_working_config = None + +        self.active_config = config._running_config +        self.working_config = config._session_config + +    @staticmethod +    def save_config(target): +        cmd = f'{SAVE_CONFIG} {target}' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            logger.critical(f'save config failed: {out}') + +    def _unsaved_commits(self) -> bool: +        tmp_save = '/tmp/config.boot.check-save' +        self.save_config(tmp_save) +        ret = not cmp(tmp_save, config_file, shallow=False) +        os.unlink(tmp_save) +        return ret + +    # Console script functions +    # +    def commit_confirm(self, minutes: int=DEFAULT_TIME_MINUTES, +                       no_prompt: bool=False) -> Tuple[str,int]: +        """Commit with reboot to saved config in 'minutes' minutes if +        'confirm' call is not issued. +        """ +        if is_systemd_service_active(f'{timer_name}.timer'): +            msg = 'Another confirm is pending' +            return msg, 1 + +        if self._unsaved_commits(): +            W = '\nYou should save previous commits before commit-confirm !\n' +        else: +            W = '' + +        prompt_str = f''' +commit-confirm will automatically reboot in {minutes} minutes unless changes +are confirmed.\n +Proceed ?''' +        prompt_str = W + prompt_str +        if not no_prompt and not ask_yes_no(prompt_str, default=True): +            msg = 'commit-confirm canceled' +            return msg, 1 + +        action = 'sg vyattacfg "/usr/bin/config-mgmt revert"' +        cmd = f'sudo systemd-run --quiet --on-active={minutes}m --unit={timer_name} {action}' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            raise ConfigMgmtError(out) + +        # start notify +        cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}' +        os.system(cmd) + +        msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot' +        return msg, 0 + +    def confirm(self) -> Tuple[str,int]: +        """Do not reboot to saved config following 'commit-confirm'. +        Update commit log and archive. +        """ +        if not is_systemd_service_active(f'{timer_name}.timer'): +            msg = 'No confirm pending' +            return msg, 0 + +        cmd = f'sudo systemctl stop --quiet {timer_name}.timer' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            raise ConfigMgmtError(out) + +        # kill notify +        cmd = 'sudo pkill -f commit-confirm-notify.py' +        rc, out = rc_cmd(cmd) +        if rc != 0: +            raise ConfigMgmtError(out) + +        entry = self._read_tmp_log_entry() +        self._add_log_entry(**entry) + +        if self._archive_active_config(): +            self._update_archive() + +        msg = 'Reboot timer stopped' +        return msg, 0 + +    def revert(self) -> Tuple[str,int]: +        """Reboot to saved config, dropping commits from 'commit-confirm'. +        """ +        _ = self._read_tmp_log_entry() + +        # archived config will be reverted on boot +        rc, out = rc_cmd('sudo systemctl reboot') +        if rc != 0: +            raise ConfigMgmtError(out) + +        return '', 0 + +    def rollback(self, rev: int, no_prompt: bool=False) -> Tuple[str,int]: +        """Reboot to config revision 'rev'. +        """ +        from shutil import copy + +        msg = '' + +        if not self._check_revision_number(rev): +            msg = f'Invalid revision number {rev}: must be 0 < rev < {maxrev}' +            return msg, 1 + +        prompt_str = 'Proceed with reboot ?' +        if not no_prompt and not ask_yes_no(prompt_str, default=True): +            msg = 'Canceling rollback' +            return msg, 0 + +        rc, out = rc_cmd(f'sudo cp {archive_config_file} {prerollback_config}') +        if rc != 0: +            raise ConfigMgmtError(out) + +        path = os.path.join(archive_dir, f'config.boot.{rev}.gz') +        with gzip.open(path) as f: +            config = f.read() +        try: +            with open(rollback_config, 'wb') as f: +                f.write(config) +            copy(rollback_config, config_file) +        except OSError as e: +            raise ConfigMgmtError from e + +        rc, out = rc_cmd('sudo systemctl reboot') +        if rc != 0: +            raise ConfigMgmtError(out) + +        return msg, 0 + +    def compare(self, saved: bool=False, commands: bool=False, +                rev1: Optional[int]=None, +                rev2: Optional[int]=None) -> Tuple[str,int]: +        """General compare function for config file revisions: +        revision n vs. revision m; working version vs. active version; +        or working version vs. saved version. +        """ +        from difflib import unified_diff + +        ct1 = self.edit_level_active_config +        if ct1 is None: +            ct1 = self.active_config +        ct2 = self.edit_level_working_config +        if ct2 is None: +            ct2 = self.working_config +        msg = 'No changes between working and active configurations.\n' +        if saved: +            ct1 = self._get_saved_config_tree() +            ct2 = self.working_config +            msg = 'No changes between working and saved configurations.\n' +        if rev1 is not None: +            if not self._check_revision_number(rev1): +                return f'Invalid revision number {rev1}', 1 +            ct1 = self._get_config_tree_revision(rev1) +            ct2 = self.working_config +            msg = f'No changes between working and revision {rev1} configurations.\n' +        if rev2 is not None: +            if not self._check_revision_number(rev2): +                return f'Invalid revision number {rev2}', 1 +            # compare older to newer +            ct2 = ct1 +            ct1 = self._get_config_tree_revision(rev2) +            msg = f'No changes between revisions {rev2} and {rev1} configurations.\n' + +        if commands: +            lines1 = ct1.to_commands().splitlines(keepends=True) +            lines2 = ct2.to_commands().splitlines(keepends=True) +        else: +            lines1 = ct1.to_string().splitlines(keepends=True) +            lines2 = ct2.to_string().splitlines(keepends=True) + +        out = '' +        comp = unified_diff(lines1, lines2) +        for line in comp: +            if re.match(r'(\-\-)|(\+\+)|(@@)', line): +                continue +            out += line +        if out: +            msg = out + +        return msg, 0 + +    def wrap_compare(self, options) -> Tuple[str,int]: +        """Interface to vyatta-cfg-run: args collected as 'options' to parse +        for compare. +        """ +        cmnds = False +        r1 = None +        r2 = None +        if 'commands' in options: +            cmnds=True +            options.remove('commands') +        for i in options: +            if not i.isnumeric(): +                options.remove(i) +        if len(options) > 0: +            r1 = int(options[0]) +        if len(options) > 1: +            r2 = int(options[1]) + +        return self.compare(commands=cmnds, rev1=r1, rev2=r2) + +    # Initialization and post-commit hooks for conf-mode +    # +    def initialize_revision(self): +        """Initialize config archive, logrotate conf, and commit log. +        """ +        mask = os.umask(0o002) +        os.makedirs(archive_dir, exist_ok=True) + +        self._add_logrotate_conf() + +        if (not os.path.exists(commit_log_file) or +            self._get_number_of_revisions() == 0): +            user = self._get_user() +            via = 'init' +            comment = '' +            self._add_log_entry(user, via, comment) +            # add empty init config before boot-config load for revision +            # and diff consistency +            if self._archive_active_config(): +                self._update_archive() + +        os.umask(mask) + +    def commit_revision(self): +        """Update commit log and rotate archived config.boot. + +        commit_revision is called in post-commit-hooks, if +        ['commit-archive', 'commit-revisions'] is configured. +        """ +        if os.getenv('IN_COMMIT_CONFIRM', ''): +            self._new_log_entry(tmp_file=tmp_log_entry) +            return + +        self._add_log_entry() + +        if self._archive_active_config(): +            self._update_archive() + +    def commit_archive(self): +        """Upload config to remote archive. +        """ +        from vyos.remote import upload + +        hostname = self.hostname +        t = datetime.now() +        timestamp = t.strftime('%Y%m%d_%H%M%S') +        remote_file = f'config.boot-{hostname}.{timestamp}' +        source_address = self.source_address + +        for location in self.locations: +            upload(archive_config_file, f'{location}/{remote_file}', +                   source_host=source_address) + +    # op-mode functions +    # +    def get_raw_log_data(self) -> list: +        """Return list of dicts of log data: +           keys: [timestamp, user, commit_via, commit_comment] +        """ +        log = self._get_log_entries() +        res_l = [] +        for line in log: +            d = self._get_log_entry(line) +            res_l.append(d) + +        return res_l + +    @staticmethod +    def format_log_data(data: list) -> str: +        """Return formatted log data as str. +        """ +        res_l = [] +        for l_no, l in enumerate(data): +            time_d = datetime.fromtimestamp(int(l['timestamp'])) +            time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") + +            res_l.append([l_no, time_str, +                          f"by {l['user']}", f"via {l['commit_via']}"]) + +            if l['commit_comment'] != 'commit': # default comment +                res_l.append([None, l['commit_comment']]) + +        ret = tabulate(res_l, tablefmt="plain") +        return ret + +    @staticmethod +    def format_log_data_brief(data: list) -> str: +        """Return 'brief' form of log data as str. + +        Slightly compacted format used in completion help for +        'rollback'. +        """ +        res_l = [] +        for l_no, l in enumerate(data): +            time_d = datetime.fromtimestamp(int(l['timestamp'])) +            time_str = time_d.strftime("%Y-%m-%d %H:%M:%S") + +            res_l.append(['\t', l_no, time_str, +                          f"{l['user']}", f"by {l['commit_via']}"]) + +        ret = tabulate(res_l, tablefmt="plain") +        return ret + +    def show_commit_diff(self, rev: int, rev2: Optional[int]=None, +                         commands: bool=False) -> str: +        """Show commit diff at revision number, compared to previous +        revision, or to another revision. +        """ +        if rev2 is None: +            out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev+1)) +            return out + +        out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2) +        return out + +    def show_commit_file(self, rev: int) -> str: +        return self._get_file_revision(rev) + +    # utility functions +    # +    @staticmethod +    def _strip_version(s): +        return re.split(r'(//)', s)[0] + +    def _get_saved_config_tree(self): +        with open(config_file) as f: +            c = self._strip_version(f.read()) +        return ConfigTree(c) + +    def _get_file_revision(self, rev: int): +        if rev not in range(0, self._get_number_of_revisions()): +            raise ConfigMgmtError('revision not available') +        revision = os.path.join(archive_dir, f'config.boot.{rev}.gz') +        with gzip.open(revision) as f: +            r = f.read().decode() +        return r + +    def _get_config_tree_revision(self, rev: int): +        c = self._strip_version(self._get_file_revision(rev)) +        return ConfigTree(c) + +    def _add_logrotate_conf(self): +        conf = f"""{archive_config_file} {{ +    su root vyattacfg +    rotate {self.max_revisions} +    start 0 +    compress +    copy +}}""" +        mask = os.umask(0o133) + +        with open(logrotate_conf, 'w') as f: +            f.write(conf) + +        os.umask(mask) + +    def _archive_active_config(self) -> bool: +        mask = os.umask(0o113) + +        ext = os.getpid() +        tmp_save = f'/tmp/config.boot.{ext}' +        self.save_config(tmp_save) + +        try: +            if cmp(tmp_save, archive_config_file, shallow=False): +                # this will be the case on boot, as well as certain +                # re-initialiation instances after delete/set +                os.unlink(tmp_save) +                return False +        except FileNotFoundError: +            pass + +        rc, out = rc_cmd(f'sudo mv {tmp_save} {archive_config_file}') +        os.umask(mask) + +        if rc != 0: +            logger.critical(f'mv file to archive failed: {out}') +            return False + +        return True + +    @staticmethod +    def _update_archive(): +        cmd = f"sudo logrotate -f -s {logrotate_state} {logrotate_conf}" +        rc, out = rc_cmd(cmd) +        if rc != 0: +            logger.critical(f'logrotate failure: {out}') + +    @staticmethod +    def _get_log_entries() -> list: +        """Return lines of commit log as list of strings +        """ +        entries = [] +        if os.path.exists(commit_log_file): +            with open(commit_log_file) as f: +                entries = f.readlines() + +        return entries + +    def _get_number_of_revisions(self) -> int: +        l = self._get_log_entries() +        return len(l) + +    def _check_revision_number(self, rev: int) -> bool: +        # exclude init revision: +        maxrev = self._get_number_of_revisions() +        if not 0 <= rev < maxrev - 1: +            return False +        return True + +    @staticmethod +    def _get_user() -> str: +        import pwd + +        try: +            user = os.getlogin() +        except OSError: +            try: +                user = pwd.getpwuid(os.geteuid())[0] +            except KeyError: +                user = 'unknown' +        return user + +    def _new_log_entry(self, user: str='', commit_via: str='', +                       commit_comment: str='', timestamp: Optional[int]=None, +                       tmp_file: str=None) -> Optional[str]: +        # Format log entry and return str or write to file. +        # +        # Usage is within a post-commit hook, using env values. In case of +        # commit-confirm, it can be written to a temporary file for +        # inclusion on 'confirm'. +        from time import time + +        if timestamp is None: +            timestamp = int(time()) + +        if not user: +            user = self._get_user() +        if not commit_via: +            commit_via = os.getenv('COMMIT_VIA', 'other') +        if not commit_comment: +            commit_comment = os.getenv('COMMIT_COMMENT', 'commit') + +        # the commit log reserves '|' as field demarcation, so replace in +        # comment if present; undo this in _get_log_entry, below +        if re.search(r'\|', commit_comment): +            commit_comment = commit_comment.replace('|', '%%') + +        entry = f'|{timestamp}|{user}|{commit_via}|{commit_comment}|\n' + +        mask = os.umask(0o113) +        if tmp_file is not None: +            try: +                with open(tmp_file, 'w') as f: +                    f.write(entry) +            except OSError as e: +                logger.critical(f'write to {tmp_file} failed: {e}') +            os.umask(mask) +            return None + +        os.umask(mask) +        return entry + +    @staticmethod +    def _get_log_entry(line: str) -> dict: +        log_fmt = re.compile(r'\|.*\|\n?$') +        keys = ['user', 'commit_via', 'commit_comment', 'timestamp'] +        if not log_fmt.match(line): +            logger.critical(f'Invalid log format {line}') +            return {} + +        timestamp, user, commit_via, commit_comment = ( +        tuple(line.strip().strip('|').split('|'))) + +        commit_comment = commit_comment.replace('%%', '|') +        d = dict(zip(keys, [user, commit_via, +                            commit_comment, timestamp])) + +        return d + +    def _read_tmp_log_entry(self) -> dict: +        try: +            with open(tmp_log_entry) as f: +                entry = f.read() +            os.unlink(tmp_log_entry) +        except OSError as e: +            logger.critical(f'error on file {tmp_log_entry}: {e}') + +        return self._get_log_entry(entry) + +    def _add_log_entry(self, user: str='', commit_via: str='', +                       commit_comment: str='', timestamp: Optional[int]=None): +        mask = os.umask(0o113) + +        entry = self._new_log_entry(user=user, commit_via=commit_via, +                                    commit_comment=commit_comment, +                                    timestamp=timestamp) + +        log_entries = self._get_log_entries() +        log_entries.insert(0, entry) +        if len(log_entries) > self.max_revisions: +            log_entries = log_entries[:-1] + +        try: +            with open(commit_log_file, 'w') as f: +                f.writelines(log_entries) +        except OSError as e: +            logger.critical(e) + +        os.umask(mask) + +# entry_point for console script +# +def run(): +    from argparse import ArgumentParser, REMAINDER + +    config_mgmt = ConfigMgmt() + +    for s in list(commit_hooks): +        if sys.argv[0].replace('-', '_').endswith(s): +            func = getattr(config_mgmt, s) +            func() +            sys.exit(0) + +    parser = ArgumentParser() +    subparsers = parser.add_subparsers(dest='subcommand') + +    commit_confirm = subparsers.add_parser('commit_confirm', +                     help="Commit with opt-out reboot to saved config") +    commit_confirm.add_argument('-t', dest='minutes', type=int, +                                default=DEFAULT_TIME_MINUTES, +                                help="Minutes until reboot, unless 'confirm'") +    commit_confirm.add_argument('-y', dest='no_prompt', action='store_true', +                                help="Execute without prompt") + +    subparsers.add_parser('confirm', help="Confirm commit") +    subparsers.add_parser('revert', help="Revert commit-confirm") + +    rollback = subparsers.add_parser('rollback', +                                     help="Rollback to earlier config") +    rollback.add_argument('--rev', type=int, +                          help="Revision number for rollback") +    rollback.add_argument('-y', dest='no_prompt', action='store_true', +                          help="Excute without prompt") + +    compare = subparsers.add_parser('compare', +                                    help="Compare config files") + +    compare.add_argument('--saved', action='store_true', +                         help="Compare session config with saved config") +    compare.add_argument('--commands', action='store_true', +                         help="Show difference between commands") +    compare.add_argument('--rev1', type=int, default=None, +                         help="Compare revision with session config or other revision") +    compare.add_argument('--rev2', type=int, default=None, +                         help="Compare revisions") + +    wrap_compare = subparsers.add_parser('wrap_compare', +                                         help="Wrapper interface for vyatta-cfg-run") +    wrap_compare.add_argument('--options', nargs=REMAINDER) + +    args = vars(parser.parse_args()) + +    func = getattr(config_mgmt, args['subcommand']) +    del args['subcommand'] + +    res = '' +    try: +        res, rc = func(**args) +    except ConfigMgmtError as e: +        print(e) +        sys.exit(1) +    if res: +        print(res) +    sys.exit(rc) diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 30e893d74..af2c7b28b 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2022-2023 VyOS maintainers and contributors <maintainers@vyos.io>  #  # This library is free software; you can redistribute it and/or  # modify it under the terms of the GNU Lesser General Public @@ -22,6 +22,10 @@ from humps import decamelize  class Error(Exception):      """ Any error that makes requested operation impossible to complete          for reasons unrelated to the user input or script logic. + +        This is the base class, scripts should not use it directly +        and should raise more specific errors instead, +        whenever possible.      """      pass @@ -45,6 +49,13 @@ class PermissionDenied(Error):      """      pass +class InsufficientResources(Error): +    """ Requested operation and its arguments are valid but the system +        does not have enough resources (such as drive space or memory) +        to complete it. +    """ +    pass +  class UnsupportedOperation(Error):      """ Requested operation is technically valid but is not implemented yet. """      pass @@ -217,6 +228,9 @@ def run(module):          if not args["raw"]:              return res          else: +            if not isinstance(res, dict) and not isinstance(res, list): +                raise InternalError(f"Bare literal is not an acceptable raw output, must be a list or an object.\ +                  The output was:{res}")              res = decamelize(res)              res = _normalize_field_names(res)              from json import dumps diff --git a/python/vyos/template.py b/python/vyos/template.py index 2a4135f9e..ce9983958 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -476,6 +476,8 @@ def get_esp_ike_cipher(group_config, ike_group=None):                  continue              tmp = '{encryption}-{hash}'.format(**proposal) +            if 'prf' in proposal: +                tmp += '-' + proposal['prf']              if 'dh_group' in proposal:                  tmp += '-' + pfs_lut[ 'dh-group' +  proposal['dh_group'] ]              elif 'pfs' in group_config and group_config['pfs'] != 'disable': diff --git a/python/vyos/util.py b/python/vyos/util.py index 110da3be5..66ded464d 100644 --- a/python/vyos/util.py +++ b/python/vyos/util.py @@ -488,7 +488,7 @@ def is_listen_port_bind_service(port: int, service: str) -> bool:      Example:          % is_listen_port_bind_service(443, 'nginx')          True -        % is_listen_port_bind_service(443, 'ocservr-main') +        % is_listen_port_bind_service(443, 'ocserv-main')          False      """      from psutil import net_connections as connections diff --git a/smoketest/scripts/cli/test_ha_vrrp.py b/smoketest/scripts/cli/test_ha_vrrp.py index 68905e447..f18a4ab86 100755 --- a/smoketest/scripts/cli/test_ha_vrrp.py +++ b/smoketest/scripts/cli/test_ha_vrrp.py @@ -87,11 +87,13 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):          advertise_interval = '77'          priority = '123'          preempt_delay = '400' +        startup_delay = '120'          for group in groups:              vlan_id = group.lstrip('VLAN')              vip = f'100.64.{vlan_id}.1/24'              group_base = base_path + ['vrrp', 'group', group] +            global_param_base = base_path + ['vrrp', 'global-parameters']              self.cli_set(['interfaces', 'ethernet', vrrp_interface, 'vif', vlan_id, 'address', inc_ip(vip, 1) + '/' + vip.split('/')[-1]]) @@ -110,6 +112,10 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):              self.cli_set(group_base + ['authentication', 'type', 'plaintext-password'])              self.cli_set(group_base + ['authentication', 'password', f'{group}']) +            # Global parameters +            config = getConfig(f'global_defs') +            self.cli_set(global_param_base + ['startup-delay', f'{startup_delay}']) +          # commit changes          self.cli_commit() @@ -131,6 +137,9 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):              # Authentication              self.assertIn(f'auth_pass "{group}"', config)              self.assertIn(f'auth_type PASS', config) +            # Global parameters +            config = getConfig(f'global_defs') +            self.assertIn(f'vrrp_startup_delay {startup_delay}', config)      def test_03_sync_group(self):          sync_group = 'VyOS' diff --git a/smoketest/scripts/cli/test_interfaces_pppoe.py b/smoketest/scripts/cli/test_interfaces_pppoe.py index 8927121a8..08b7f2f46 100755 --- a/smoketest/scripts/cli/test_interfaces_pppoe.py +++ b/smoketest/scripts/cli/test_interfaces_pppoe.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2022 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -57,8 +57,8 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):      def test_01_pppoe_client(self):          # Check if PPPoE dialer can be configured and runs          for interface in self._interfaces: -            user = 'VyOS-user-' + interface -            passwd = 'VyOS-passwd-' + interface +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}'              mtu = '1400'              self.cli_set(base_path + [interface, 'authentication', 'user', user]) @@ -76,23 +76,26 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):          # verify configuration file(s)          for interface in self._interfaces: -            user = 'VyOS-user-' + interface -            password = 'VyOS-passwd-' + interface +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}'              tmp = get_config_value(interface, 'mtu')[1]              self.assertEqual(tmp, mtu)              tmp = get_config_value(interface, 'user')[1].replace('"', '')              self.assertEqual(tmp, user)              tmp = get_config_value(interface, 'password')[1].replace('"', '') -            self.assertEqual(tmp, password) +            self.assertEqual(tmp, passwd)              tmp = get_config_value(interface, 'ifname')[1]              self.assertEqual(tmp, interface)      def test_02_pppoe_client_disabled_interface(self):          # Check if PPPoE Client can be disabled          for interface in self._interfaces: -            self.cli_set(base_path + [interface, 'authentication', 'user', 'vyos']) -            self.cli_set(base_path + [interface, 'authentication', 'password', 'vyos']) +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' + +            self.cli_set(base_path + [interface, 'authentication', 'user', user]) +            self.cli_set(base_path + [interface, 'authentication', 'password', passwd])              self.cli_set(base_path + [interface, 'source-interface', self._source_interface])              self.cli_set(base_path + [interface, 'disable']) @@ -117,7 +120,10 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):      def test_03_pppoe_authentication(self):          # When username or password is set - so must be the other          for interface in self._interfaces: -            self.cli_set(base_path + [interface, 'authentication', 'user', 'vyos']) +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' + +            self.cli_set(base_path + [interface, 'authentication', 'user', user])              self.cli_set(base_path + [interface, 'source-interface', self._source_interface])              self.cli_set(base_path + [interface, 'ipv6', 'address', 'autoconf']) @@ -125,7 +131,7 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              with self.assertRaises(ConfigSessionError):                  self.cli_commit() -            self.cli_set(base_path + [interface, 'authentication', 'password', 'vyos']) +            self.cli_set(base_path + [interface, 'authentication', 'password', passwd])          self.cli_commit() @@ -136,8 +142,11 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):          sla_len = '8'          for interface in self._interfaces: -            self.cli_set(base_path + [interface, 'authentication', 'user', 'vyos']) -            self.cli_set(base_path + [interface, 'authentication', 'password', 'vyos']) +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' + +            self.cli_set(base_path + [interface, 'authentication', 'user', user]) +            self.cli_set(base_path + [interface, 'authentication', 'password', passwd])              self.cli_set(base_path + [interface, 'no-default-route'])              self.cli_set(base_path + [interface, 'no-peer-dns'])              self.cli_set(base_path + [interface, 'source-interface', self._source_interface]) @@ -149,18 +158,54 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              self.cli_set(dhcpv6_pd_base + ['interface', self._source_interface, 'address', address])              self.cli_set(dhcpv6_pd_base + ['interface', self._source_interface, 'sla-id',  sla_id]) -            # commit changes -            self.cli_commit() +        # commit changes +        self.cli_commit() + +        for interface in self._interfaces: +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}'              # verify "normal" PPPoE value - 1492 is default MTU              tmp = get_config_value(interface, 'mtu')[1]              self.assertEqual(tmp, '1492')              tmp = get_config_value(interface, 'user')[1].replace('"', '') -            self.assertEqual(tmp, 'vyos') +            self.assertEqual(tmp, user)              tmp = get_config_value(interface, 'password')[1].replace('"', '') -            self.assertEqual(tmp, 'vyos') +            self.assertEqual(tmp, passwd)              tmp = get_config_value(interface, '+ipv6 ipv6cp-use-ipaddr')              self.assertListEqual(tmp, ['+ipv6', 'ipv6cp-use-ipaddr']) +    def test_05_pppoe_options(self): +        # Check if PPPoE dialer can be configured with DHCPv6-PD +        for interface in self._interfaces: +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' +            ac_name = f'AC{interface}' +            service_name = f'SRV{interface}' +            host_uniq = 'cafebeefBABE123456' + +            self.cli_set(base_path + [interface, 'authentication', 'user', user]) +            self.cli_set(base_path + [interface, 'authentication', 'password', passwd]) +            self.cli_set(base_path + [interface, 'source-interface', self._source_interface]) + +            self.cli_set(base_path + [interface, 'access-concentrator', ac_name]) +            self.cli_set(base_path + [interface, 'service-name', service_name]) +            self.cli_set(base_path + [interface, 'host-uniq', host_uniq]) + +        # commit changes +        self.cli_commit() + +        for interface in self._interfaces: +            ac_name = f'AC{interface}' +            service_name = f'SRV{interface}' +            host_uniq = 'cafebeefBABE123456' + +            tmp = get_config_value(interface, 'pppoe-ac')[1] +            self.assertEqual(tmp, f'"{ac_name}"') +            tmp = get_config_value(interface, 'pppoe-service')[1] +            self.assertEqual(tmp, f'"{service_name}"') +            tmp = get_config_value(interface, 'pppoe-host-uniq')[1] +            self.assertEqual(tmp, f'"{host_uniq}"') +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_snmp.py b/smoketest/scripts/cli/test_service_snmp.py index e80c689cc..b18b9e7a1 100755 --- a/smoketest/scripts/cli/test_service_snmp.py +++ b/smoketest/scripts/cli/test_service_snmp.py @@ -123,6 +123,28 @@ class TestSNMPService(VyOSUnitTestSHIM.TestCase):          self.assertTrue(process_named_running(PROCESS_NAME))          self.cli_delete(['interfaces', 'dummy', dummy_if]) +        ## Check communities and default view RESTRICTED +        for auth in ['ro', 'rw']: +            community = 'VyOS' + auth +            for addr in clients: +                if is_ipv4(addr): +                    entry = auth + 'community ' + community + ' ' + addr + ' -V' +                else: +                    entry = auth + 'community6 ' + community + ' ' + addr + ' -V' +                config = get_config_value(entry) +                expected = 'RESTRICTED' +                self.assertIn(expected, config) +            for addr in networks: +                if is_ipv4(addr): +                    entry = auth + 'community ' + community + ' ' + addr + ' -V' +                else: +                    entry = auth + 'community6 ' + community + ' ' + addr + ' -V' +                config = get_config_value(entry) +                expected = 'RESTRICTED' +                self.assertIn(expected, config) +        # And finally check global entry for RESTRICTED view +        config = get_config_value('view RESTRICTED    included .1') +        self.assertIn('80', config)      def test_snmpv3_sha(self):          # Check if SNMPv3 can be configured with SHA authentication diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index 46db0bbf5..03780c465 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -337,6 +337,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase):          self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'dh-group', '2'])          self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'encryption', 'aes256'])          self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'hash', 'sha1']) +        self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'prf', 'prfsha1'])          # Profile          self.cli_set(base_path + ['profile', 'NHRPVPN', 'authentication', 'mode', 'pre-shared-secret']) @@ -349,7 +350,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase):          swanctl_conf = read_file(swanctl_file)          swanctl_lines = [ -            f'proposals = aes128-sha1-modp1024,aes256-sha1-modp1024', +            f'proposals = aes128-sha1-modp1024,aes256-sha1-prfsha1-modp1024',              f'version = 1',              f'rekey_time = {ike_lifetime}s',              f'rekey_time = {esp_lifetime}s', diff --git a/smoketest/scripts/cli/test_vpn_openconnect.py b/smoketest/scripts/cli/test_vpn_openconnect.py index 8572d6d66..ec8ecacb9 100755 --- a/smoketest/scripts/cli/test_vpn_openconnect.py +++ b/smoketest/scripts/cli/test_vpn_openconnect.py @@ -18,6 +18,7 @@ import unittest  from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.template import ip_from_cidr  from vyos.util import process_named_running  from vyos.util import read_file @@ -52,6 +53,9 @@ config_file = '/run/ocserv/ocserv.conf'  auth_file = '/run/ocserv/ocpasswd'  otp_file = '/run/ocserv/users.oath' +listen_if = 'dum116' +listen_address = '100.64.0.1/32' +  class TestVPNOpenConnect(VyOSUnitTestSHIM.TestCase):      @classmethod      def setUpClass(cls): @@ -61,6 +65,8 @@ class TestVPNOpenConnect(VyOSUnitTestSHIM.TestCase):          # out the current configuration :)          cls.cli_delete(cls, base_path) +        cls.cli_set(cls, ['interfaces', 'dummy', listen_if, 'address', listen_address]) +          cls.cli_set(cls, pki_path + ['ca', 'openconnect', 'certificate', cert_data.replace('\n','')])          cls.cli_set(cls, pki_path + ['certificate', 'openconnect', 'certificate', cert_data.replace('\n','')])          cls.cli_set(cls, pki_path + ['certificate', 'openconnect', 'private', 'key', key_data.replace('\n','')]) @@ -68,6 +74,7 @@ class TestVPNOpenConnect(VyOSUnitTestSHIM.TestCase):      @classmethod      def tearDownClass(cls):          cls.cli_delete(cls, pki_path) +        cls.cli_delete(cls, ['interfaces', 'dummy', listen_if])          super(TestVPNOpenConnect, cls).tearDownClass()      def tearDown(self): @@ -104,6 +111,9 @@ class TestVPNOpenConnect(VyOSUnitTestSHIM.TestCase):          self.cli_set(base_path + ['ssl', 'ca-certificate', 'openconnect'])          self.cli_set(base_path + ['ssl', 'certificate', 'openconnect']) +        listen_ip_no_cidr = ip_from_cidr(listen_address) +        self.cli_set(base_path + ['listen-address', listen_ip_no_cidr]) +          self.cli_commit()          # Verify configuration @@ -111,10 +121,15 @@ class TestVPNOpenConnect(VyOSUnitTestSHIM.TestCase):          # authentication mode local password-otp          self.assertIn(f'auth = "plain[passwd=/run/ocserv/ocpasswd,otp=/run/ocserv/users.oath]"', daemon_config) +        self.assertIn(f'listen-host = {listen_ip_no_cidr}', daemon_config)          self.assertIn(f'ipv4-network = {v4_subnet}', daemon_config)          self.assertIn(f'ipv6-network = {v6_prefix}', daemon_config)          self.assertIn(f'ipv6-subnet-prefix = {v6_len}', daemon_config) +        # defaults +        self.assertIn(f'tcp-port = 443', daemon_config) +        self.assertIn(f'udp-port = 443', daemon_config) +          for ns in name_server:              self.assertIn(f'dns = {ns}', daemon_config)          for domain in split_dns: diff --git a/smoketest/scripts/system/test_module_load.py b/smoketest/scripts/system/test_module_load.py index 76a41ac4d..bd30c57ec 100755 --- a/smoketest/scripts/system/test_module_load.py +++ b/smoketest/scripts/system/test_module_load.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019-2020 VyOS maintainers and contributors +# Copyright (C) 2019-2023 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -23,8 +23,7 @@ modules = {      "intel_qat": ["qat_200xx", "qat_200xxvf", "qat_c3xxx", "qat_c3xxxvf",                    "qat_c62x", "qat_c62xvf", "qat_d15xx", "qat_d15xxvf",                    "qat_dh895xcc", "qat_dh895xccvf"], -    "accel_ppp": ["ipoe", "vlan_mon"], -    "misc": ["wireguard"] +    "accel_ppp": ["ipoe", "vlan_mon"]  }  class TestKernelModules(unittest.TestCase): diff --git a/src/conf_mode/config_mgmt.py b/src/conf_mode/config_mgmt.py new file mode 100755 index 000000000..c681a8405 --- /dev/null +++ b/src/conf_mode/config_mgmt.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import os +import sys + +from vyos import ConfigError +from vyos.config import Config +from vyos.config_mgmt import ConfigMgmt +from vyos.config_mgmt import commit_post_hook_dir, commit_hooks + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['system', 'config-management'] +    if not conf.exists(base): +        return None + +    mgmt = ConfigMgmt(config=conf) + +    return mgmt + +def verify(_mgmt): +    return + +def generate(mgmt): +    if mgmt is None: +        return + +    mgmt.initialize_revision() + +def apply(mgmt): +    if mgmt is None: +        return + +    locations = mgmt.locations +    archive_target = os.path.join(commit_post_hook_dir, +                               commit_hooks['commit_archive']) +    if locations: +        try: +            os.symlink('/usr/bin/config-mgmt', archive_target) +        except FileExistsError: +            pass +        except OSError as exc: +            raise ConfigError from exc +    else: +        try: +            os.unlink(archive_target) +        except FileNotFoundError: +            pass +        except OSError as exc: +            raise ConfigError from exc + +    revisions = mgmt.max_revisions +    revision_target = os.path.join(commit_post_hook_dir, +                               commit_hooks['commit_revision']) +    if revisions > 0: +        try: +            os.symlink('/usr/bin/config-mgmt', revision_target) +        except FileExistsError: +            pass +        except OSError as exc: +            raise ConfigError from exc +    else: +        try: +            os.unlink(revision_target) +        except FileNotFoundError: +            pass +        except OSError as exc: +            raise ConfigError from exc + +if __name__ == '__main__': +    try: +        c = get_config() +        verify(c) +        generate(c) +        apply(c) +    except ConfigError as e: +        print(e) +        sys.exit(1) diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 7567444db..08861053d 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -75,6 +75,8 @@ def get_config(config=None):          default_values = defaults(base + ['name'])          if 'port' in default_values:              del default_values['port'] +        if 'volume' in default_values: +            del default_values['volume']          for name in container['name']:              container['name'][name] = dict_merge(default_values, container['name'][name]) @@ -85,6 +87,13 @@ def get_config(config=None):                      default_values = defaults(base + ['name', 'port'])                      container['name'][name]['port'][port] = dict_merge(                          default_values, container['name'][name]['port'][port]) +            # XXX: T2665: we can not safely rely on the defaults() when there are +            # tagNodes in place, it is better to blend in the defaults manually. +            if 'volume' in container['name'][name]: +                for volume in container['name'][name]['volume']: +                    default_values = defaults(base + ['name', 'volume']) +                    container['name'][name]['volume'][volume] = dict_merge( +                        default_values, container['name'][name]['volume'][volume])      # Delete container network, delete containers      tmp = node_changed(conf, base + ['network']) @@ -245,7 +254,7 @@ def generate_run_arguments(name, container_config):      env_opt = ''      if 'environment' in container_config:          for k, v in container_config['environment'].items(): -            env_opt += f" -e \"{k}={v['value']}\"" +            env_opt += f" --env \"{k}={v['value']}\""      # Publish ports      port = '' @@ -255,7 +264,7 @@ def generate_run_arguments(name, container_config):              protocol = container_config['port'][portmap]['protocol']              sport = container_config['port'][portmap]['source']              dport = container_config['port'][portmap]['destination'] -            port += f' -p {sport}:{dport}/{protocol}' +            port += f' --publish {sport}:{dport}/{protocol}'      # Bind volume      volume = '' @@ -263,7 +272,8 @@ def generate_run_arguments(name, container_config):          for vol, vol_config in container_config['volume'].items():              svol = vol_config['source']              dvol = vol_config['destination'] -            volume += f' -v {svol}:{dvol}' +            mode = vol_config['mode'] +            volume += f' --volume {svol}:{dvol}:{mode}'      container_base_cmd = f'--detach --interactive --tty --replace {cap_add} ' \                           f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index ee4defa0d..5f0b76f90 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -54,7 +54,8 @@ def get_config(config=None):      # All parameters that can be changed on-the-fly (like interface description)      # should not lead to a reconnect!      for options in ['access-concentrator', 'connect-on-demand', 'service-name', -                    'source-interface', 'vrf', 'no-default-route', 'authentication']: +                    'source-interface', 'vrf', 'no-default-route', +                    'authentication', 'host_uniq']:          if is_node_changed(conf, base + [ifname, options]):              pppoe.update({'shutdown_required': {}})              # bail out early - no need to further process other nodes diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 914ec245c..ab2ccf99e 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -166,6 +166,10 @@ def verify(snmp):              if 'community' not in trap_config:                  raise ConfigError(f'Trap target "{trap}" requires a community to be set!') +    if 'oid_enable' in snmp: +        Warning(f'Custom OIDs are enabled and may lead to system instability and high resource consumption') + +      verify_vrf(snmp)      # bail out early if SNMP v3 is not configured diff --git a/src/migration-scripts/snmp/2-to-3 b/src/migration-scripts/snmp/2-to-3 new file mode 100755 index 000000000..5f8d9c88d --- /dev/null +++ b/src/migration-scripts/snmp/2-to-3 @@ -0,0 +1,57 @@ +#!/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/>. + +# T4857: Implement FRR SNMP recomendations +#  cli changes from: +#  set service snmp oid-enable route-table +#  To +#  set service snmp oid-enable ip-forward + +import re + +from sys import argv +from sys import exit + +from vyos.configtree import ConfigTree +from vyos.ifconfig import Section + +if (len(argv) < 1): +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +base = ['service snmp'] +config = ConfigTree(config_file) + +if not config.exists(base): +    # Nothing to do +    exit(0) + +if config.exists(base + ['oid-enable']): +    config.delete(base + ['oid-enable']) +    config.set(base + ['oid-enable'], 'ip-forward') + + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    exit(1) diff --git a/src/op_mode/config_mgmt.py b/src/op_mode/config_mgmt.py new file mode 100755 index 000000000..66de26d1f --- /dev/null +++ b/src/op_mode/config_mgmt.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import sys +import typing + +import vyos.opmode +from vyos.config_mgmt import ConfigMgmt + +def show_commit_diff(raw: bool, rev: int, rev2: typing.Optional[int], +                     commands: bool): +    config_mgmt = ConfigMgmt() +    config_diff = config_mgmt.show_commit_diff(rev, rev2, commands) + +    if raw: +        rev2 = (rev+1) if rev2 is None else rev2 +        if commands: +            d = {f'config_command_diff_{rev2}_{rev}': config_diff} +        else: +            d = {f'config_file_diff_{rev2}_{rev}': config_diff} +        return d + +    return config_diff + +def show_commit_file(raw: bool, rev: int): +    config_mgmt = ConfigMgmt() +    config_file = config_mgmt.show_commit_file(rev) + +    if raw: +        d = {f'config_revision_{rev}': config_file} +        return d + +    return config_file + +def show_commit_log(raw: bool): +    config_mgmt = ConfigMgmt() + +    msg = '' +    if config_mgmt.max_revisions == 0: +        msg = ('commit-revisions is not configured;\n' +               'commit log is empty or stale:\n\n') + +    data = config_mgmt.get_raw_log_data() +    if raw: +        return data + +    out = config_mgmt.format_log_data(data) +    out = msg + out + +    return out + +def show_commit_log_brief(raw: bool): +    # used internally for completion help for 'rollback' +    # option 'raw' will return same as 'show_commit_log' +    config_mgmt = ConfigMgmt() + +    data = config_mgmt.get_raw_log_data() +    if raw: +        return data + +    out = config_mgmt.format_log_data_brief(data) + +    return out + +if __name__ == '__main__': +    try: +        res = vyos.opmode.run(sys.modules[__name__]) +        if res: +            print(res) +    except (ValueError, vyos.opmode.Error) as e: +        print(e) +        sys.exit(1) diff --git a/src/op_mode/generate_interfaces_debug_archive.py b/src/op_mode/generate_interfaces_debug_archive.py new file mode 100644 index 000000000..f5767080a --- /dev/null +++ b/src/op_mode/generate_interfaces_debug_archive.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +from datetime import datetime +from pathlib import Path +from shutil import rmtree +from socket import gethostname +from sys import exit +from tarfile import open as tar_open +from vyos.util import rc_cmd +import os + +# define a list of commands that needs to be executed + +CMD_LIST: list[str] = [ +    "journalctl -b -n 500", +    "journalctl -b -k -n 500", +    "ip -s l", +    "cat /proc/interrupts", +    "cat /proc/softirqs", +    "top -b -d 1 -n 2 -1", +    "netstat -l",              +    "cat /proc/net/dev", +    "cat /proc/net/softnet_stat", +    "cat /proc/net/icmp", +    "cat /proc/net/udp", +    "cat /proc/net/tcp", +    "cat /proc/net/netstat", +    "sysctl net", +    "timeout 10 tcpdump -c 500 -eni any port not 22" +] + +CMD_INTERFACES_LIST: list[str] = [ +    "ethtool -i ", +    "ethtool -S ", +    "ethtool -g ", +    "ethtool -c ", +    "ethtool -a ", +    "ethtool -k ", +    "ethtool -i ", +    "ethtool --phy-statistics " +] + +# get intefaces info +interfaces_list = os.popen('ls /sys/class/net/').read().split() + +# modify CMD_INTERFACES_LIST for all interfaces +CMD_INTERFACES_LIST_MOD=[] +for command_interface in interfaces_list: +    for command_interfacev2 in CMD_INTERFACES_LIST: +        CMD_INTERFACES_LIST_MOD.append (f'{command_interfacev2}{command_interface}') + +# execute a command and save the output to a file + +def save_stdout(command: str, file: Path) -> None: +    rc, stdout = rc_cmd(command) +    body: str = f'''### {command} ### +Command: {command} +Exit code: {rc} +Stdout: +{stdout} + +''' +    with file.open(mode='a') as f: +        f.write(body) + +# get local host name +hostname: str = gethostname() +# get current time +time_now: str = datetime.now().isoformat(timespec='seconds') + +# define a temporary directory for logs and collected data +tmp_dir: Path = Path(f'/tmp/drops-debug_{time_now}') +# set file paths +drops_file: Path = Path(f'{tmp_dir}/drops.txt') +interfaces_file: Path = Path(f'{tmp_dir}/interfaces.txt') +archive_file: str = f'/tmp/packet-drops-debug_{time_now}.tar.bz2' + +# create files +tmp_dir.mkdir() +drops_file.touch() +interfaces_file.touch() + +try: +    # execute all commands +    for command in CMD_LIST: +        save_stdout(command, drops_file) +    for command_interface in CMD_INTERFACES_LIST_MOD: +        save_stdout(command_interface, interfaces_file) + +    # create an archive +    with tar_open(name=archive_file, mode='x:bz2') as tar_file: +        tar_file.add(tmp_dir) + +    # inform user about success +    print(f'Debug file is generated and located in {archive_file}') +except Exception as err: +    print(f'Error during generating a debug file: {err}') +finally: +    # cleanup +    rmtree(tmp_dir) +    exit() diff --git a/src/op_mode/lldp.py b/src/op_mode/lldp.py index dc2b1e0b5..1a1b94783 100755 --- a/src/op_mode/lldp.py +++ b/src/op_mode/lldp.py @@ -61,7 +61,14 @@ def _get_raw_data(interface=None, detail=False):  def _get_formatted_output(raw_data):      data_entries = [] -    for neighbor in dict_search('lldp.interface', raw_data): +    tmp = dict_search('lldp.interface', raw_data) +    if not tmp: +        return None +    # One can not always ensure that "interface" is of type list, add safeguard. +    # E.G. Juniper Networks, Inc. ex2300-c-12t only has a dict, not a list of dicts +    if isinstance(tmp, dict): +        tmp = [tmp] +    for neighbor in tmp:          for local_if, values in neighbor.items():              tmp = [] @@ -80,6 +87,10 @@ def _get_formatted_output(raw_data):              # Capabilities              cap = ''              capabilities = jmespath.search('chassis.[*][0][0].capability', values) +            # One can not always ensure that "capability" is of type list, add +            # safeguard. E.G. Unify US-24-250W only has a dict, not a list of dicts +            if isinstance(capabilities, dict): +                capabilities = [capabilities]              if capabilities:                  for capability in capabilities:                      if capability['enabled']: diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py index fc63b0100..b320a529e 100755 --- a/src/services/api/graphql/generate/schema_from_op_mode.py +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -25,16 +25,17 @@ from inspect import signature, getmembers, isfunction, isclass, getmro  from jinja2 import Template  from vyos.defaults import directories +from vyos.opmode import _is_op_mode_function_name as is_op_mode_function_name  from vyos.util import load_as_module  if __package__ is None or __package__ == '':      sys.path.append("/usr/libexec/vyos/services/api") -    from graphql.libs.op_mode import is_op_mode_function_name, is_show_function_name +    from graphql.libs.op_mode import is_show_function_name      from graphql.libs.op_mode import snake_to_pascal_case, map_type_name      from vyos.config import Config      from vyos.configdict import dict_merge      from vyos.xml import defaults  else: -    from .. libs.op_mode import is_op_mode_function_name, is_show_function_name +    from .. libs.op_mode import is_show_function_name      from .. libs.op_mode import snake_to_pascal_case, map_type_name      from .. import state diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 87ea59c43..8254e22b1 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -15,7 +15,7 @@  from importlib import import_module  from typing import Any, Dict, Optional -from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake +from ariadne import ObjectType, convert_camel_case_to_snake  from graphql import GraphQLResolveInfo  from makefun import with_signature @@ -45,7 +45,6 @@ def make_mutation_resolver(mutation_name, class_name, session_func):      func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)'      @mutation.field(mutation_name) -    @convert_kwargs_to_snake_case      @with_signature(func_sig, func_name=resolver_name)      async def func_impl(*args, **kwargs):          try: diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 1ad586428..daccc19b2 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -15,7 +15,7 @@  from importlib import import_module  from typing import Any, Dict, Optional -from ariadne import ObjectType, convert_kwargs_to_snake_case, convert_camel_case_to_snake +from ariadne import ObjectType, convert_camel_case_to_snake  from graphql import GraphQLResolveInfo  from makefun import with_signature @@ -45,7 +45,6 @@ def make_query_resolver(query_name, class_name, session_func):      func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Optional[Dict]=None)'      @query.field(query_name) -    @convert_kwargs_to_snake_case      @with_signature(func_sig, func_name=resolver_name)      async def func_impl(*args, **kwargs):          try: diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py index c1eb493db..c553bbd67 100644 --- a/src/services/api/graphql/libs/op_mode.py +++ b/src/services/api/graphql/libs/op_mode.py @@ -29,11 +29,6 @@ def load_op_mode_as_module(name: str):      name = os.path.splitext(name)[0].replace('-', '_')      return load_as_module(name, path) -def is_op_mode_function_name(name): -    if re.match(r"^(show|clear|reset|restart|add|delete)", name): -        return True -    return False -  def is_show_function_name(name):      if re.match(r"^show", name):          return True diff --git a/src/services/api/graphql/session/errors/op_mode_errors.py b/src/services/api/graphql/session/errors/op_mode_errors.py index 4029fd0a1..a8a9ee426 100644 --- a/src/services/api/graphql/session/errors/op_mode_errors.py +++ b/src/services/api/graphql/session/errors/op_mode_errors.py @@ -4,6 +4,7 @@ op_mode_err_msg = {      "UnconfiguredSubsystem": "subsystem is not configured or not running",      "DataUnavailable": "data currently unavailable",      "PermissionDenied": "client does not have permission", +    "InsufficientResources": "insufficient system resources"      "IncorrectValue": "argument value is incorrect",      "UnsupportedOperation": "operation is not supported (yet)",  } @@ -11,6 +12,7 @@ op_mode_err_msg = {  op_mode_err_code = {      "UnconfiguredSubsystem": 2000,      "DataUnavailable": 2001, +    "InsufficientResources": 2002,      "PermissionDenied": 1003,      "IncorrectValue": 1002,      "UnsupportedOperation": 1004, | 
