feat: add Nebula mesh network overlay
buildbot/nix-eval Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-exodus Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-isaiah Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-genesis Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-linode Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-jeremiah Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-zeke Build done.
buildbot/nix-build gitea:greg/nixos#checks.x86_64-linux.nixos-hosea Build done.
buildbot/nix-build Build done.

Introduces a greg.nebula NixOS module and enables it across all managed
hosts for the nebula.thehellings.com overlay (CIDR: 10.157.0.0/16).

Architecture:
- linode: lighthouse + relay (public internet, UDP 4242)
- genesis: regular node + unsafe_routes router for 10.42.0.0/16 (home LAN)
- hosea, isaiah, jeremiah, zeke, exodus: regular nodes with unsafe_routes
  pointing to genesis to reach the home LAN

Changes:
- modules/nixos/nebula.nix: new greg.nebula module
  - isLighthouse / isRelay options
  - unsafeRoutes option (tun.unsafe_routes)
  - routesSubnet option: enables IP forwarding + nftables masquerade NAT
    on the gateway host (genesis) so Nebula peers reach 10.42.0.0/16
  - agenix secret reference per-host (secrets/nebula/<name>.key.age)
  - opens UDP/4242 in the firewall
- modules/nixos/default.nix: import nebula.nix
- hosts/unstable/linode/default.nix: greg.nebula.isLighthouse = true
- hosts/unstable/genesis/default.nix: greg.nebula.routesSubnet = "10.42.0.0/16"
- hosts/unstable/{hosea,isaiah,jeremiah,zeke,exodus}/default.nix:
  greg.nebula.enable = true with unsafeRoutes via genesis
