From 4d721a58020971d00ab854c37b68e88359999f9c Mon Sep 17 00:00:00 2001
From: Christian Breunig <christian@breunig.cc>
Date: Tue, 19 Dec 2023 07:49:03 +0100
Subject: T2898: add ndp-proxy service

VyOS CLI command
  set service ndp-proxy interface eth0 prefix 2001:db8::/64 mode 'static'

Will generate the following NDP proxy configuration

  $ cat /run/ndppd/ndppd.conf
  # autogenerated by service_ndp-proxy.py

  # This tells 'ndppd' how often to reload the route file /proc/net/ipv6_route
  route-ttl 30000

  # This sets up a listener, that will listen for any Neighbor Solicitation
  # messages, and respond to them according to a set of rules
  proxy eth0 {
      # Turn on or off the router flag for Neighbor Advertisements
      router no
      # Control how long to wait for a Neighbor Advertisment message before invalidating the entry (milliseconds)
      timeout 500
      # Control how long a valid or invalid entry remains in the cache (milliseconds)
      ttl 30000

      # This is a rule that the target address is to match against. If no netmask
      # is provided, /128 is assumed. You may have several rule sections, and the
      # addresses may or may not overlap.
      rule 2001:db8::/64 {
          static
      }
  }
---
 data/configd-include.json                          |   1 +
 data/templates/ndppd/ndppd.conf.j2                 |  71 +++++------
 debian/control                                     |  12 +-
 .../include/version/nat66-version.xml.i            |   2 +-
 interface-definitions/service_ndp-proxy.xml.in     | 132 +++++++++++++++++++++
 op-mode-definitions/monitor-log.xml.in             |   6 +
 op-mode-definitions/show-log.xml.in                |   6 +
 smoketest/scripts/cli/test_service_ndp-proxy.py    |  70 +++++++++++
 src/conf_mode/nat66.py                             |  12 +-
 src/conf_mode/service_ndp-proxy.py                 |  91 ++++++++++++++
 src/migration-scripts/nat66/2-to-3                 |  61 ++++++++++
 11 files changed, 406 insertions(+), 58 deletions(-)
 create mode 100644 interface-definitions/service_ndp-proxy.xml.in
 create mode 100755 smoketest/scripts/cli/test_service_ndp-proxy.py
 create mode 100755 src/conf_mode/service_ndp-proxy.py
 create mode 100755 src/migration-scripts/nat66/2-to-3

diff --git a/data/configd-include.json b/data/configd-include.json
index 92d3863ce..6d7261b73 100644
--- a/data/configd-include.json
+++ b/data/configd-include.json
@@ -63,6 +63,7 @@
 "service_ipoe-server.py",
 "service_mdns-repeater.py",
 "service_monitoring_telegraf.py",
+"service_ndp-proxy.py",
 "service_pppoe-server.py",
 "service_router-advert.py",
 "service_upnp.py",
diff --git a/data/templates/ndppd/ndppd.conf.j2 b/data/templates/ndppd/ndppd.conf.j2
index 1297f36be..6369dbdeb 100644
--- a/data/templates/ndppd/ndppd.conf.j2
+++ b/data/templates/ndppd/ndppd.conf.j2
@@ -1,44 +1,35 @@
-########################################################
-#
-# autogenerated by nat66.py
-#
-#   The configuration file must define one upstream
-#   interface.
-#
-#   For some services, such as nat66, because it runs
-#    stateless, it needs to rely on NDP Proxy to respond
-#   to NDP requests.
-#
-#   When using nat66 source rules, NDP Proxy needs
-#   to be enabled
-#
-########################################################
+# autogenerated by service_ndp-proxy.py
 
