Commit 076028be557e

Vincent Demeester <vincent@sbr.pm>
2026-08-11 19:23:22
feat(lutece): added Scaleway Dedibox VPS in Paris
Lutece joins the mesh on wg0 and serves a travel tunnel on wg1, giving a French exit IP from abroad while still reaching the homelab. Travel traffic is masqueraded there, so no other host needs a route for the new subnet. Terraform is absent on purpose: Dedibox has no provider resources, and a monthly-commitment VPS is ordered once by hand.
1 parent 08a4f18
infra/lutece/README.md
@@ -0,0 +1,116 @@
+# lutece — Scaleway Dedibox VPS (VPS-START-2-S, Paris)
+
+NixOS VPS at `64.31.63.130`, installed with `nixos-anywhere` + disko. Two roles:
+
+- **homelab mesh client** on `wg0` (`10.100.0.3`), peer of carthage
+- **travel exit node** on `wg1` (`10.101.0.0/24`) — full tunnel giving a French
+  IP from abroad, plus access to the homelab
+
+## Why no Terraform
+
+Dedibox is on the legacy Online.net platform and has **no resources in the
+`scaleway/scaleway` Terraform provider** (its `scaleway_baremetal_server` is
+Elastic Metal, a different product). It is scriptable through the `dedibox/v1`
+API (`scw dedibox ...`), but a VPS on monthly commitment is a pet, ordered once
+by hand. The declarative part lives in `systems/lutece/`.
+
+## Hardware facts (verified on the running host)
+
+| | |
+|---|---|
+| Disk | `/dev/vda`, 30 GB, virtio |
+| Firmware | BIOS (GPT + `EF02` boot partition, GRUB) |
+| CPU / RAM | 2 vCPU / 1.9 GB + 4 GB swapfile |
+| IPv4 | `64.31.63.130/24`, gw `64.31.63.1` — **static, no DHCP** |
+| IPv6 | `2a11:840:72:1b::/64` via SLAAC; stable address ends `:216:3eff:fea8:6cf3` |
+
+The static IPv4 is the important one: a DHCP assumption leaves the box
+unreachable after reboot. IPv6 is intentionally *not* pinned — its gateway is
+the provider router's link-local address.
+
+## Reinstall
+
+```console
+nix run github:nix-community/nixos-anywhere -- \
+  --flake .#lutece --build-on local root@64.31.63.130
+```
+
+Run it from a host whose key is in the VPS's `authorized_keys` (set at order
+time in the Dedibox console). It must be usable **non-interactively**, so a
+Yubikey/touch key will not do. Keep an IPv4 reachable —
+`nixos-anywhere` has known failures on IPv6-only Scaleway targets
+(nix-community/nixos-anywhere#227).
+
+Note the two keys under `/etc/wireguard/` are **not** in the Nix store and do
+not survive a reinstall; regenerate them as below.
+
+## WireGuard keys (manual, once)
+
+```console
+# mesh identity (wg0) — public key goes to globals.machines.lutece.net.vpn.pubkey
+umask 077; wg genkey > /etc/wireguard/private.key; wg pubkey < /etc/wireguard/private.key
+
+# travel server identity (wg1) — public key goes into travel client configs
+umask 077; wg genkey > /etc/wireguard/travel.key; wg pubkey < /etc/wireguard/travel.key
+```
+
+## Travel tunnel
+
+Peers are declared in `globals.net.travel.peers` (name → travel IP); their
+public keys are reused from the machine's mesh identity in
+`globals.machines.<name>.net.vpn.pubkey`.
+
+Client config, e.g. for hokkaido:
+
+```ini
+[Interface]
+PrivateKey = <the device's existing mesh private key>
+Address = 10.101.0.5/32
+DNS = 1.1.1.1
+MTU = 1340
+
+[Peer]
+PublicKey = d28ha+85FlP188kDkCDMX6I8iM20PtbTy6kj0sHvHwo=
+Endpoint = 64.31.63.130:51820
+AllowedIPs = 0.0.0.0/0, ::/0
+PersistentKeepalive = 25
+```
+
+Only `Address` changes per device:
+
+| Device | mesh | travel |
+|---|---|---|
+| hokkaido | `10.100.0.5` | `10.101.0.5/32` |
+| osaka (Boox tablet) | `10.100.0.64` | `10.101.0.64/32` |
+| suzu | `10.100.0.65` | `10.101.0.65/32` |
+| houbeb-makcbook-pro | `10.100.0.71` | `10.101.0.71/32` |
+
+`AllowedIPs = 0.0.0.0/0` is what makes it a full tunnel: web traffic exits in
+Paris with a French IP, and `10.100.0.0/24` reaches the homelab through
+lutece's `wg0`.
+
+**How the return path works:** lutece masquerades `10.101.0.0/24` on
+POSTROUTING with no output interface, so homelab hosts see traffic as coming
+from `10.100.0.3` and need no route for the travel subnet. That is why adding
+this feature required no change to rhea/aion/carthage's config.
+
+Caveat: this is a hosting IP range, not residential. Geo-restricted services
+that block datacenter ranges may still refuse it.
+
+## Deploy order
+
+1. Generate `/etc/wireguard/travel.key` on lutece (above).
+2. Deploy lutece: `nixos-rebuild switch --flake .#lutece --target-host root@64.31.63.130`
+3. Redeploy carthage so it accepts lutece as a mesh peer.
+4. Add the travel tunnel to the device and test: `curl ifconfig.me` should show
+   `64.31.63.130`.
+
+## Open items
+
+- Root only accepts touch-required keys, so nothing can manage lutece
+  non-interactively. Add an `aomi-tpm` entry for `lutece` in
+  `globals.ssh.vincent` if automation is wanted.
+- SSH is still public; restrict it to the VPN address once the mesh is up (see
+  the TODO in `systems/lutece/extra.nix`).
+- Travel peers currently reuse their mesh keypair. Separate keys would be
+  cleaner; it means provisioning a new keypair per device.
systems/lutece/boot.nix
@@ -0,0 +1,35 @@
+{ lib, ... }:
+{
+  console.keyMap = lib.mkForce "us";
+
+  # disko handles GRUB device configuration
+  boot.loader.grub.enable = lib.mkForce true;
+  boot.loader.systemd-boot.enable = lib.mkForce false;
+  boot.initrd.systemd.enable = lib.mkForce false;
+
+  # Scaleway virtual instance (QEMU/KVM guest)
+  boot.initrd.availableKernelModules = [
+    "virtio_net"
+    "virtio_pci"
+    "virtio_mmio"
+    "virtio_blk"
+    "virtio_scsi"
+    "9p"
+    "9pnet_virtio"
+  ];
+  boot.initrd.kernelModules = [
+    "virtio_balloon"
+    "virtio_console"
+    "virtio_rng"
+  ];
+
+  # Make the Scaleway web serial console usable
+  boot.kernelParams = [ "console=ttyS0" ];
+
+  # VPS optimization: No physical hardware, no firmware needed
+  hardware.enableRedistributableFirmware = lib.mkForce false;
+  hardware.enableAllFirmware = lib.mkForce false;
+
+  # VPS optimization: No firmware update service needed
+  services.fwupd.enable = lib.mkForce false;
+}
systems/lutece/extra.nix
@@ -0,0 +1,46 @@
+{
+  globals,
+  lib,
+  libx,
+  ...
+}:
+{
+  imports = [
+    ../common/services/openssh.nix
+  ];
+
+  # Disable TPM2 (VPS has no TPM hardware)
+  security.tpm2.enable = lib.mkForce false;
+
+  # ── Travel exit node ───────────────────────────────────────────
+  # wg0 is the homelab mesh client (to carthage, auto-derived from globals).
+  # wg1 gives travel devices a French IP: they set AllowedIPs = 0.0.0.0/0 and
+  # everything exits here. The module's POSTROUTING masquerade has no output
+  # interface, so it covers both internet egress via eth0 and traffic forwarded
+  # into wg0 towards the homelab — which is why no other host needs a route for
+  # the travel subnet.
+  services.wireguard.server = {
+    enable = true;
+    interface = "wg1";
+    privateKeyFile = "/etc/wireguard/travel.key";
+    natSource = globals.net.travel.subnet;
+    ips = [ "10.101.0.1/24" ];
+    peers = lib.mapAttrsToList (name: ip: {
+      publicKey = globals.machines.${name}.net.vpn.pubkey;
+      allowedIPs = libx.wg-ips [ ip ];
+    }) globals.net.travel.peers;
+  };
+
+  networking.firewall = {
+    allowPing = true;
+    allowedTCPPorts = [
+      80 # HTTP
+      443 # HTTPS
+    ];
+  };
+
+  # TODO: once the WireGuard mesh is up, restrict SSH to the VPN interface like
+  # carthage does:
+  #   services.openssh.listenAddresses = [ { addr = <vpn ip>; port = 22; } ];
+  #   services.openssh.openFirewall = lib.mkForce false;
+}
systems/lutece/hardware.nix
@@ -0,0 +1,80 @@
+{ lib, ... }:
+{
+  # Disko declarative partitioning for Scaleway (DEV1-* local SSD)
+  # Verify the disk device with `lsblk` on the temporary Ubuntu before running
+  # nixos-anywhere: local `l_ssd` volumes show up as /dev/vda, block storage
+  # (`sbs_volume`, used by PLAY2/PRO2/POP2) as /dev/sda.
+  disko.devices = {
+    disk = {
+      main = {
+        device = "/dev/vda";
+        type = "disk";
+        content = {
+          type = "gpt";
+          partitions = {
+            boot = {
+              size = "1M";
+              type = "EF02"; # BIOS boot partition for GRUB
+            };
+            root = {
+              size = "100%";
+              content = {
+                type = "filesystem";
+                format = "ext4";
+                mountpoint = "/";
+                mountOptions = [
+                  "defaults"
+                  "noatime"
+                ];
+              };
+            };
+          };
+        };
+      };
+    };
+  };
+
+  swapDevices = [
+    {
+      device = "/swapfile";
+      size = 4096; # 2x RAM — the VPS only has 2GB
+    }
+  ];
+
+  # Dedibox VPS networking is STATIC — there is no DHCP server on this segment.
+  # Values taken from the Debian rescue install (`ip -br a`, `ip r`, `ip -6 r`).
+  # Interface names are pinned to eth0 (unpredictable naming) so the static
+  # config below cannot break on a kernel/udev rename.
+  networking = {
+    useDHCP = false;
+    usePredictableInterfaceNames = lib.mkForce false;
+
+    interfaces.eth0 = {
+      ipv4.addresses = [
+        {
+          address = "64.31.63.130";
+          prefixLength = 24;
+        }
+      ];
+    };
+
+    defaultGateway = {
+      address = "64.31.63.1";
+      interface = "eth0";
+    };
+
+    # IPv6 is autoconfigured from router advertisements (SLAAC), yielding the
+    # stable EUI-64 address 2a11:840:72:1b:216:3eff:fea8:6cf3 in
+    # 2a11:840:72:1b::/64. Deliberately not pinned: the v6 gateway is the
+    # provider router's link-local address, which is theirs to change.
+
+    nameservers = [
+      "1.1.1.1"
+      "8.8.8.8"
+    ];
+  };
+
+  # Keep honouring RAs even if IP forwarding is enabled later (accept_ra=1 is
+  # ignored once a host forwards; 2 keeps SLAAC working).
+  boot.kernel.sysctl."net.ipv6.conf.eth0.accept_ra" = 2;
+}
flake.nix
@@ -112,6 +112,12 @@
           homeInput = inputs.home-manager-26_05;
           agenixInput = inputs.agenix-26_05;
         };
