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
{ modulesPath, config, pkgs, lib, homeRepo, authKeys, ... }:

let
  hostname = "legosi";

  tailscaleInterface = "tailscale0";
in {
  imports = [
    ./base-server.nix
    "${modulesPath}/profiles/qemu-guest.nix"
    ../../users/Profpatsch/website/module.nix
    ../../users/Profpatsch/softwaregardening/module.nix
    ../../users/Profpatsch/decentsoftware/module.nix
    ../../users/Profpatsch/modular-flyer/nixos-module.nix
    ../../users/Profpatsch/source-forge/nixos-module.nix
    ../../users/Profpatsch/hosty/nixos-module.nix
    ../../users/Profpatsch/twin-towns/nixos-module.nix
  ];

  config = {
    profpatsch.website.enable = true;
    profpatsch.softwaregardening.enable = true;
    profpatsch.decentsoftware.enable = true;
    profpatsch.modularPlakate.enable = true;
    profpatsch.sourceForge.enable = true; # projects are declared at runtime, see `source-forge project add`
    # Data is not baked into the closure: run `systemctl start twin-towns-ingest`
    # to fetch it from Wikidata, then restart twin-towns. See twin-towns(1).
    profpatsch.twinTowns.enable = true;
    # Apps are installed at runtime with `hosty start -system`, not declared
    # here. Nothing is internet-facing yet: apps listen on localhost and are
    # reached over tailscale until the reverse proxy lands (Phase 2).
    profpatsch.hosty.enable = true;
    # Publish apps at <app>.hosty-test.profpatsch.de. Caddy terminates TLS on
    # 127.0.0.1:8443; nginx's stream block below routes there by SNI.
    # Proven against Let's Encrypt staging first (identical issuance path, far
    # looser rate limits), then switched to production once a staging
    # certificate had been issued and served end to end.
    profpatsch.hosty.publish.enable = true;
    profpatsch.softwaregardening.boosterBotAdmins = [
      "https://mastodon.xyz/users/Profpatsch"
    ];
    system.stateVersion = "23.11";

    boot.loader.grub.device = "/dev/sda";

    fileSystems = {
      "/" = {
        device = "/dev/sda1";
        fsType = "ext4";
      };
    };

    networking = {
      hostName = hostname;

      firewall = {
        allowedTCPPorts = [
          80 443
        ];

        interfaces.${tailscaleInterface} = {
          allowedTCPPorts = [
          ];
        };
      };

    };

    services.tailscale = {
      enable = true;
      interfaceName = tailscaleInterface;
    };

    # services.duplicity = {
    #   enable = true;
    #   frequency = "daily";

    #   root = "/";
    #   # exclude all the system-related dirs
    #   exclude = [
    #     "/bin"
    #     "/boot"
    #     "/dev"
    #     "/nix"
    #     "/proc"
    #     "/run"
    #     "/sys"
    #     "/tmp"
    #     "/usr"
    #     # /var/lib is what we want because it contains all services,
    #     # but let’s be generous and keep everything in /var except log
    #     "/var/log"
    #   ];

    #   targetUrl = "b2://000efe88f7148a00000000003@profpatsch-legosi/";

    #   # this uses the internal stateDirectory of the duplicity module
    #   # Has to be set manually once of course.
    #   secretFile = "/var/lib/duplicity/secrets";

    #   extraFlags = [
    #     "--name" "legosi-root"
    #     "--verbosity" "info"
    #     "--full-if-older-than" "60D"
    #     "--num-retries" "3"
    #     # I hate GPG from the bottom of my heart
    #     "--encrypt-key" gpgPublicKeyId
    #     "--gpg-options" "--keyring ${gpgPublicKeyring} --trust-model always"
    #   ];

    # };

    environment.systemPackages = [ pkgs.git pkgs.sqlite ];

    users.users = {
      root.openssh.authorizedKeys.keys = authKeys;
    };

    profpatsch.user.profpatsch.programs.weechat = [
      {
        userName = "weechat";
        # give this user access to the bitlbee group and socket
        extraGroups = [ "bitlbee" ];
        weechatDataDir = "/var/lib/weechat";
        authorizedKeys = authKeys;
        # redirect the bitlbee unix socket to a fake domain
        # because weechat is unable to connect to unix sockets.
        wrapExecStart = [
          "${pkgs.ip2unix}/bin/ip2unix"
          "-r"
          "addr=1.2.3.4,port=6667,path=${config.profpatsch.user.profpatsch.services.bitlbee.socketFile}"
        ];
      }
    ];

    profpatsch.user.profpatsch.services.bitlbee = {
       enable = true;
    };

    security.acme.acceptTerms = true;
    security.acme.defaults.email = "mail@profpatsch.de";

    services.nginx = {
      enable = true;
      recommendedGzipSettings = true;
      recommendedOptimisation = true;

      # ---------------------------------------------------------------------
      # SNI passthrough on :443 (hosty Phase 2, steps B1/B2 — see DESIGN.md)
      #
      # nginx binds :443 in a `stream` block, reads the SNI hostname *without*
      # decrypting, and forwards the raw TCP connection to a backend:
      # <app>.hosty-test.profpatsch.de goes to Caddy, which terminates TLS for
      # hosty apps on 127.0.0.1:8443; everything else goes to nginx's own HTTP
      # block on 127.0.0.1:8444, unchanged.
      #
      # This landed in two steps on purpose. B1 introduced the passthrough with
      # *only* the nginx branch, so it changed no behaviour and any breakage had
      # exactly one possible cause; B2 then added the Caddy branch as a purely
      # additive change. That sequencing is what made the port_in_redirect bug
      # below attributable when it appeared.
      # ---------------------------------------------------------------------

      # :443 moves to loopback and learns PROXY protocol; :80 deliberately does
      # NOT move. All 16 ACME certificates renew over the webroot challenge on
      # :80, so it must keep its 0.0.0.0 listener — otherwise every renewal
      # breaks silently, and only becomes visible weeks later when the certs
      # actually expire.
      #
      # Giving each entry an explicit `ssl` field is what makes this work: the
      # nginx module otherwise doubles every listen line into an HTTP and an
      # SSL variant, and an explicit `ssl` opts out of that, letting the TLS
      # listener move without dragging the HTTP one along.
      defaultListen = [
        { addr = "127.0.0.1"; port = 8444; ssl = true; proxyProtocol = true; }
        { addr = "0.0.0.0"; port = 80; ssl = false; }
      ];

      commonHttpConfig = ''
        # Recover the real client address from the PROXY protocol header the
        # stream block prepends; without this every request is logged, rate
        # limited and passed upstream as coming from 127.0.0.1.
        set_real_ip_from 127.0.0.1;
        real_ip_header proxy_protocol;

        # Load-bearing, and the non-obvious part of this change. nginx builds
        # absolute redirects from the port it is *locally* bound to
        # (ngx_http_header_filter_module.c: port = ngx_inet_get_port(
        # c->local_sockaddr)), which is now 8444 rather than 443. Since
        # port_in_redirect defaults to on, and the suppression only special
        # cases 443, every implicit directory redirect would otherwise start
        # emitting https://host:8444/... — a port that is firewalled off.
        # set_real_ip_from does not help: the realip module rewrites the
        # *source* address, never the local one. Turning port_in_redirect off
        # drops the port and yields the public URL again.
        #
        # Canary for this: curl -sI https://profpatsch.de/mlp/music
        port_in_redirect off;
      '';

      # Every external connection now costs two: one from the client into the
      # stream block, and one from the stream block into the HTTP block. The
      # default is 512 per worker with a single worker here, so without this
      # the effective external capacity would halve to ~256.
      eventsConfig = "worker_connections 4096;";

      # ...and worker_connections is capped by the file descriptor limit, whose
      # soft value is 1024. nginx raises its own limit from the main context.
      prependConfig = "worker_rlimit_nofile 8192;";

      streamConfig = ''
        map $ssl_preread_server_name $hosty_backend {
          # hosty apps terminate at Caddy; everything else stays with nginx.
          # The map is also the allowlist that makes on-demand TLS safe: only
          # names matching this pattern ever reach Caddy, so the question of
          # who may request a certificate is largely settled before Caddy sees
          # the connection (the ask endpoint is then defence in depth).
          ~^[^.]+\.hosty-test\.profpatsch\.de$ 127.0.0.1:8443;
          default                              127.0.0.1:8444;
        }
        server {
          listen 0.0.0.0:443;
          ssl_preread on;
          proxy_pass $hosty_backend;
          proxy_protocol on;

          # The stream module's proxy_timeout defaults to 10 minutes and is now
          # the binding constraint on every connection, including ones the HTTP
          # block deliberately keeps open for longer: the inventory vhost sets
          # proxy_read_timeout 3600s for its capture WebSocket and SSE stats
          # stream. Matching that here keeps this change a no-op for them.
          proxy_timeout 3600s;
        }
      '';
    };

    # services.syncthing = {
    #   enable = true;
    #   openDefaultPorts = true;
    #   devices = {
    #     mushu = {
    #       name = "mushu";
    #       id = "B5H23ZS-NANQF55-2TW7V4F-22I7B34-7OIZ3ND-PHWL6JP-IIEVBBK-32RKWQR";
    #     };
    #   };

    #   folders.mushu-data = {
    #     id = "so6nv-oq5wu";
    #     path = "/sync/mushu-data";
    #     type = "receiveonly";
    #     watch = false;
    #     devices = [ "mushu" ];
    #   };
    # };
  };
}