-{% set global = namespace(ndppd_interfaces = [],ndppd_prefixs = []) %}
-{% if source.rule is vyos_defined %}
-{%     for rule, config in source.rule.items() if config.disable is not defined %}
-{%         if config.outbound_interface.name is vyos_defined %}
-{%             if config.outbound_interface.name not in global.ndppd_interfaces %}
-{%                 set global.ndppd_interfaces = global.ndppd_interfaces + [config.outbound_interface.name] %}
-{%             endif   %}
-{%             if config.translation.address is vyos_defined and config.translation.address | is_ip_network %}
-{%                 set global.ndppd_prefixs = global.ndppd_prefixs + [{'interface':config.outbound_interface.name,'rule':config.translation.address}] %}
-{%             endif %}
-{%         endif %}
-{%     endfor %}
-{% endif %}
+# This tells 'ndppd' how often to reload the route file /proc/net/ipv6_route
+route-ttl {{ route_refresh }}
+
+{% if interface is vyos_defined %}
+# This sets up a listener, that will listen for any Neighbor Solicitation
+# messages, and respond to them according to a set of rules
+{%     for iface, iface_config in interface.items() if iface_config.disable is not vyos_defined %}
+proxy {{ iface }} {
+    # Turn on or off the router flag for Neighbor Advertisements
+    router {{ 'yes' if iface_config.enable_router_bit is vyos_defined else 'no' }}
+    # Control how long to wait for a Neighbor Advertisment message before invalidating the entry (milliseconds)
+    timeout {{ iface_config.timeout }}
+    # Control how long a valid or invalid entry remains in the cache (milliseconds)
+    ttl {{ iface_config.ttl }}
 
-{% for interface in global.ndppd_interfaces %}
-proxy {{ interface }} {
-    router yes
-    timeout 500
-    ttl 30000
-{%     for map in global.ndppd_prefixs %}
-{%         if map.interface == interface %}
-    rule {{ map.rule }} {
-        static
+{%         if iface_config.prefix is vyos_defined %}
+    # This is a rule that the target address is to match against. If no netmask
+    # is provided, /128 is assumed. You may have several rule sections, and the
+    # addresses may or may not overlap.
+{%             for prefix, prefix_config in iface_config.prefix.items() if prefix_config.disable is not vyos_defined %}
+    rule {{ prefix }} {
+{%                 if prefix_config.mode is vyos_defined('interface') %}
+        iface {{ prefix_config.interface }}
+{%                 else %}
+        {{ prefix_config.mode }}
+{%                 endif %}
     }
-{%         endif  %}
-{%     endfor   %}
+{%             endfor %}
+{%         endif %}
 }
-{% endfor %}
+
+{%     endfor %}
+{% endif %}
diff --git a/debian/control b/debian/control
index 08adc8a68..af42202fc 100644
--- a/debian/control
+++ b/debian/control
@@ -152,7 +152,7 @@ Depends:
   console-data,
   dropbear,
 # End "service console-server"
-# For "set service aws glb"
+# For "service aws glb"
   aws-gwlbtun,
 # For "service dns dynamic"
   ddclient (>= 3.11.1),
@@ -160,6 +160,9 @@ Depends:
 # # For "service ids"
   fastnetmon [amd64],
 # End "service ids"
+# # For "service ndp-proxy"
+  ndppd,
+# End "service ndp-proxy"
 # For "service router-advert"
   radvd,
 # End "service route-advert"
@@ -251,18 +254,15 @@ Depends:
 # For "nat64"
   jool,
 # End "nat64"
-# For nat66
-  ndppd,
-# End nat66
 # For "system ntp"
   chrony,
 # End "system ntp"
 # For "vpn openconnect"
   ocserv,
 # End "vpn openconnect"
-# For "set system flow-accounting"
+# For "system flow-accounting"
   pmacct (>= 1.6.0),
-# End "set system flow-accounting"
+# End "system flow-accounting"
 # For container
   podman,
   netavark,
diff --git a/interface-definitions/include/version/nat66-version.xml.i b/interface-definitions/include/version/nat66-version.xml.i
index 478ca080f..43a54c969 100644
--- a/interface-definitions/include/version/nat66-version.xml.i
+++ b/interface-definitions/include/version/nat66-version.xml.i
@@ -1,3 +1,3 @@
 <!-- include start from include/version/nat66-version.xml.i -->
-<syntaxVersion component='nat66' version='2'></syntaxVersion>
+<syntaxVersion component='nat66' version='3'></syntaxVersion>
 <!-- include end -->
diff --git a/interface-definitions/service_ndp-proxy.xml.in b/interface-definitions/service_ndp-proxy.xml.in
new file mode 100644
index 000000000..9801c99ab
--- /dev/null
+++ b/interface-definitions/service_ndp-proxy.xml.in
@@ -0,0 +1,132 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+  <node name="service">
+    <children>
+      <node name="ndp-proxy" owner="${vyos_conf_scripts_dir}/service_ndp-proxy.py">
+        <properties>
+          <help>Neighbor Discovery Protocol (NDP) Proxy</help>
+        </properties>
+        <children>
+          <leafNode name="route-refresh">
+            <properties>
+              <help>Refresh interval for IPv6 routes</help>
+              <valueHelp>
+                <format>u32:10000-120000</format>
+                <description>Time in milliseconds</description>
+              </valueHelp>
+              <constraint>
+                <validator name="numeric" argument="--range 10000-120000"/>
+              </constraint>
+              <constraintErrorMessage>Route-refresh must be between 10000 and 120000 milliseconds</constraintErrorMessage>
+            </properties>
+            <defaultValue>30000</defaultValue>
+          </leafNode>
+          <tagNode name="interface">
+            <properties>
+              <help>NDP proxy listener interface</help>
+              <completionHelp>
+                <script>${vyos_completion_dir}/list_interfaces</script>
+              </completionHelp>
+              <constraint>
+                #include <include/constraint/interface-name.xml.i>
+              </constraint>
+            </properties>
+            <children>
+              #include <include/generic-disable-node.xml.i>
+              <leafNode name="enable-router-bit">
+                <properties>
+                  <help>Enable router bit in Neighbor Advertisement messages</help>
+                  <valueless/>
+                  </properties>
+                </leafNode>
+              <leafNode name="timeout">
+                <properties>
+                  <help>Timeout for Neighbor Advertisement after Neighbor Solicitation message</help>
+                  <valueHelp>
+                    <format>u32:500-120000</format>
+                    <description>Timeout in milliseconds</description>
+                  </valueHelp>
+                  <constraint>
+                    <validator name="numeric" argument="--range 500-120000"/>
+                  </constraint>
+                  <constraintErrorMessage>Timeout must be between 500 and 120000 milliseconds</constraintErrorMessage>
+                </properties>
+                <defaultValue>500</defaultValue>
+              </leafNode>
+              <leafNode name="ttl">
+                <properties>
+                  <help>Proxy entry cache Time-To-Live</help>
+                  <valueHelp>
+                    <format>u32:10000-120000</format>
+                    <description>Time in milliseconds</description>
+                  </valueHelp>
+                  <constraint>
+                    <validator name="numeric" argument="--range 10000-120000"/>
+                  </constraint>
+                  <constraintErrorMessage>TTL must be between 10000 and 120000 milliseconds</constraintErrorMessage>
+                </properties>
+                <defaultValue>30000</defaultValue>
+              </leafNode>
+              <tagNode name="prefix">
+                <properties>
+                  <help>Prefix target addresses are matched against</help>
+                  <valueHelp>
+                    <format>ipv6net</format>
+                    <description>IPv6 network prefix</description>
+                  </valueHelp>
+                  <valueHelp>
+                    <format>ipv6</format>
+                    <description>IPv6 address</description>
+                  </valueHelp>
+                  <constraint>
+                    <validator name="ipv6-prefix"/>
+                    <validator name="ipv6-address"/>
+                  </constraint>
+                </properties>
+                <children>
+                  #include <include/generic-disable-node.xml.i>
+                  <leafNode name="mode">
+                    <properties>
+                      <help>Specify the running mode of the rule</help>
+                      <completionHelp>
+                        <list>static auto interface</list>
+                      </completionHelp>
+                      <valueHelp>
+                        <format>static</format>
+                        <description>Immediately answer any Neighbor Solicitation Messages</description>
+                      </valueHelp>
+                      <valueHelp>
+                        <format>auto</format>
+                        <description>Check for a matching route in /proc/net/ipv6_route</description>
+                      </valueHelp>
+                      <valueHelp>
+                        <format>interface</format>
+                        <description>Forward Neighbor Solicitation message through specified interface</description>
+                      </valueHelp>
+                      <constraint>
+                        <regex>(static|auto|interface)</regex>
+                      </constraint>
+                      <constraintErrorMessage>Mode must be either one of: static, auto or interface</constraintErrorMessage>
+                    </properties>
+                    <defaultValue>static</defaultValue>
+                  </leafNode>
+                  <leafNode name="interface">
+                    <properties>
+                      <help>Interface to forward Neighbor Solicitation message through. Required for "iface" mode</help>
+                      <completionHelp>
+                        <script>${vyos_completion_dir}/list_interfaces</script>
+                      </completionHelp>
+                      <constraint>
+                        #include <include/constraint/interface-name.xml.i>
+                      </constraint>
+                    </properties>
+                  </leafNode>
+                </children>
+              </tagNode>
+            </children>
+          </tagNode>
+        </children>
+      </node>
+    </children>
+  </node>
+</interfaceDefinition>
diff --git a/op-mode-definitions/monitor-log.xml.in b/op-mode-definitions/monitor-log.xml.in
index 3a8118dcb..c03ec4cce 100644
--- a/op-mode-definitions/monitor-log.xml.in
+++ b/op-mode-definitions/monitor-log.xml.in
@@ -120,6 +120,12 @@
             </properties>
             <command>journalctl --no-hostname --boot --follow --dmesg</command>
           </leafNode>
+          <leafNode name="ndp-proxy">
+            <properties>
+              <help>Monitor last lines of Neighbor Discovery Protocol (NDP) Proxy</help>
+            </properties>
+            <command>journalctl --no-hostname --boot --follow --unit ndppd.service</command>
+          </leafNode>
           <leafNode name="nhrp">
             <properties>
               <help>Monitor last lines of Next Hop Resolution Protocol log</help>
diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in
index 399c6acf8..b013bdfe4 100644
--- a/op-mode-definitions/show-log.xml.in
+++ b/op-mode-definitions/show-log.xml.in
@@ -464,6 +464,12 @@
             </properties>
             <command>egrep -i "kernel:.*\[NAT-[A-Z]{3,}-[0-9]+(-MASQ)?\]" $(find /var/log -maxdepth 1 -type f -name messages\* | sort -t. -k2nr)</command>
           </leafNode>
+          <leafNode name="ndp-proxy">
+            <properties>
+              <help>Show log for Neighbor Discovery Protocol (NDP) Proxy</help>
+            </properties>
+            <command>journalctl --no-hostname --boot --unit ndppd.service</command>
+          </leafNode>
           <leafNode name="nhrp">
             <properties>
               <help>Show log for Next Hop Resolution Protocol (NHRP)</help>
diff --git a/smoketest/scripts/cli/test_service_ndp-proxy.py b/smoketest/scripts/cli/test_service_ndp-proxy.py
new file mode 100755
index 000000000..a947ec478
--- /dev/null
+++ b/smoketest/scripts/cli/test_service_ndp-proxy.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import unittest
+
+from base_vyostest_shim import VyOSUnitTestSHIM
+
+from vyos.configsession import ConfigSessionError
+from vyos.ifconfig import Section
+from vyos.utils.process import cmd
+from vyos.utils.process import process_named_running
+
+PROCESS_NAME = 'ndppd'
+NDPPD_CONF = '/run/ndppd/ndppd.conf'
+base_path = ['service', 'ndp-proxy']
+
+def getConfigSection(string=None, end=' {', endsection='^}'):
+    tmp = f'cat {NDPPD_CONF} | sed -n "/^{string}{end}/,/{endsection}/p"'
+    out = cmd(tmp)
+    return out
+
+class TestServiceNDPProxy(VyOSUnitTestSHIM.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        super(TestServiceNDPProxy, cls).setUpClass()
+
+        # ensure we can also run this test on a live system - so lets clean
+        # out the current configuration :)
+        cls.cli_delete(cls, base_path)
+
+    def tearDown(self):
+        # Check for running process
+        self.assertTrue(process_named_running(PROCESS_NAME))
+
+        # delete testing SSH config
+        self.cli_delete(base_path)
+        self.cli_commit()
+
+        self.assertFalse(process_named_running(PROCESS_NAME))
+
+    def test_basic(self):
+        interfaces = Section.interfaces('ethernet')
+        for interface in interfaces:
+            self.cli_set(base_path + ['interface', interface])
+            self.cli_set(base_path + ['interface', interface, 'enable-router-bit'])
+
+        self.cli_commit()
+
+        for interface in interfaces:
+            config = getConfigSection(f'proxy {interface}')
+            self.assertIn(f'proxy {interface}', config)
+            self.assertIn(f'router yes', config)
+            self.assertIn(f'timeout 500', config) # default value
+            self.assertIn(f'ttl 30000', config) # default value
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)
diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py
index 0ba08aef3..dee1551fe 100755
--- a/src/conf_mode/nat66.py
+++ b/src/conf_mode/nat66.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #
-# Copyright (C) 2020-2021 VyOS maintainers and contributors
+# Copyright (C) 2020-2023 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
@@ -36,7 +36,6 @@ airbag.enable()
 k_mod = ['nft_nat', 'nft_chain_nat']
 
 nftables_nat66_config = '/run/nftables_nat66.nft'
-ndppd_config = '/run/ndppd/ndppd.conf'
 
 def get_config(config=None):
     if config:
@@ -101,7 +100,6 @@ def generate(nat):
         nat['first_install'] = True
 
     render(nftables_nat66_config, 'firewall/nftables-nat66.j2', nat, permission=0o755)
-    render(ndppd_config, 'ndppd/ndppd.conf.j2', nat, permission=0o755)
     return None
 
 def apply(nat):
@@ -109,14 +107,6 @@ def apply(nat):
         return None
 
     cmd(f'nft -f {nftables_nat66_config}')
-
-    if 'deleted' in nat or not dict_search('source.rule', nat):
-        cmd('systemctl stop ndppd')
-        if os.path.isfile(ndppd_config):
-            os.unlink(ndppd_config)
-    else:
-        cmd('systemctl restart ndppd')
-
     call_dependents()
 
     return None
diff --git a/src/conf_mode/service_ndp-proxy.py b/src/conf_mode/service_ndp-proxy.py
new file mode 100755
index 000000000..aa2374f4c
--- /dev/null
+++ b/src/conf_mode/service_ndp-proxy.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configverify import verify_interface_exists
+from vyos.utils.process import call
+from vyos.template import render
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+systemd_service = 'ndppd.service'
+ndppd_config = '/run/ndppd/ndppd.conf'
+
+def get_config(config=None):
+    if config:
+        conf = config
+    else:
+        conf = Config()
+    base = ['service', 'ndp-proxy']
+    if not conf.exists(base):
+        return None
+
+    ndpp = conf.get_config_dict(base, key_mangling=('-', '_'),
+                                get_first_key=True,
+                                with_recursive_defaults=True)
+
+    return ndpp
+
+def verify(ndpp):
+    if not ndpp:
+        return None
+
+    if 'interface' in ndpp:
+        for interface, interface_config in ndpp['interface'].items():
+            verify_interface_exists(interface)
+
+            if 'rule' in interface_config:
+                for rule, rule_config in interface_config['rule'].items():
+                    if rule_config['mode'] == 'interface' and 'interface' not in rule_config:
+                        raise ConfigError(f'Rule "{rule}" uses interface mode but no interface defined!')
+
+                    if rule_config['mode'] != 'interface' and 'interface' in rule_config:
+                        if interface_config['mode'] != 'interface' and 'interface' in interface_config:
+                            raise ConfigError(f'Rule "{rule}" does not use interface mode, thus interface can not be defined!')
+
+    return None
+
+def generate(ndpp):
+    if not ndpp:
+        return None
+
+    render(ndppd_config, 'ndppd/ndppd.conf.j2', ndpp)
+    return None
+
+def apply(ndpp):
+    if not ndpp:
+        call(f'systemctl stop {systemd_service}')
+        if os.path.isfile(ndppd_config):
+            os.unlink(ndppd_config)
+        return None
+
+    call(f'systemctl reload-or-restart {systemd_service}')
+    return None
+
+if __name__ == '__main__':
+    try:
+        c = get_config()
+        verify(c)
+        generate(c)
+        apply(c)
+    except ConfigError as e:
+        print(e)
+        exit(1)
diff --git a/src/migration-scripts/nat66/2-to-3 b/src/migration-scripts/nat66/2-to-3
new file mode 100755
index 000000000..f34f170b3
--- /dev/null
+++ b/src/migration-scripts/nat66/2-to-3
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from sys import argv,exit
+from vyos.configtree import ConfigTree
+
+if len(argv) < 2:
+    print("Must specify file name!")
+    exit(1)
+
+file_name = argv[1]
+
+with open(file_name, 'r') as f:
+    config_file = f.read()
+
+base = ['nat66', 'source']
+new_base = ['service', 'ndp-proxy', 'interface']
+
+config = ConfigTree(config_file)
+if not config.exists(base):
+    # Nothing to do
+    exit(0)
+
+for rule in config.list_nodes(base + ['rule']):
+    base_rule = base + ['rule', rule]
+
+    interface = None
+    if config.exists(base_rule + ['outbound-interface', 'name']):
+        interface = config.return_value(base_rule + ['outbound-interface', 'name'])
+    else:
+        continue
+
+    prefix_base = base_rule + ['source', 'prefix']
+    if config.exists(prefix_base):
+        prefix = config.return_value(prefix_base)
+        config.set(new_base + [interface, 'prefix', prefix, 'mode'], value='static')
+        config.set_tag(new_base)
+        config.set_tag(new_base + [interface, 'prefix'])
+
+        if config.exists(base_rule + ['disable']):
+            config.set(new_base + [interface, 'prefix', prefix, 'disable'])
+
+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)
-- 
cgit v1.2.3