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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
{ depot, pkgs, ... }:

let
  inherit (pkgs) lib;

  goDeps = import ../go-deps.nix { inherit depot pkgs; };

  asciinema-server = import ../asciinema-server { inherit depot pkgs; };

  # ---------------------------------------------------------------------------
  # Self-contained images: packing the Nix closure
  # ---------------------------------------------------------------------------
  #
  # A .hosty image must carry everything its app needs to run, because system
  # mode gives the app a RootDirectory= of the extracted image and nothing else
  # — no host /nix/store is bound in (see DESIGN.md).
  #
  # Nix-built binaries are dynamically linked against absolute store paths that
  # are baked into the ELF header at build time: the interpreter in .interp and
  # the library search path in DT_RUNPATH. For vaultwarden, for example:
  #
  #   interpreter  /nix/store/…-glibc-2.40-224/lib/ld-linux-x86-64.so.2
  #   rpath        /nix/store/…-sqlite-3.50.4/lib:/nix/store/…-openssl-3.6.2/lib:…
  #
  # Those paths cannot be relocated without rewriting the binary. But they do
  # not have to come from the *host*: store paths are content-addressed, so
  # placing the same closure at the same absolute paths *inside the image*
  # satisfies the loader identically. That is all closureMap does.
  #
  # `closureInfo` yields the transitive closure as a plain newline-delimited
  # list, which is turned into `src:dst` pairs mapping each store path to the
  # identical path in the image, consumed via `hosty pack -map @file`. Whole
  # paths are packed rather than just each one's lib/ subdirectory: it is
  # provably correct (nothing a package ships can be missing) and zstd
  # compression in `hosty pack` absorbs most of the cost. Trim later only if
  # measurement says it matters.
  closureMap = rootPaths:
    let ci = pkgs.closureInfo { inherit rootPaths; };
    in pkgs.runCommand "hosty-closure-map" { } ''
      # dst is the store path minus its leading slash: `hosty pack` treats map
      # destinations as image-relative, so `/nix/store/x` becomes `nix/store/x`
      # in the staging tree and is extracted back to the absolute path.
      sed 's|^/\(.*\)$|/\1:\1|' ${ci}/store-paths > $out
    '';

  # CA certificates. A second ambient host dependency that the closure does NOT
  # cover, and which fails *silently* when missing.
  #
  # Nix's openssl ships an empty $out/etc/ssl/certs, so a TLS client falls back
  # to the compiled-in default /etc/ssl/certs/ca-certificates.crt — a host path
  # supplied by NixOS, not by any package. Inside a bare image that file does
  # not exist, and SSL_CTX_set_default_verify_paths() still returns success
  # while loading zero certificates; every outbound HTTPS request then fails
  # verification at runtime with no hint as to why. Measured in a chroot with
  # only vaultwarden's closure present:
  #
  #   with the bundle:     set_default_verify_paths=1  loaded_certs=167
  #   without the bundle:  set_default_verify_paths=1  loaded_certs=0
  #
  # So the bundle is mapped to the path OpenSSL actually looks at, and
  # SSL_CERT_FILE is declared as config for the many libraries that consult it
  # instead. Needed by any app making outbound HTTPS calls: vaultwarden fetches
  # site icons, rss-parrot federates.
  caBundleMap = pkgs.writeText "hosty-ca-map" ''
    ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt:etc/ssl/certs/ca-certificates.crt
  '';

  # Config declarations pointing TLS libraries at the bundle above. OpenSSL
  # finds it via the compiled-in path, but curl/Go/others check these first.
  caCertConfig = [
    ''-config "SSL_CERT_FILE:Path to the CA bundle inside the image:optional,default=/etc/ssl/certs/ca-certificates.crt"''
    ''-config "NIX_SSL_CERT_FILE:Path to the CA bundle inside the image:optional,default=/etc/ssl/certs/ca-certificates.crt"''
  ];

  # The hosty binary itself. Statically linked and pure-Go: the SQLite driver is
  # modernc.org/sqlite (a Go translation, not a cgo binding) and go-fuse talks to
  # /dev/fuse directly, so nothing here needs libc. buildGo builds its stdlib
  # with CGO_ENABLED=0, which is what makes that actually hold — importing "net"
  # otherwise pulls in the cgo resolver and silently produces a dynamically
  # linked binary that needs glibc from the host at runtime.
  hosty = depot.nix.buildGo.program {
    name = "hosty";
    srcs = [
      ./main.go
      ./db.go
      ./env.go
      ./config.go
      ./names.go
      ./pack.go
      ./lifecycle.go
      ./system.go
      ./start.go
      ./stop.go
      ./run.go
      ./config_cmd.go
      ./info.go
      ./export.go
      ./hello.go
      ./ask.go
      ./fsserve.go
      ./utils.go
      ./fuse.go
    ];
    deps = [
      goDeps.modernc-sqlite
      goDeps.go-fuse.fs
      goDeps.go-fuse.fuse
      goDeps.zombiezen-go-sqlite
      goDeps.zombiezen-go-sqlite.sqlitex
      goDeps.klauspost-stdgozstd
    ];
  };

  # Pack hosty-hello v0.1 — maps the hosty binary into the image.
  # No closureMap: the hosty binary is statically linked (buildGo builds its
  # stdlib with CGO_ENABLED=0), so it has no interpreter and no DT_NEEDED and
  # needs nothing outside the image. Its Nix closure lists only tzdata, mailcap
  # and iana-etc — runtime data files the Go stdlib reads opportunistically and
  # does without when absent, which the system test confirms.
  hosty-hello-v1 = pkgs.runCommand "hosty-hello-v1.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name        hosty-hello \
        -version     0.1 \
        -description "Hosty hello world example app (v0.1)" \
        -map         ${hosty}/bin/hosty:usr/bin/hosty \
        -ExecStart   "/usr/bin/hosty hello run-v1" \
        -config      "GREETING:Greeting message shown on GET /:optional,default=Hello from hosty!" \
        -out         $out
    '';

  # Pack hosty-hello v0.2 — same binary, different ExecStart, triggers migration
  hosty-hello-v2 = pkgs.runCommand "hosty-hello-v2.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name        hosty-hello \
        -version     0.2 \
        -description "Hosty hello world example app (v0.2, with last_visited migration)" \
        -map         ${hosty}/bin/hosty:usr/bin/hosty \
        -ExecStart   "/usr/bin/hosty hello run-v2" \
        -config      "GREETING:Greeting message shown on GET /:optional,default=Hello from hosty!" \
        -out         $out
    '';

  # vaultwarden-setup: first-run setup binary for vaultwarden.
  # Serves a minimal HTML form on HOSTY_PORT where the admin enters a password.
  # Hashes it with Argon2id (owasp preset: m=19456, t=2, p=1), writes the result
  # to _hosty_setup in HOSTY_DB so hosty can store it as a sensitive credential,
  # then exits 0 to signal completion to hosty start.
  vaultwarden-setup = depot.nix.buildGo.program {
    name = "vaultwarden-setup";
    srcs = [
      (pkgs.writeText "vaultwarden-setup.go" ''
        package main

        import (
        	"context"
        	"crypto/rand"
        	"database/sql"
        	"encoding/base64"
        	"fmt"
        	"net/http"
        	"os"
        	"time"

        	_ "modernc.org/sqlite"
        	"golang.org/x/crypto/argon2"
        )

        func main() {
        	port := os.Getenv("HOSTY_PORT")
        	dbPath := os.Getenv("HOSTY_DB")
        	if port == "" || dbPath == "" {
        		fmt.Fprintln(os.Stderr, "vaultwarden-setup: HOSTY_PORT and HOSTY_DB must be set")
        		os.Exit(1)
        	}
        	fmt.Fprintf(os.Stderr, "vaultwarden-setup: port=%s db=%s\n", port, dbPath)

        	// Unbuffered: handler blocks on send until main receives, ensuring
        	// the response is fully written before we shut down.
        	done := make(chan error)

        	mux := http.NewServeMux()
        	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: %s %s\n", r.Method, r.URL.Path)
        		if r.Method == http.MethodGet {
        			w.Header().Set("Content-Type", "text/html; charset=utf-8")
        			fmt.Fprint(w, setupPage(""))
        			return
        		}
        		if r.Method != http.MethodPost {
        			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        			return
        		}
        		if err := r.ParseForm(); err != nil {
        			http.Error(w, "bad form", http.StatusBadRequest)
        			return
        		}
        		domain := r.FormValue("domain")
        		password := r.FormValue("password")
        		confirm := r.FormValue("confirm")
        		if domain == "" {
        			w.Header().Set("Content-Type", "text/html; charset=utf-8")
        			fmt.Fprint(w, setupPage("Domain URL must not be empty."))
        			return
        		}
        		if password == "" {
        			w.Header().Set("Content-Type", "text/html; charset=utf-8")
        			fmt.Fprint(w, setupPage("Password must not be empty."))
        			return
        		}
        		if password != confirm {
        			w.Header().Set("Content-Type", "text/html; charset=utf-8")
        			fmt.Fprint(w, setupPage("Passwords do not match."))
        			return
        		}

        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: hashing password\n")
        		hash, err := hashArgon2id(password)
        		if err != nil {
        			http.Error(w, "hashing failed: "+err.Error(), http.StatusInternalServerError)
        			done <- err
        			return
        		}
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: writing to db\n")
        		if err := writeSetup(dbPath, domain, hash); err != nil {
        			http.Error(w, "db write failed: "+err.Error(), http.StatusInternalServerError)
        			done <- err
        			return
        		}

        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: done, sending response\n")
        		w.Header().Set("Content-Type", "text/html; charset=utf-8")
        		fmt.Fprint(w, donePage)
        		if f, ok := w.(http.Flusher); ok {
        			f.Flush()
        		}
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: response sent, signalling done\n")
        		// Unbuffered send: blocks until main receives, then handler returns.
        		done <- nil
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: handler returning\n")
        	})

        	srv := &http.Server{Addr: "127.0.0.1:" + port, Handler: mux}
        	go func() {
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: starting server on %s\n", port)
        		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        			fmt.Fprintf(os.Stderr, "vaultwarden-setup: server error: %v\n", err)
        			done <- err
        		}
        	}()

        	fmt.Fprintf(os.Stderr, "vaultwarden-setup: open http://localhost:%s in your browser\n", port)

        	// Block until handler signals done (or server error).
        	fmt.Fprintf(os.Stderr, "vaultwarden-setup: waiting for done signal\n")
        	if err := <-done; err != nil {
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: error: %v\n", err)
        		os.Exit(1)
        	}
        	fmt.Fprintf(os.Stderr, "vaultwarden-setup: shutting down server\n")
        	shutCtx, shutCancel := context.WithTimeout(context.Background(), 2*time.Second)
        	defer shutCancel()
        	if err := srv.Shutdown(shutCtx); err != nil {
        		fmt.Fprintf(os.Stderr, "vaultwarden-setup: shutdown: %v\n", err)
        	}
        	fmt.Fprintf(os.Stderr, "vaultwarden-setup: exiting\n")
        }

        // hashArgon2id hashes a password using vaultwarden's owasp preset:
        // m=19456 KiB, t=2 iterations, p=1 thread — matching `vaultwarden hash --preset owasp`.
        func hashArgon2id(password string) (string, error) {
        	salt := make([]byte, 16)
        	if _, err := rand.Read(salt); err != nil {
        		return "", err
        	}
        	const memory  = 19456
        	const time    = 2
        	const threads = 1
        	const keyLen  = 32
        	hash := argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen)
        	b64salt := base64.RawStdEncoding.EncodeToString(salt)
        	b64hash := base64.RawStdEncoding.EncodeToString(hash)
        	return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
        		memory, time, threads, b64salt, b64hash), nil
        }

        // writeSetup writes domain and hashed admin token to _hosty_setup.
        func writeSetup(dbPath, domain, hash string) error {
        	db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL")
        	if err != nil {
        		return err
        	}
        	defer db.Close()

        	rows := [][3]string{
        		{
        			"DOMAIN:Public URL of this vaultwarden instance (e.g. https://vault.example.com):required",
        			"DOMAIN",
        			domain,
        		},
        		{
        			"ADMIN_TOKEN_FILE:Argon2id PHC hash for the vaultwarden admin panel:required,sensitive",
        			"ADMIN_TOKEN_FILE",
        			hash,
        		},
        	}
        	for _, row := range rows {
        		if _, err := db.Exec(`
        			INSERT INTO _hosty_setup (declaration, key, value)
        			VALUES (?, ?, ?)
        			ON CONFLICT(key) DO UPDATE SET declaration=excluded.declaration, value=excluded.value
        		`, row[0], row[1], row[2]); err != nil {
        			return fmt.Errorf("inserting %s: %w", row[1], err)
        		}
        	}
        	return nil
        }

        const donePage = `<!DOCTYPE html><html><head><title>Vaultwarden Setup</title></head><body>
        <h1>Setup complete!</h1>
        <p>Admin password has been set. This window can be closed.</p>
        </body></html>`

        func setupPage(errMsg string) string {
        	errHTML := ""
        	if errMsg != "" {
        		errHTML = "<p style='color:red'>" + errMsg + "</p>"
        	}
        	return `<!DOCTYPE html><html><head><title>Vaultwarden Setup</title></head><body>
        <h1>Vaultwarden First-Time Setup</h1>
        ` + errHTML + `
        <form method="POST">
          <label>Public URL (e.g. https://vault.example.com):<br>
            <input type="url" name="domain" size="40" autofocus required></label><br><br>
          <label>Admin panel password:<br>
            <input type="password" name="password" required></label><br><br>
          <label>Confirm password:<br>
            <input type="password" name="confirm" required></label><br><br>
          <button type="submit">Set up &amp; start vaultwarden</button>
        </form>
        </body></html>`
        }
      '')
    ];
    deps = [
      goDeps.modernc-sqlite
      goDeps.golang-x-crypto.argon2
    ];
  };

  # Pack vaultwarden as a hosty app.
  # - Binary, web vault, and setup binary are mapped into the image (self-contained).
  # - hosty_port_env=ROCKET_PORT: vaultwarden listens on the hosty-assigned port.
  # - hosty_db_env=DATABASE_URL: vaultwarden's tables coexist with hosty's _hosty_* tables in data.hosty.
  # - hosty_fs_env=DATA_FOLDER: attachments, icon cache, RSA keys, sends, tmp via the FUSE-backed mutable FS.
  # - ADMIN_TOKEN_FILE: set via the first-run setup web UI (vaultwarden-setup).
  #   The setup binary hashes the password with Argon2id and writes it to _hosty_setup;
  #   hosty then stores it as a sensitive credential (LoadCredential in the unit file).
  #
  # NOTE: this package does not build. `-setup-exec` is not a flag `hosty pack`
  # accepts, and `git log -S` shows it never was: the first-run setup-wizard
  # hook it implies was packaged before it was implemented. The setup binary
  # itself is built and mapped in fine; only the hook that would run it on
  # first start is missing. Left as-is deliberately rather than papered over —
  # implementing it is a feature, not a fix. rss-parrot is the CGO validation
  # target in the meantime.
  vaultwarden-hosty = pkgs.runCommand "vaultwarden.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name           vaultwarden \
        -version        ${pkgs.vaultwarden.version} \
        -description    "Unofficial Bitwarden-compatible server (vaultwarden)" \
        -map            ${pkgs.vaultwarden}/bin/vaultwarden:usr/bin/vaultwarden \
        -map            ${pkgs.vaultwarden.passthru.webvault}/share/vaultwarden/vault:usr/share/vaultwarden/web-vault \
        -map            ${vaultwarden-setup}/bin/vaultwarden-setup:usr/bin/vaultwarden-setup \
        -ExecStart      "/usr/bin/vaultwarden" \
        -setup-exec     "/usr/bin/vaultwarden-setup" \
        -hosty-port-env ROCKET_PORT \
        -hosty-db-env   DATABASE_URL \
        -hosty-fs-env   DATA_FOLDER \
        -config         "ROCKET_ADDRESS:Bind address (leave as 127.0.0.1 for local/reverse-proxy use):optional,default=127.0.0.1" \
        -config         "WEB_VAULT_FOLDER:Path to web vault static files (packed into image):optional,default=rootfs/usr/share/vaultwarden/web-vault" \
        -config         "SIGNUPS_ALLOWED:Allow new user self-registration (true/false):optional,default=false" \
        -config         "SIGNUPS_VERIFY:Require email verification on registration (false to disable):optional,default=false" \
        -config         "SMTP_HOST:SMTP server hostname for email delivery:optional" \
        -config         "SMTP_FROM:Sender email address:optional" \
        -config         "SMTP_USERNAME:SMTP authentication username:optional" \
        -config         "SMTP_PASSWORD:SMTP authentication password:optional,sensitive" \
        -out            $out
    '';

  # pocket-id v2.5.0 pre-built binary
  pocket-id-bin = pkgs.fetchurl {
    url = "https://github.com/pocket-id/pocket-id/releases/download/v2.5.0/pocket-id-linux-amd64";
    sha256 = "057qzvgrqf703ix9ia1pzy77nzid35zmm2cfjf7zirh1frnlixyy";
  };

  # Pack pocket-id as a hosty app.
  # No closureMap here, deliberately: this is an upstream release binary, built
  # statically (`file` reports "statically linked", and its Nix closure is just
  # itself). It has no interpreter and no DT_NEEDED, so there is nothing for the
  # loader to find outside the image — the self-containment the other apps get
  # from packing their closure, this one already has.
  pocket-id = pkgs.runCommand "pocket-id.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name        pocket-id \
        -version     2.5.0 \
        -description "Simple self-hosted OIDC provider with passkey authentication" \
        -map         ${pocket-id-bin}:usr/bin/pocket-id \
        -ExecStart   "/usr/bin/pocket-id" \
        -config      "APP_URL:Public URL of this service (e.g. https://auth.example.com):required" \
        -config      "ENCRYPTION_KEY_FILE:Path to file containing the base64 encryption key (min 16 bytes):required,sensitive" \
        -config      "UPLOAD_PATH:File upload directory:optional,default=data/uploads" \
        -out         $out
    '';

  # Like hosty-hello, no closureMap and no CA bundle: asciinema-server is a
  # buildGo binary and therefore statically linked, and it only serves — it
  # makes no outbound HTTPS calls, so an empty trust store cannot hurt it.
  asciinema-server-hosty = pkgs.runCommand "asciinema-server.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name        asciinema-server \
        -version     0.1 \
        -description "Minimal self-hosted asciinema recording server" \
        -map         ${asciinema-server}/bin/asciinema-server:usr/bin/asciinema-server \
        -ExecStart   "/usr/bin/asciinema-server serve" \
        -hosty-db-env   DATABASE_PATH \
        -hosty-port-env PORT \
        -config      "APP_TITLE:Site name shown in page titles and login page:optional,default=asciinema" \
        -config      "BASE_URL:Full public base URL (e.g. https::://asciinema.example.com), defaults to http::://localhost::<PORT>:optional" \
        -out         $out
    '';

  # ---------------------------------------------------------------------------
  # rss-parrot: turn Mastodon into your feed reader.
  # Source: https://codeberg.org/gugray/rss-parrot
  # ---------------------------------------------------------------------------

  rss-parrot-version = "0.0.99";

  rss-parrot-src = pkgs.fetchFromGitea {
    domain = "codeberg.org";
    owner = "gugray";
    repo = "rss-parrot";
    rev = "v${rss-parrot-version}";
    sha256 = "sha256-h5OZW2io+ERYlxbqz/UpE+10IdaWjAfoit4t85+03b4=";
  };

  # The rss-parrot server. Uses mattn/go-sqlite3, so CGO must be enabled.
  # Also installs the www/ template+asset tree the server serves at runtime.
  rss-parrot-server = pkgs.buildGoModule {
    pname = "rss-parrot-server";
    version = rss-parrot-version;
    src = rss-parrot-src;
    modRoot = "src/server";
    vendorHash = "sha256-584x5KJMWJjOc48Dswp0abcAiOP4kbDuB4FQwiFEz7U=";
    env.CGO_ENABLED = "1";
    # Restrict who may request new feeds. Upstream lets anyone on the fediverse
    # add a feed to the instance, which is not viable for a small personal
    # instance: every request costs polling bandwidth and storage forever.
    # The patch adds an allowed_requesters_file config key; the wrapper
    # generates that file from the ALLOWED_REQUESTERS hosty config value.
    # An absent or empty file keeps upstream's open behaviour.
    patches = [ ./rss-parrot-patches/allowlist.patch ];
    postPatch = ''
      echo "v${rss-parrot-version}" > src/server/www/version.txt
      # rss-parrot's log_file sink is opened with os.OpenFile(...O_RDWR). Under
      # systemd, stdout/stderr are journal sockets that cannot be reopened as
      # files at all (ENXIO), regardless of flags. Patch initLogger so the
      # special paths /dev/stderr and /dev/stdout reuse the already-open fd
      # instead of reopening — letting the wrapper route logs to the journal
      # without writing anything to the SQLite-backed FS.
      substituteInPlace src/server/main.go \
        --replace-fail \
          'logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0666)' \
          'var logFile *os.File; var err error; if cfg.LogFile == "/dev/stderr" { logFile = os.Stderr } else if cfg.LogFile == "/dev/stdout" { logFile = os.Stdout } else { logFile, err = os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0666) }'
    '';
    postInstall = ''
      mkdir -p $out/share/rss_parrot
      cp -r www $out/share/rss_parrot/www
    '';
    # The server binary is named rss_parrot (module name is rss_parrot).
    postFixup = ''
      if [ -e "$out/bin/server" ] && [ ! -e "$out/bin/rss_parrot" ]; then
        mv "$out/bin/server" "$out/bin/rss_parrot"
      fi
    '';
  };

  # The first-run/launcher wrapper. stdlib-only, so vendorHash = null.
  rss-parrot-wrapper = pkgs.buildGoModule {
    pname = "rss-parrot-wrapper";
    version = rss-parrot-version;
    src = ./rss-parrot-wrapper;
    vendorHash = null;
  };

  # Pack rss-parrot as a hosty app.
  # - rss_parrot binary, its www/ assets, and the wrapper are mapped into the image.
  # - ExecStart is the wrapper, which generates config.json/secrets.json from the
  #   hosty env vars, persists the birb identity + secrets under HOSTY_FS, chdirs
  #   into the image www root, and execs rss_parrot.
  # - rss-parrot's own tables live in data.hosty (HOSTY_DB); mutable state
  #   (config, secrets, identity, blocked-feeds, profiles, log) lives in HOSTY_FS.
  # - HOST is the required public domain used for all ActivityPub URLs; run
  #   behind a TLS reverse proxy pointing at the hosty-assigned port.
  # - ALLOWED_REQUESTERS restricts who may add feeds (see the patch above);
  #   leaving it unset keeps upstream's behaviour of accepting requests from
  #   anyone on the fediverse.
  # - The Nix closure is packed in (see closureMap). rss_parrot is CGO, so it is
  #   dynamically linked against glibc at an absolute store path and cannot run
  #   without it. Its closure also drags in tzdata and iana-etc, which Go reads
  #   at runtime for timezones and /etc/services — exactly the kind of ambient
  #   data dependency that closureInfo catches and a hand-written list would not.
  # - The CA bundle is packed in (see caBundleMap): rss-parrot federates over
  #   HTTPS, which fails with an empty trust store.
  rss-parrot-hosty = pkgs.runCommand "rss-parrot.hosty"
    { nativeBuildInputs = [ hosty ]; }
    ''
      hosty pack \
        -name        rss-parrot \
        -version     ${rss-parrot-version} \
        -description "Turn Mastodon into your feed reader (rss-parrot)" \
        -map         ${rss-parrot-server}/bin/rss_parrot:usr/bin/rss_parrot \
        -map         ${rss-parrot-server}/share/rss_parrot/www:usr/share/rss_parrot/www \
        -map         ${rss-parrot-wrapper}/bin/rss-parrot-wrapper:usr/bin/rss-parrot-wrapper \
        -map         @${closureMap [ rss-parrot-server rss-parrot-wrapper ]} \
        -map         @${caBundleMap} \
        -ExecStart   "/usr/bin/rss-parrot-wrapper" \
        -config      "HOST:Public domain for federation, e.g. parrot.example.com (behind a TLS reverse proxy):required" \
        -config      "LOG_LEVEL:Log level Debug/Info/Warn/Error:optional,default=Info" \
        -config      "BIRB_USER:Handle of the built-in birb account:optional,default=birb" \
        -config      "ALLOWED_REQUESTERS:Fediverse monikers (@user@host) allowed to request new feeds, comma-separated; empty means anyone may request:optional" \
        -config      "RSS_PARROT_BIN:Image-absolute path to the rss_parrot binary, resolved against HOSTY_ROOT:optional,default=/usr/bin/rss_parrot" \
        -config      "RSS_PARROT_WWW_ROOT:Image-absolute dir containing www/ (working dir for rss_parrot), resolved against HOSTY_ROOT:optional,default=/usr/share/rss_parrot" \
        ${lib.concatStringsSep " \\\n        " caCertConfig} \
        -out         $out
    '';

in
{
  inherit hosty hosty-hello-v1 hosty-hello-v2 pocket-id vaultwarden-setup vaultwarden-hosty
          asciinema-server asciinema-server-hosty
          rss-parrot-server rss-parrot-wrapper rss-parrot-hosty
          closureMap caBundleMap;
}