1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
{ depot, pkgs, lib, config, ... }:

let
  cfg = config.profpatsch.hosty;
  hosty = (import ./default.nix { inherit depot pkgs; }).hosty;

  # Where per-app Caddy route snippets live. Outside cfg.stateDir because that
  # is 0700 root and Caddy runs as its own user; this only ever holds hostname
  # and port, no secrets.
  routeDir = "/var/lib/hosty-routes";
in {
  options.profpatsch.hosty = {
    enable = lib.mkEnableOption "hosty self-hosted app platform (system mode)";

    stateDir = lib.mkOption {
      type = lib.types.str;
      default = "/var/lib/hosty";
      description = ''
        State directory for system mode. Holds one subdirectory per installed
        app (data.hosty + runtime.hosty) and the extracted portable-service
        images under images/<name>.

        Must be under /var/lib: system mode hands writable app data to systemd
        via StateDirectory=, which is always relative to /var/lib, and hosty
        refuses to set up an app whose state dir lies elsewhere.
      '';
    };

    baseDomain = lib.mkOption {
      type = lib.types.str;
      default = "hosty-test.profpatsch.de";
      description = ''
        Base domain under which apps are published as <app>.<baseDomain>.
        The production label is deliberately undecided and `hosty-test` is a
        scratch domain meant to be thrown away, which is why this is an option
        rather than a constant.
      '';
    };

    publish = {
      enable = lib.mkEnableOption ''
        TLS termination for hosty apps at <app>.<baseDomain>, via Caddy with
        on-demand certificates.

        This does NOT arrange for traffic to reach Caddy: the host must route
        :443 for <baseDomain> to caddyPort itself. On legosi that is nginx's
        SNI passthrough (see machines/profpatsch/legosi.nix)
      '';

      caddyPort = lib.mkOption {
        type = lib.types.port;
        default = 8443;
        description = ''
          Loopback port Caddy terminates TLS on. Deliberately not 443: the host
          reverse proxy owns that and forwards here after reading SNI.
        '';
      };

      askPort = lib.mkOption {
        type = lib.types.port;
        default = 9123;
        description = ''
          Loopback port for the on-demand TLS permission endpoint
          (`hosty serve-ask`). Caddy refuses to enable on-demand TLS without
          one, since otherwise anyone pointing a hostname at this host could
          make it mint certificates.
        '';
      };

      acmeStaging = lib.mkOption {
        type = lib.types.bool;
        default = false;
        description = ''
          Use Let's Encrypt's staging endpoint. Certificates are then untrusted
          by browsers, but the issuance path is identical and staging has far
          looser rate limits — so a first deployment can be proven end to end
          without risking the production limit of 5 failed validations per hour.
        '';
      };
    };
  };

  config = lib.mkIf cfg.enable {
    # No systemd service of our own: hosty is a CLI, not a daemon. Apps are
    # installed by running `hosty start -system` as root, which attaches them
    # as portable services; systemd then owns their lifecycle. Declarative app
    # management is deliberately not attempted yet.
    environment.systemPackages = [ hosty ];

    # Required, not optional. The FUSE filesystem backing HOSTY_FS is mounted
    # by root (the <app>-fs.service companion unit) but read and written by the
    # app's transient DynamicUser. FUSE refuses foreign uids unless the mount
    # passes allow_other, and libfuse only honours allow_other when
    # user_allow_other is present in /etc/fuse.conf — which is exactly what
    # this option uncomments. Without it every system-mode app fails at the
    # point it first touches HOSTY_FS.
    #
    # It also installs the setuid fusermount wrappers under /run/wrappers/bin,
    # which go-fuse execs to establish the mount.
    programs.fuse.userAllowOther = true;

    boot.kernelModules = [ "fuse" ];

    # systemd-portabled needs no wiring here: NixOS ships it in its default
    # units whenever systemd is built withPortabled, and it is D-Bus activated
    # on the first portablectl call. Verified rather than assumed — the VM test
    # in system-test.nix attaches an image without configuring anything.

    # 0700: app state contains credential files for sensitive config (written
    # by hosty, read by systemd LoadCredential=), so nothing below this should
    # be world-readable. hosty creates the per-app subdirectories itself.
    systemd.tmpfiles.rules = [
      "d ${cfg.stateDir} 0700 root root -"
    ]
    # Route snippets are read by Caddy and written by root; they hold only a
    # hostname and a port, no secrets, so 0755. Created up front for somewhere
    # to put snippets — an absent or empty directory is not an error, since
    # `import <dir>/*.caddy` simply matches nothing (verified).
    ++ lib.optional cfg.publish.enable "d ${routeDir} 0755 root root -";

    # -----------------------------------------------------------------------
    # Publishing apps at <app>.<baseDomain> (Phase 2, step B2)
    # -----------------------------------------------------------------------

    # The on-demand TLS permission endpoint. Caddy asks it, before issuing a
    # certificate, whether a name is allowed to have one.
    #
    # Unprivileged on purpose, and able to be so only because it keys off
    # /run/systemd/system.attached (0755, maintained by portablectl) rather
    # than hosty's own state: /var/lib/hosty is 0700 and its app dirs are
    # symlinks into the equally closed /var/lib/private, so reading those would
    # require root — and would answer the wrong question anyway, since
    # `hosty stop` leaves data.hosty behind and a stopped app would keep
    # renewing certificates forever.
    systemd.services.hosty-serve-ask = lib.mkIf cfg.publish.enable {
      description = "hosty on-demand TLS permission endpoint";
      wantedBy = [ "multi-user.target" ];
      before = [ "caddy.service" ];
      serviceConfig = {
        ExecStart = "${hosty}/bin/hosty serve-ask"
          + " -base-domain ${cfg.baseDomain}"
          + " -addr 127.0.0.1:${toString cfg.publish.askPort}";
        DynamicUser = true;
        Restart = "always";
        RestartSec = "5s";

        # It reads one world-readable directory and answers 200/404. Nothing
        # here is load-bearing for functionality, which is exactly why it can
        # all be switched on.
        ProtectSystem = "strict";
        ProtectHome = true;
        PrivateTmp = true;
        PrivateDevices = true;
        NoNewPrivileges = true;
        ProtectKernelTunables = true;
        ProtectKernelModules = true;
        ProtectControlGroups = true;
        RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ];
        RestrictNamespaces = true;
        LockPersonality = true;
        MemoryDenyWriteExecute = true;
        SystemCallArchitectures = "native";
        SystemCallFilter = [ "@system-service" ];
        # Only ever talks to Caddy on loopback.
        IPAddressAllow = "localhost";
        IPAddressDeny = "any";
      };
    };

    services.caddy = lib.mkIf cfg.publish.enable {
      enable = true;

      # `admin off` and the NixOS module's reload support are mutually
      # exclusive: `caddy reload` POSTs the new config to the admin API, so with
      # the API disabled a reload fails with "connection refused" and systemd
      # reports the unit as failed while the old config keeps serving — the
      # worst combination, since routes silently do not take effect.
      #
      # Restarting instead is acceptable here: app install/removal is a rare,
      # human-initiated event, and a restart drops only in-flight connections to
      # hosty apps (the 14 nginx vhosts are untouched, being a different
      # process). Certificates survive because they live in Caddy's data dir,
      # so a restart does not re-issue anything.
      enableReload = false;

      # Caddy terminates TLS for hosty apps only, on loopback. Whatever owns
      # :443 forwards here after reading SNI, so Caddy gets :443 semantics
      # without owning the socket.
      #
      # https_port is load-bearing and not obvious: `bind 127.0.0.1` alone
      # still makes Caddy listen on port 443 (verified by adapting the config),
      # which would collide with the very proxy that forwards to it.
      globalConfig = ''
        # No admin API: routes come from this file, and an open admin socket is
        # a privileged surface with nothing to gain here.
        admin off

        https_port ${toString cfg.publish.caddyPort}

        # The HTTP->HTTPS redirect server would bind :80, which the host's
        # reverse proxy owns and needs for its own ACME challenges. Caddy never
        # sees plain HTTP anyway — only the SNI-routed TLS connection.
        auto_https disable_redirects

        on_demand_tls {
          ask http://127.0.0.1:${toString cfg.publish.askPort}/ask
        }

        # HTTP-01 is impossible here (:80 belongs to the host proxy), so it is
        # disabled explicitly rather than left to fail and fall back. That
        # leaves TLS-ALPN-01, which works precisely because the SNI passthrough
        # already forwards the handshake to this port — the reason the
        # passthrough had to land before Caddy.
        cert_issuer acme {
          disable_http_challenge
          ${lib.optionalString cfg.publish.acmeStaging
            "dir https://acme-staging-v02.api.letsencrypt.org/directory"}
        }

        servers 127.0.0.1:${toString cfg.publish.caddyPort} {
          listener_wrappers {
            # Must precede `tls`: the PROXY header is plaintext at the very
            # start of the connection, before the TLS handshake. `require`
            # rejects connections without one, so nothing can reach Caddy
            # except through the proxy.
            proxy_protocol {
              allow 127.0.0.1/32
              fallback_policy require
            }
            tls
          }
          # Recover the real client address the proxy passed along, rather than
          # attributing every request to 127.0.0.1.
          trusted_proxies static 127.0.0.1/32
        }
      '';

      # Per-app routes are imported from a directory hosty owns, rather than
      # being generated here: an app's port is assigned at install time and
      # kept in its runtime state, which Nix cannot know at build time. Writing
      # a file and reloading is also what DESIGN.md settled on over the admin
      # API, since admin-API changes are in-memory and would need re-registering
      # after every restart.
      #
      # Until step A makes hosty write these automatically, a snippet is placed
      # by hand. Each MUST contain `bind 127.0.0.1`: without it the site block
      # produces a *second* server listening on :8443 across all interfaces,
      # bypassing both the proxy_protocol wrapper and trusted_proxies — i.e.
      # exposing Caddy publicly and breaking PROXY parsing. Verified by
      # adapting the config; it is silent, so it must be stated.
      #
      # Note that writing a snippet is not enough on its own: systemd's
      # restartTriggers watch only the Nix-generated Caddyfile, not this
      # directory, so whoever writes a snippet must also
      # `systemctl restart caddy`. Step A will do both together.
      extraConfig = ''
        import ${routeDir}/*.caddy

        # Catch-all. Reached when a name passed both the SNI map and the ask
        # endpoint but has no route — normally an app that is attached but
        # whose snippet is missing. 502 rather than a TLS failure keeps that
        # case debuggable.
        https:// {
          bind 127.0.0.1
          tls {
            on_demand
          }
          respond "hosty: no route configured for {host}" 502
        }
      '';
    };
  };
}