···11+let
22+ domain = "relay.lassul.us";
33+ # Plain HTTP/WS on loopback only; nginx terminates TLS in front of it.
44+ httpPort = 3340;
55+ # QUIC address discovery. Public UDP, and the one part nginx cannot proxy,
66+ # so the relay reads the ACME cert itself. 7842 is iroh's
77+ # DEFAULT_RELAY_QUIC_PORT -- clients derive it from the relay url and expect
88+ # it there, so do not move it without telling every client.
99+ quicPort = 7842;
1010+ certGroup = "irohrelaycert";
1111+1212+ configFile = (
1313+ pkgs:
1414+ pkgs.writeText "iroh-relay.toml" ''
1515+ enable_relay = true
1616+ http_bind_addr = "127.0.0.1:${toString httpPort}"
1717+1818+ # The whole reason to run our own relay: QAD tells an endpoint its own NAT
1919+ # mapping, which is what makes hole punching work. Without it this is just
2020+ # a data relay and peers never upgrade to direct. Requires TLS.
2121+ enable_quic_addr_discovery = true
2222+2323+ enable_metrics = true
2424+ metrics_bind_addr = "127.0.0.1:9096"
2525+2626+ [tls]
2727+ hostname = "${domain}"
2828+ quic_bind_addr = "[::]:${toString quicPort}"
2929+3030+ # `Reloading` re-reads the cert from disk periodically, so ACME renewals are
3131+ # picked up without a restart. `LetsEncrypt` is not usable here: it wants to
3232+ # own port 80 for the challenge, which nginx already has.
3333+ cert_mode = "Reloading"
3434+ manual_cert_path = "/var/lib/acme/${domain}/fullchain.pem"
3535+ manual_key_path = "/var/lib/acme/${domain}/key.pem"
3636+3737+ # Serve the relay's HTTP/WS side as plain HTTP so nginx can reverse-proxy
3838+ # it; the cert above is then used only by the QUIC listener. "dangerous"
3939+ # overstates it here: the plaintext hop is loopback, nginx does real TLS on
4040+ # 443, and relayed payloads are already end-to-end encrypted between iroh
4141+ # endpoints -- the relay only ever sees ciphertext.
4242+ dangerous_http_only = true
4343+ ''
4444+ );
4545+in
4646+{
4747+ pkgs,
4848+ ...
4949+}:
5050+{
5151+ # Self-hosted iroh relay.
5252+ #
5353+ # A relay supplies the two things a relay-free iroh endpoint cannot get for
5454+ # itself: QUIC address discovery (learning its own NAT mapping) and a
5555+ # signalling path to coordinate hole punching. Measured from a double-NATed
5656+ # host, relayed configs hole-punched to direct in ~80ms with 0 failures over
5757+ # 30 runs, while the relay-free config failed 100% of the time.
5858+ #
5959+ # Running our own also fixes the throughput floor: n0's public relays
6060+ # rate-limit to ~0.9 Mbps, which is fine for keystrokes and miserable for
6161+ # anything larger when hole punching fails.
6262+ #
6363+ # Clients opt in with `RelayMode::Custom`, e.g.
6464+ # iroh-bench --config dht-selfrelay --relay-url https://relay.lassul.us
6565+ # dumbpipe listen-tcp --relay-url https://relay.lassul.us (patched build)
6666+6767+ # nginx owns 80/443 and the ACME challenge, so it fetches the cert and the
6868+ # relay only reads it. A dedicated group keeps that read as narrow as
6969+ # possible; nginx must be a member to serve the vhost. Same pattern as
7070+ # mailserver.nix's `lasscert`.
7171+ security.acme.certs.${domain}.group = certGroup;
7272+ users.groups.${certGroup}.members = [ "nginx" ];
7373+7474+ services.nginx.virtualHosts.${domain} = {
7575+ enableACME = true;
7676+ forceSSL = true;
7777+ # One location covers every route the relay serves: `/relay` (the websocket
7878+ # clients ride), `/ping` and `/generate_204` (net_report latency probes,
7979+ # which is how a client picks its nearest relay out of a map).
8080+ locations."/" = {
8181+ proxyPass = "http://127.0.0.1:${toString httpPort}";
8282+ proxyWebsockets = true;
8383+ # Relay connections are long-lived and idle whenever a pair has upgraded
8484+ # to a direct path; nginx's default 60s read timeout would tear them down
8585+ # and force clients back through a reconnect.
8686+ extraConfig = ''
8787+ proxy_read_timeout 1d;
8888+ proxy_send_timeout 1d;
8989+ '';
9090+ };
9191+ };
9292+9393+ systemd.services.iroh-relay = {
9494+ description = "iroh relay (${domain})";
9595+ wantedBy = [ "multi-user.target" ];
9696+ after = [
9797+ "network-online.target"
9898+ "nginx.service"
9999+ ];
100100+ wants = [ "network-online.target" ];
101101+102102+ serviceConfig = {
103103+ ExecStart = "${pkgs.iroh-relay}/bin/iroh-relay --config-path ${configFile pkgs}";
104104+ Restart = "always";
105105+ RestartSec = 5;
106106+107107+ DynamicUser = true;
108108+ # Read access to /var/lib/acme/${domain}. Works with DynamicUser: systemd
109109+ # adds the transient user to the group at start.
110110+ SupplementaryGroups = [ certGroup ];
111111+112112+ # ProtectSystem=strict only remounts read-only, so reading the cert is
113113+ # still fine.
114114+ ProtectSystem = "strict";
115115+ ProtectHome = true;
116116+ PrivateTmp = true;
117117+ NoNewPrivileges = true;
118118+ ProtectKernelTunables = true;
119119+ ProtectControlGroups = true;
120120+ RestrictAddressFamilies = [
121121+ "AF_INET"
122122+ "AF_INET6"
123123+ ];
124124+ };
125125+ };
126126+127127+ # Only the QAD listener needs a hole punched; the relay's HTTP side is
128128+ # reached through nginx on 443, which is already open.
129129+ networking.firewall.allowedUDPPorts = [ quicPort ];
130130+}