Commit 3cb2915
Changed files (7)
modules
hosts
hyacine
services
mobius
services
services
modules/hosts/hyacine/services/services.nix
@@ -1,5 +1,7 @@
{den, ...}: {
den.hosts.hyacine.settings = {
+ services.webserver.external = true;
+
services.headscale = {
domain = "gate.trin.one";
port = 3465;
modules/hosts/mobius/services/services.nix
@@ -1,6 +1,8 @@
{den, ...}: {
den.hosts.mobius = {
settings = {
+ services.webserver.external = false;
+
services.woodpecker.agent.agents = {
codeberg = {
server = "grpc.ci.codeberg.org:443";
@@ -15,6 +17,8 @@
den.aspects.mobius = {
includes = with den.aspects; [
+ services.webserver
+
services.woodpecker.agent
];
};
modules/services/webserver/caddy.nix
@@ -21,7 +21,6 @@
nixos = {
services.caddy = {
enable = true;
- # Reload Caddy instead of restarting it when configuration file changes.
enableReload = true;
globalConfig = ''
@@ -37,9 +36,12 @@
den.aspects.services.webserver.caddy.reverse-proxy-collector = {host, ...}: {
nixos = {
reverseProxy,
+ reverseProxyTailscale,
config,
...
}: let
+ external = host.settings.services.webserver.external;
+
# Wrap IPv6 addresses in brackets so they can be used in a URL authority.
fmtAddr = isV6: addr:
if isV6
@@ -120,43 +122,85 @@
sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
- entries =
+ mkEntry = tailscale: resolution: domain: conf: {
+ inherit domain resolution tailscale;
+ https = conf.https or false;
+ port = conf.port;
+ };
+
+ publicEntries =
+ lib.concatMap (
+ r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
+ )
+ reverseProxy;
+
+ tailscaleEntries =
lib.concatMap (
r:
- lib.mapAttrsToList (
- domain: conf: {
- inherit domain;
- https = conf.https or false;
- port = conf.port;
- resolution = resolveAddress r.source.host;
- }
- )
+ lib.mapAttrsToList (mkEntry true {
+ kind = "inline";
+ address = "127.0.0.1";
+ })
r.value
)
- reverseProxy;
+ (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
+
+ entries = tailscaleEntries ++ lib.optionals external publicEntries;
+
+ proxyLine = e:
+ if e.resolution.kind == "import"
+ then "import ${config.vaultix.templates.${templateName e.domain}.path}"
+ else "reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}";
+
+ entryLines = e:
+ if !e.tailscale
+ then [(proxyLine e)]
+ else if (host.hasAspect den.aspects.services.tailscale-nginx-auth)
+ then [
+ ''
+ forward_auth unix//run/tailscale.nginx-auth.sock {
+ uri /auth
+ header_up Remote-Addr {remote_host}
+ header_up Remote-Port {remote_port}
+ header_up Original-URI {uri}
+ copy_headers {
+ Tailscale-User>X-Webauth-User
+ Tailscale-Name>X-Webauth-Name
+ Tailscale-Login>X-Webauth-Login
+ Tailscale-Tailnet>X-Webauth-Tailnet
+ Tailscale-Profile-Picture>X-Webauth-Profile-Picture
+ }
+ }
+ ''
+ (proxyLine e)
+ ]
+ else
+ abort ''
+ Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.
+
+ Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable
+ Tailscale authentication for reverse-proxied services.
+ '';
importEntries = lib.filter (e: e.resolution.kind == "import") entries;
+ # Tailscale-only vhosts are served over plain HTTP: their domains have no
+ # public DNS, so Caddy's automatic HTTPS would fail trying to reach a
+ # public ACME CA. And self-hosted Headscale did'nt support HTTPS serve yet.
+ # The `http://` scheme disables automatic HTTPS for the site
+ # (Tailscale/WireGuard already encrypts the transport). Public vhosts keep
+ # automatic HTTPS.
+ siteName = e:
+ if e.tailscale
+ then "http://${e.domain}"
+ else e.domain;
+
virtualHosts = lib.listToAttrs (map (e: {
- name = e.domain;
- value = {
- extraConfig =
- if e.resolution.kind == "import"
- then ''
- encode zstd gzip
- import ${config.vaultix.templates.${templateName e.domain}.path}
- ''
- else ''
- encode zstd gzip
- reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}
- '';
- };
+ name = siteName e;
+ value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ entryLines e);
})
entries);
- # Each secret IP is rendered into a Caddyfile snippet through vaultix, so
- # the plain text address never lands in the Nix store. Caddy then splices
- # the snippet in with the `import` directive.
reverseProxyTemplates = lib.listToAttrs (map (e: {
name = templateName e.domain;
value = {
modules/services/webserver/default.nix
@@ -1,7 +1,28 @@
-{den, ...}: {
+{
+ den,
+ lib,
+ ...
+}: {
den.aspects.services.webserver = {
+ settings = {
+ external = lib.mkOption {
+ description = ''
+ Whether this host's webserver is publicly reachable. Every host serves
+ its own Tailscale-only reverse proxies. When true, caddy additionally
+ serves the fleet-wide public reverse proxies on every interface
+ (0.0.0.0/::), and its Tailscale-only vhosts are kept off the public
+ interface by rejecting non-Tailscale source IPs. When false, caddy
+ only serves the host's own Tailscale reverse proxies, bound to the
+ host's own Tailscale IP.
+ '';
+ type = lib.types.bool;
+ default = false;
+ };
+ };
+
includes = [
den.aspects.services.webserver.reverse-proxy-processer
+ den.aspects.services.webserver.reverse-proxy-tailscale-processer
den.aspects.services.webserver.caddy
];
modules/services/webserver/quirks.nix
@@ -11,6 +11,11 @@
# }
den.quirks.reverseProxy.description = "Reverse proxies for web services";
+ # Mirrors reverseProxy, but these services are never exposed publicly: caddy
+ # only serves them on the owning host's own Tailscale interface, and headscale
+ # publishes them as Magic DNS records pointing at that host's Tailscale IP.
+ den.quirks.reverseProxyTailscale.description = "Reverse proxies for web services exposed only over Tailscale";
+
den.aspects.services.webserver.reverse-proxy-processer = {
__isPolicy = true;
fn = _: let
@@ -22,4 +27,16 @@
])
];
};
+
+ den.aspects.services.webserver.reverse-proxy-tailscale-processer = {
+ __isPolicy = true;
+ fn = _: let
+ inherit (den.lib.policy) pipe;
+ in [
+ (pipe.from den.quirks.reverseProxyTailscale [
+ (pipe.collect ({host, ...}: true))
+ pipe.withProvenance
+ ])
+ ];
+ };
}
modules/services/headscale.nix
@@ -52,11 +52,36 @@
host,
pkgs,
config,
+ reverseProxyTailscale,
...
}: let
cfg = host.settings.services.headscale;
hostAddr = host.address;
+ # Publish a Magic DNS record for every host's Tailscale-only reverse proxy,
+ # pointing each domain at the owning host's Tailscale IP.
+ tailscaleRecords =
+ lib.concatMap (
+ r: let
+ srcAddr = r.source.host.address;
+ in
+ lib.concatMap (
+ domain:
+ (lib.optional (srcAddr.ipv4.tailscale != null) {
+ name = domain;
+ type = "A";
+ value = srcAddr.ipv4.tailscale;
+ })
+ ++ (lib.optional (srcAddr.ipv6.tailscale != null) {
+ name = domain;
+ type = "AAAA";
+ value = srcAddr.ipv6.tailscale;
+ })
+ )
+ (builtins.attrNames r.value)
+ )
+ reverseProxyTailscale;
+
headscale-wrapped =
pkgs.runCommandLocal "headscale-wrapped" {
nativeBuildInputs = [pkgs.makeWrapper];
@@ -106,6 +131,7 @@
"2400:3200::1" # AliDNS
"2606:4700:4700::1111" # Cloudflare
];
+ extra_records = tailscaleRecords;
};
};
};
modules/services/tailscale-nginx-auth.nix
@@ -0,0 +1,30 @@
+{den, ...}: {
+ den.aspects.services.tailscale-nginx-auth = {
+ includes = [den.aspects.core.tailscale];
+
+ nixos = {pkgs, ...}: {
+ systemd.sockets.tailscale-nginx-auth = {
+ description = "Tailscale NGINX Authentication socket";
+ partOf = ["tailscale-nginx-auth.service"];
+ wantedBy = ["sockets.target"];
+ listenStreams = ["/run/tailscale.nginx-auth.sock"];
+ };
+
+ systemd.services.tailscale-nginx-auth = {
+ description = "Tailscale NGINX Authentication service";
+ requires = ["tailscale-nginx-auth.socket"];
+ after = ["tailscaled.service"];
+
+ serviceConfig = {
+ ExecStart = "${pkgs.tailscale-nginx-auth}/bin/tailscale.nginx-auth";
+ DynamicUser = true;
+ BindPaths = ["/run/tailscale/tailscaled.sock"];
+ PrivateDevices = true;
+ ProtectHome = true;
+ RestrictAddressFamilies = ["AF_UNIX"];
+ Restart = "on-failure";
+ };
+ };
+ };
+ };
+}