- network.json: add nebulaIp field for each managed host
- secrets/secrets.nix: declare nebula/<host>.key.age entries
- secrets/nebula/README.md: full PKI bootstrap guide (CA, certs, agenix)
This commit is contained in:
root
2026-03-28 23:24:52 -05:00
parent 07b3601206
commit 792217f640
13 changed files with 451 additions and 10 deletions
+1
View File
@@ -22,6 +22,7 @@
./router.nix
./rpi4.nix
./syncthing.nix
./nebula.nix
./tailscale.nix
./vmdev.nix
];
+222
View File
@@ -0,0 +1,222 @@
{
config,
lib,
metadata,
...
}:
# Nebula overlay mesh network module (greg namespace)
#
# This module configures a Nebula node for the nebula.thehellings.com overlay.
# CIDR: 10.157.0.0/16
# Lighthouse: linode (public internet, acts as relay too)
#
# Each host requires:
# secrets/nebula/<hostname>.key.age - encrypted private key
# secrets/nebula/<hostname>.crt - certificate (public, unencrypted in repo)
# secrets/nebula/ca.crt - CA certificate (public, unencrypted in repo)
#
# The nebula IP for each host must be set in network.json under
# hosts.<name>.nebulaIp (e.g. "10.157.0.1")
#
# Lighthouse address is the public IP/DNS of linode. Update the
# `lighthouseAddrs` option below or override per-host if it changes.
let
cfg = config.greg.nebula;
nebulaDomain = "nebula.thehellings.com";
# Linode's public address — used by all non-lighthouse hosts to reach it.
# Override with greg.nebula.lighthouseAddr if the public IP ever changes.
defaultLighthouseAddr = "linode.${nebulaDomain}";
in
{
options.greg.nebula = {
enable = lib.mkEnableOption "Nebula overlay mesh network";
isLighthouse = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether this host is a Nebula lighthouse/relay node";
};
isRelay = lib.mkOption {
type = lib.types.bool;
default = cfg.isLighthouse;
description = "Whether this host acts as a relay (am_relay). Defaults to true when isLighthouse is true.";
};
nebulaIp = lib.mkOption {
type = lib.types.str;
description = "This host's Nebula overlay IP (e.g. 10.157.0.1)";
default =
let
hostData = metadata.hosts.${config.networking.hostName} or { };
in
hostData.nebulaIp or (throw "greg.nebula.nebulaIp must be set for host ${config.networking.hostName}");
};
lighthouseAddr = lib.mkOption {
type = lib.types.str;
default = defaultLighthouseAddr;
description = "Public address (host:port) used to reach the lighthouse from non-lighthouse hosts";
};
lighthouseNebulaIp = lib.mkOption {
type = lib.types.str;
default =
let
linodeData = metadata.hosts.linode or { };
in
linodeData.nebulaIp or "10.157.0.1";
description = "Nebula overlay IP of the lighthouse host";
};
port = lib.mkOption {
type = lib.types.port;
default = 4242;
description = "UDP port Nebula listens on";
};
# unsafe_routes: allow non-Nebula subnets to be routed through this host.
# Used on genesis to expose 10.42.0.0/16 (the home LAN) to the overlay.
unsafeRoutes = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
route = lib.mkOption {
type = lib.types.str;
description = "CIDR to route through this host (e.g. 10.42.0.0/16)";
};
via = lib.mkOption {
type = lib.types.str;
description = "Nebula overlay IP of the host that provides the route";
};
};
}
);
default = [ ];
description = "List of unsafe_routes to configure on this host (for reaching non-Nebula subnets)";
};
# Whether this host IS the router for an unsafe subnet
# (enables IP forwarding + nftables masquerade for the home LAN)
routesSubnet = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
When set, this host will route traffic from the Nebula overlay
to this subnet. Enables IP forwarding and nftables masquerade.
Example: "10.42.0.0/16"
'';
};
};
config = lib.mkIf cfg.enable {
# agenix: decrypt this host's Nebula private key at boot
age.secrets."nebula-${config.networking.hostName}-key" = {
file = ../../secrets/nebula/${config.networking.hostName}.key.age;
# nebula service runs as root, key owned by root is fine
mode = "0400";
};
services.nebula.networks.${nebulaDomain} = {
enable = true;
# CA certificate (public — lives unencrypted in the repo)
ca = ../../secrets/nebula/ca.crt;
# Host certificate (public — lives unencrypted in the repo)
cert = ../../secrets/nebula/${config.networking.hostName}.crt;
# Private key (agenix-decrypted at runtime)
key = config.age.secrets."nebula-${config.networking.hostName}-key".path;
# Static host map: tell every node where the lighthouse lives
staticHostMap = {
"${cfg.lighthouseNebulaIp}" = [ "${cfg.lighthouseAddr}:${toString cfg.port}" ];
};
isLighthouse = cfg.isLighthouse;
isRelay = cfg.isRelay;
listen = {
host = "0.0.0.0";
port = cfg.port;
};
lighthouses = lib.optionals (!cfg.isLighthouse) [ cfg.lighthouseNebulaIp ];
relays = lib.optionals (!cfg.isLighthouse && !cfg.isRelay) [ cfg.lighthouseNebulaIp ];
tun = {
# Interface name: nebula.<domain-first-label>
dev = "nebula0";
# unsafe_routes for hosts that need access to a non-Nebula subnet
unsafeRoutes = cfg.unsafeRoutes;
};
# Firewall: permissive defaults — tighten per-host as desired
firewall = {
outbound = [
{
port = "any";
proto = "any";
host = "any";
}
];
inbound =
[
# Allow ICMP (ping) from any Nebula peer
{
port = "any";
proto = "icmp";
host = "any";
}
# Allow all traffic from within the Nebula overlay
{
port = "any";
proto = "any";
host = "any";
}
]
# When routing an unsafe subnet, allow inbound traffic destined
# for that subnet from any Nebula peer (local_cidr scopes it)
++ lib.optionals (cfg.routesSubnet != null) [
{
port = "any";
proto = "any";
host = "any";
local_cidr = cfg.routesSubnet;
}
];
};
};
# Open the Nebula UDP port in the firewall
networking.firewall.allowedUDPPorts = [ cfg.port ];
# When this host routes traffic to a non-Nebula subnet, enable IP
# forwarding and add nftables masquerade rules (see unsafe_routes guide).
boot.kernel.sysctl = lib.mkIf (cfg.routesSubnet != null) {
"net.ipv4.ip_forward" = lib.mkDefault "1";
};
networking.nftables.tables = lib.mkIf (cfg.routesSubnet != null) {
nebula_routing = {
family = "ip";
content = ''
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.157.0.0/16 ip daddr ${cfg.routesSubnet} counter masquerade
}
chain forward {
type filter hook forward priority filter; policy accept;
ct state related,established counter accept
iifname "nebula0" ip saddr 10.157.0.0/16 ip daddr ${cfg.routesSubnet} counter accept
}
'';
};
};
};
}