+        lutece = libx.mkHost {
+          hostname = "lutece";
+          pkgsInput = inputs.nixpkgs-26_05;
+          homeInput = inputs.home-manager-26_05;
+          agenixInput = inputs.agenix-26_05;
+        };
         carthage = libx.mkHost {
           hostname = "carthage";
           pkgsInput = inputs.nixpkgs-26_05;
globals.nix
@@ -185,6 +185,27 @@ _: {
     vpn = {
       endpoint = "46.224.100.116";
     };
+    # Travel tunnel: lutece (Paris) as a full-tunnel exit node, used to get a
+    # French IP from abroad. Separate subnet and separate interface (wg1) from
+    # the 10.100.0.0/24 homelab mesh.
+    #
+    # Traffic from these peers is masqueraded on lutece, so the rest of the
+    # mesh sees 10.100.0.3 as the source and needs no route for 10.101.0.0/24.
+    travel = {
+      subnet = "10.101.0.0/24";
+      server = "lutece";
+      # Public key of /etc/wireguard/travel.key on lutece, for client configs.
+      pubkey = "d28ha+85FlP188kDkCDMX6I8iM20PtbTy6kj0sHvHwo=";
+      # name -> travel IP. Public keys are reused from the machine's mesh
+      # identity (globals.machines.<name>.net.vpn.pubkey); last octet mirrors
+      # the machine's VPN address for readability.
+      peers = {
+        hokkaido = "10.101.0.5";
+        osaka = "10.101.0.64";
+        suzu = "10.101.0.65";
+        houbeb-makcbook-pro = "10.101.0.71";
+      };
+    };
     nfs = {
       defaultHosts = {
         rhea = {
@@ -414,6 +435,25 @@ _: {
         vincent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxstR3xEf87leVVDS3GVPx8Ap9+eP+OfkSvM26V54XP vincent@shikoku";
       };
     };
+    # Scaleway Dedibox VPS (VPS-START-2-S, Paris) — see infra/lutece
+    # Mesh client on wg0 (10.100.0.3) and travel exit server on wg1
+    # (see globals.net.travel).
+    lutece = {
+      net = {
+        ips = [ "64.31.63.130" ];
+        vpn = {
+          pubkey = "H7q7Qz7JqAKXlEbNMBW3quac2GDASAZ0SYvCvJ96NTo=";
+          ips = [ "10.100.0.3" ];
+        };
+        names = [
+          "lutece.vpn"
+          "lutece.sbr.pm"
+        ];
+      };
+      ssh = {
+        hostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII0QxlxWPQfd4t8IxqhLKFRXPVfrRSJBpUSXipIcKOVm"; # ssh-keyscan -q -t ed25519 64.31.63.130
+      };
+    };
     # Hetzner Cloud VPS
     # TODO: Update pubkey, hostKey, and syncthing id after provisioning
     carthage = {