# This test verifies that we can request and assign IPv6 prefixes from upstream
# (e.g. ISP) routers.
# The setup consists of three VMs. One for the ISP, as your residential router
# and the third as a client machine in the residential network.
#
# There are two VLANs in this test:
# - VLAN 1 is the connection between the ISP and the router
# - VLAN 2 is the connection between the router and the client

import ./make-test-python.nix (
  { pkgs, lib, ... }:
  {
    name = "systemd-networkd-ipv6-prefix-delegation";
    meta = with lib.maintainers; {
      maintainers = [
        andir
        hexa
      ];
    };
    nodes = {

      # The ISP's routers job is to delegate IPv6 prefixes via DHCPv6. Like with
      # regular IPv6 auto-configuration it will also emit IPv6 router
      # advertisements (RAs). Those RA's will not carry a prefix but in contrast
      # just set the "Other" flag to indicate to the receiving nodes that they
      # should attempt DHCPv6.
      #
      # Note: On the ISPs device we don't really care if we are using networkd in
      # this example. That being said we can't use it (yet) as networkd doesn't
      # implement the serving side of DHCPv6. We will use ISC Kea for that task.
      isp =
        { lib, pkgs, ... }:
        {
          virtualisation.vlans = [ 1 ];
          networking = {
            useDHCP = false;
            firewall.enable = false;
            interfaces.eth1 = lib.mkForce { }; # Don't use scripted networking
          };

          systemd.network = {
            enable = true;

            networks = {
              "eth1" = {
                matchConfig.Name = "eth1";
                address = [
                  "2001:DB8::1/64"
                ];
                networkConfig.IPv4Forwarding = true;
                networkConfig.IPv6Forwarding = true;
              };
            };
          };

          # Since we want to program the routes that we delegate to the "customer"
          # into our routing table we must provide kea with the required capability.
          systemd.services.kea-dhcp6-server.serviceConfig = {
            AmbientCapabilities = [ "CAP_NET_ADMIN" ];
            CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
          };

          services = {
            # Configure the DHCPv6 server to hand out both IA_NA and IA_PD.
            #
            # We will hand out /48 prefixes from the subnet 2001:DB8:F000::/36.
            # That gives us ~8k prefixes. That should be enough for this test.
            #
            # Since (usually) you will not receive a prefix with the router
            # advertisements we also hand out /128 leases from the range
            # 2001:DB8:0000:0000:FFFF::/112.
            kea.dhcp6 = {
              enable = true;
              settings = {
                interfaces-config.interfaces = [ "eth1" ];
                subnet6 = [
                  {
                    id = 1;
                    interface = "eth1";
                    subnet = "2001:DB8::/32";
                    pd-pools = [
                      {
                        prefix = "2001:DB8:1000::";
                        prefix-len = 36;
                        delegated-len = 48;
                      }
                    ];
                    pools = [
                      {
                        pool = "2001:DB8:0000:0000::-2001:DB8:0FFF:FFFF::FFFF";
                      }
                    ];
                  }
                ];

                # This is the glue between Kea and the Kernel FIB. DHCPv6
                # rightfully has no concept of setting up a route in your
                # FIB. This step really depends on your setup.
                #
                # In a production environment your DHCPv6 server is likely
                # not the router. You might want to consider BGP, NETCONF
                # calls, … in those cases.
                #
                # In this example we use the run script hook, that lets use
                # execute anything and passes information via the environment.
                # https://kea.readthedocs.io/en/kea-2.2.0/arm/hooks.html#run-script-run-script-support-for-external-hook-scripts
                hooks-libraries = [
                  {
                    library = "${pkgs.kea}/lib/kea/hooks/libdhcp_run_script.so";
                    parameters = {
                      name = pkgs.writeShellScript "kea-run-hooks" ''
                        export PATH="${
                          lib.makeBinPath (
                            with pkgs;
                            [
                              coreutils
                              iproute2
                            ]
                          )
                        }"

                        set -euxo pipefail

                        leases6_committed() {
                          for i in $(seq $LEASES6_SIZE); do
                            idx=$((i-1))
                            prefix_var="LEASES6_AT''${idx}_ADDRESS"
                            plen_var="LEASES6_AT''${idx}_PREFIX_LEN"

                            ip -6 route replace ''${!prefix_var}/''${!plen_var} via $QUERY6_REMOTE_ADDR dev $QUERY6_IFACE_NAME
                          done
                        }

                        unknown_handler() {
                          echo "Unhandled function call ''${*}"
                          exit 123
                        }

                        case "$1" in
                            "leases6_committed")
                                leases6_committed
                                ;;
                            *)
                                unknown_handler "''${@}"
                                ;;
                        esac
                      '';
                      sync = false;
                    };
                  }
                ];
              };
            };

            # Finally we have to set up the router advertisements. While we could be
            # using networkd or bird for this task `radvd` is probably the most
            # venerable of them all. It was made explicitly for this purpose and
            # the configuration is much more straightforward than what networkd
            # requires.
            # As outlined above we will have to set the `Managed` flag as otherwise
            # the clients will not know if they should do DHCPv6. (Some do
            # anyway/always)
            radvd = {
              enable = true;
              config = ''
                interface eth1 {
                  AdvSendAdvert on;
                  AdvManagedFlag on;
                  AdvOtherConfigFlag off; # we don't really have DNS or NTP or anything like that to distribute
                  prefix ::/64 {
                    AdvOnLink on;
                    AdvAutonomous on;
                  };
                };
              '';
            };

          };
        };

      # This will be our (residential) router that receives the IPv6 prefix (IA_PD)
      # and /128 (IA_NA) allocation.
      #
      # Here we will actually start using networkd.
      router = {
        virtualisation.vlans = [
          1
          2
        ];
        systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug";

        boot.kernel.sysctl = {
          # we want to forward packets from the ISP to the client and back.
          "net.ipv6.conf.all.forwarding" = 1;
        };

        networking = {
          useNetworkd = true;
          useDHCP = false;
          # Consider enabling this in production and generating firewall rules
          # for fowarding/input from the configured interfaces so you do not have
          # to manage multiple places
          firewall.enable = false;
          interfaces.eth1.ipv6.addresses = lib.mkForce [ ];
        };

        systemd.network = {
          networks = {
            # systemd-networkd will load the first network unit file
            # that matches, ordered lexiographically by filename.
            # /etc/systemd/network/{40-eth1,99-main}.network already
            # exists. This network unit must be loaded for the test,
            # however, hence why this network is named such.

            # Configuration of the interface to the ISP.
            # We must request accept RAs and request the PD prefix.
            "01-eth1" = {
              name = "eth1";
              networkConfig = {
                Description = "ISP interface";
                IPv6AcceptRA = true;
                #DHCP = false; # no need for legacy IP
              };
              linkConfig = {
                # We care about this interface when talking about being "online".
                # If this interface is in the `routable` state we can reach
                # others and they should be able to reach us.
                RequiredForOnline = "routable";
              };
              # This configures the DHCPv6 client part towards the ISPs DHCPv6 server.
              dhcpV6Config = {
                # We have to include a request for a prefix in our DHCPv6 client
                # request packets.
                # Otherwise the upstream DHCPv6 server wouldn't know if we want a
                # prefix or not.  Note: On some installation it makes sense to
                # always force that option on the DHPCv6 server since there are
                # certain CPEs that are just not setting this field but happily
                # accept the delegated prefix.
                PrefixDelegationHint = "::/48";
              };
              ipv6SendRAConfig = {
                # Let networkd know that we would very much like to use DHCPv6
                # to obtain the "managed" information. Not sure why they can't
                # just take that from the upstream RAs.
                Managed = true;
              };
            };

            # Interface to the client. Here we should redistribute a /64 from
            # the prefix we received from the ISP.
            "01-eth2" = {
              name = "eth2";
              networkConfig = {
                Description = "Client interface";
                # The client shouldn't be allowed to send us RAs, that would be weird.
                IPv6AcceptRA = false;

                # Delegate prefixes from the DHCPv6 PD pool.
                DHCPPrefixDelegation = true;
                IPv6SendRA = true;
              };

              # In a production environment you should consider setting these as well:
              # ipv6SendRAConfig = {
              #EmitDNS = true;
              #EmitDomains = true;
              #DNS= = "fe80::1"; # or whatever "well known" IP your router will have on the inside.
              # };

              # This adds a "random" ULA prefix to the interface that is being
              # advertised to the clients.
              # Not used in this test.
              # ipv6Prefixes = [
              #   {
              #     ipv6PrefixConfig = {
              #       AddressAutoconfiguration = true;
              #       PreferredLifetimeSec = 1800;
              #       ValidLifetimeSec = 1800;
              #     };
              #   }
              # ];
            };

            # finally we are going to add a static IPv6 unique local address to
            # the "lo" interface.  This will serve as ICMPv6 echo target to
            # verify connectivity from the client to the router.
            "01-lo" = {
              name = "lo";
              addresses = [
                { Address = "FD42::1/128"; }
              ];
            };
          };
        };
      };

      # This is the client behind the router. We should be receiving router
      # advertisements for both the ULA and the delegated prefix.
      # All we have to do is boot with the default (networkd) configuration.
      client = {
        virtualisation.vlans = [ 2 ];
        systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug";
        networking = {
          useNetworkd = true;
          useDHCP = false;
          interfaces.eth1.ipv6.addresses = lib.mkForce [ ];
        };
      };
    };

    testScript = ''
      # First start the router and wait for it it reach a state where we are
      # certain networkd is up and it is able to send out RAs
      router.start()
      router.wait_for_unit("systemd-networkd.service")

      # After that we can boot the client and wait for the network online target.
      # Since we only care about IPv6 that should not involve waiting for legacy
      # IP leases.
      client.start()
      client.systemctl("start network-online.target")
      client.wait_for_unit("network-online.target")

      # the static address on the router should not be reachable
      client.wait_until_succeeds("ping -6 -c 1 FD42::1")

      # the global IP of the ISP router should still not be a reachable
      router.fail("ping -6 -c 1 2001:DB8::1")

      # Once we have internal connectivity boot up the ISP
      isp.start()

      # Since for the ISP "being online" should have no real meaning we just
      # wait for the target where all the units have been started.
      # It probably still takes a few more seconds for all the RA timers to be
      # fired etc..
      isp.wait_for_unit("multi-user.target")

      # wait until the uplink interface has a good status
      router.systemctl("start network-online.target")
      router.wait_for_unit("network-online.target")
      router.wait_until_succeeds("ping -6 -c1 2001:DB8::1")

      # shortly after that the client should have received it's global IPv6
      # address and thus be able to ping the ISP
      client.wait_until_succeeds("ping -6 -c1 2001:DB8::1")

      # verify that we got a globally scoped address in eth1 from the
      # documentation prefix
      ip_output = client.succeed("ip --json -6 address show dev eth1")

      import json

      ip_json = json.loads(ip_output)[0]
      assert any(
          addr["local"].upper().startswith("2001:DB8:")
          for addr in ip_json["addr_info"]
          if addr["scope"] == "global"
      )
    '';
  }
)