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
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
|
package main
import (
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// =============================================================================
// System mode: root portable services via portablectl
// =============================================================================
// attachedUnitDir is where portabled places the units of an attached portable
// service. Because hosty attaches with --runtime it is the /run variant, which
// also means attachments do not survive a reboot (see DESIGN.md, "Known
// limitation"). It is 0755 and world-readable, which is what lets the
// unprivileged `hosty serve-ask` use it as its source of truth.
const attachedUnitDir = "/run/systemd/system.attached"
// attachDropinDir is the systemd drop-in directory portabled reads for a
// system-attached service. We add our companion drop-in here after attach.
// We attach with --runtime, so units land under /run (writable on NixOS, where
// /etc/systemd/system is read-only); portabled uses /run/systemd/system.attached.
func attachDropinDir(name string) string {
return filepath.Join(attachedUnitDir, name+".service.d")
}
// attachedUnitPathIn is attachedUnitPath with the directory as a parameter, so
// tests can point it at a temporary tree.
func attachedUnitPathIn(dir, name string) string {
return filepath.Join(dir, name+".service")
}
// attachedUnitPath is the unit file portabled writes when an image is attached,
// and removes when it is detached. Its existence is hosty's definition of "this
// app is currently published".
func attachedUnitPath(name string) string {
return attachedUnitPathIn(attachedUnitDir, name)
}
// hostyDropinName is the ONLY file hosty may create or delete inside an
// attached service's drop-in directory. The rest of that directory
// (10-profile.conf, 20-portable.conf) belongs to portabled and is its record of
// the attachment — removing those makes it lose the image and fail to detach.
//
// The "50-" prefix makes it sort after portabled's own drop-ins, so that later
// definitions win and the security profile does not clobber hosty's runtime
// wiring. It was "10-hosty.conf" once, where the profile silently overrode it.
const hostyDropinName = "50-hosty.conf"
// writeImageUnit synthesizes the portable-service unit for system mode into the
// image tree at a path systemd-portabled actually scans. The ExecStart is the
// image-absolute command from _hosty_meta; runtime bits (env, port, binds,
// credentials) are layered on via the companion drop-in after attach.
//
// It is written to BOTH etc/systemd/system and usr/lib/systemd/system: the
// former is what portabled scans on NixOS (which drops FHS unit paths), the
// latter is the conventional portable-service location for FHS hosts, so the
// same image also works if attached on a standard distro.
func writeImageUnit(name, imageDir string, dataDB *sql.DB) error {
execStart, err := metaGet(dataDB, "ExecStart")
if err != nil || execStart == "" {
execStart = "/usr/bin/" + name
}
version, _ := metaGet(dataDB, "version")
contents := fmt.Sprintf(`# Written by hosty. Do not edit; synthesized from _hosty_meta at deploy time.
[Unit]
Description=%s %s
[Service]
ExecStart=%s
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
`, name, version, execStart)
for _, rel := range []string{"etc/systemd/system", "usr/lib/systemd/system"} {
dir := filepath.Join(imageDir, rel)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, name+".service"), []byte(contents), 0644); err != nil {
return err
}
}
// Keep os-release's PORTABLE_PREFIXES in step with the unit we just wrote.
//
// `hosty pack` bakes PORTABLE_PREFIXES=<packed name> into usr/lib/os-release,
// but the unit above is named after the *install* name, which `-name`
// may override. portablectl cross-checks the two and refuses to attach when
// they disagree:
//
// Selected matches 'foo' are not compatible with portable service image
// '/var/lib/hosty/images/foo', refusing. (Acceptable prefix matches are: bar)
//
// The unit is deliberately synthesized here rather than packed (see
// writeScaffold); the prefix list is part of that same "named at deploy
// time" contract and has to be rewritten alongside it.
return rewritePortablePrefixes(imageDir, name)
}
// rewritePortablePrefixes sets PORTABLE_PREFIXES=<name> in the image's
// os-release, leaving every other field untouched. Both the FHS and the
// systemd-preferred locations are updated when present, since portabled reads
// whichever it finds first.
func rewritePortablePrefixes(imageDir, name string) error {
for _, rel := range []string{"usr/lib/os-release", "etc/os-release"} {
path := filepath.Join(imageDir, rel)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("reading %s: %w", rel, err)
}
lines := strings.Split(string(data), "\n")
found := false
for i, line := range lines {
if strings.HasPrefix(line, "PORTABLE_PREFIXES=") {
lines[i] = "PORTABLE_PREFIXES=" + name
found = true
}
}
if !found {
// Nothing to keep in step; an image without the field is attached
// under its directory basename, which is already <name>.
continue
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil {
return fmt.Errorf("writing %s: %w", rel, err)
}
}
return nil
}
// setupSystemUnits synthesizes the portable-service unit into the image, writes
// the FUSE companion unit + hosty drop-in, then attaches the image and starts it.
//
// The image tree at imageDir carries os-release (PORTABLE_PREFIXES=<name>) but
// NOT a systemd unit — hosty synthesizes <name>.service here from _hosty_meta.
// It is written to <image>/etc/systemd/system (not usr/lib/systemd/system):
// systemd-portabled on NixOS does not scan /usr/lib/systemd/system, so a unit
// there would be invisible. `portablectl attach` then copies the unit into
// /run/systemd/system.attached/ and adds a RootDirectory= drop-in so the app
// runs inside the image. Our companion drop-in injects the runtime bits that
// only exist on the host: the assigned port, config env, credentials, and
// BindPaths for data.hosty + the FUSE dir.
func setupSystemUnits(name, port, imageDir, dataPath, fsDir, credsDir, stateDir string, dataDB, envDB *sql.DB) error {
// Synthesize the portable unit into a location portabled scans.
if err := writeImageUnit(name, imageDir, dataDB); err != nil {
return fmt.Errorf("writing image unit: %w", err)
}
// FUSE companion unit lives on the host (system manager). It is coupled to
// the app service so the two live and die together (see writeSystemFSUnit).
if err := writeSystemFSUnit(name, stateDir); err != nil {
return fmt.Errorf("writing system fs unit: %w", err)
}
// (Re)attach the portable image so a changed unit/image is picked up.
//
// Detaching is not just "portablectl detach": portabled refuses while the
// unit is still active ("Unit file '<name>.service' is active, can't
// detach") and again while our drop-in still occupies the attached unit
// directory ("Directory not empty"). A bare detach therefore failed
// silently on a running app, and the following attach then failed hard with
// "Unit file '<name>.service' exists on the host already, refusing" —
// making `hosty start` on an installed, running app impossible, which is
// exactly the upgrade path.
//
// detachSystem already encodes that ordering for `hosty stop`; reuse it
// rather than keeping a second, subtly weaker copy here.
if isAttached(name) {
detachSystem(name, imageDir, fsDir)
}
// Always the 'default' portable profile: DynamicUser, ProtectSystem=strict and
// a restricted capability set (see portablectl(1) PROFILES). Selecting the
// profile is a host-policy decision rather than a packaging one, so it is
// deliberately not configurable via the image — revisit by adding a flag on
// 'hosty start -system' if a real need for 'trusted'/'strict' shows up.
if out, err := exec.Command("portablectl", "attach", "--runtime", "--enable",
"--profile=default", "--copy=symlink", imageDir, name).CombinedOutput(); err != nil {
return fmt.Errorf("portablectl attach: %w\n%s", err, out)
}
// Write our companion drop-in into the attached location.
if err := writeAttachDropin(name, port, dataPath, fsDir, credsDir, dataDB); err != nil {
return fmt.Errorf("writing attach drop-in: %w", err)
}
if err := runCmd("systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("daemon-reload: %w", err)
}
return nil
}
// isAttached reports whether the named portable image is attached, by reading
// `portablectl list` rather than asking `portablectl is-attached`.
//
// `is-attached` cannot be trusted for this. It resolves its argument loosely
// and reports some *other* image's state when the one asked about is not
// attached — including for an argument that does not exist at all:
//
// # hosty-hello attached and running, rss-parrot-throwaway detached
// $ portablectl is-attached rss-parrot-throwaway → running-runtime
// $ portablectl is-attached /var/lib/hosty/images/nope → running-runtime
// $ portablectl list | grep rss-parrot-throwaway → detached
//
// Switching from the name form to the image-path form does not fix it, as the
// third line above shows. Every confusing failure in system mode on a host
// with more than one app traced back to this: detach loops that could not
// terminate because a detached image still looked attached, and errors naming
// an unrelated app ("Unit file 'hosty-hello.service' is active, can't detach"
// while detaching rss-parrot-throwaway).
//
// `portablectl list` is the only output that reports per-image state
// correctly. With --no-legend each line is "NAME TYPE RO CRTIME MTIME USAGE
// STATE", so the name is the first field and the state the last.
func isAttached(name string) bool {
out, err := exec.Command("portablectl", "list", "--no-legend").Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != name {
continue
}
return fields[len(fields)-1] != "detached"
}
// Not listed at all: never attached, or already fully cleaned up.
return false
}
// systemdEnvValue quotes a value for use in a systemd Environment= assignment.
//
// systemd splits the right-hand side on whitespace and treats each part as a
// separate KEY=VALUE assignment, so an unquoted value containing spaces loses
// everything after the first word ("Invalid environment assignment, ignoring").
// Double quotes make it a single token; backslashes and quotes inside the value
// have to be escaped so they survive that unquoting.
func systemdEnvValue(v string) string {
if !strings.ContainsAny(v, " \t\"'\\") {
return v
}
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return `"` + r.Replace(v) + `"`
}
// writeAttachDropin writes the companion drop-in for a system-attached service.
// It carries everything writeUnitFile emits for user mode except ExecStart and
// RootDirectory (those come from the packed unit + portablectl's own drop-in):
// port/db/fs env, app aliases, config env, credentials, and BindPaths for
// data.hosty and the FUSE dir into the app's RootDirectory namespace.
// The environment database is deliberately not a parameter: sensitive values
// reach the app as credential files (written by writeCredentials) and are
// referenced here only by path, so the unit generator never needs to read a
// secret itself.
func writeAttachDropin(name, port, dataPath, fsDir, credsDir string, dataDB *sql.DB) error {
dir := attachDropinDir(name)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
var b strings.Builder
fmt.Fprintf(&b, "# Written by hosty. Do not edit; regenerated on each 'hosty start -system'.\n")
fmt.Fprintf(&b, "[Unit]\n")
// Couple the app to the FUSE unit: app requires it, starts after it, and
// stops if it drops (BindsTo+After). PartOf on the fs unit handles the
// reverse (app stop/restart → fs stop/unmount).
fmt.Fprintf(&b, "Requires=%s-fs.service\n", name)
fmt.Fprintf(&b, "BindsTo=%s-fs.service\n", name)
fmt.Fprintf(&b, "After=%s-fs.service\n\n", name)
fmt.Fprintf(&b, "[Service]\n")
// NOTE: the host's /nix/store is deliberately NOT bound in here.
//
// Nix-built app binaries are dynamically linked against absolute store paths
// baked into the ELF (interpreter in .interp, libraries in DT_RUNPATH), which
// RootDirectory= hides — so this used to carry BindReadOnlyPaths=-/nix/store.
// That worked, but it quietly broke the thing a .hosty file is supposed to be:
// an image that runs anywhere. An image built on one machine only ran on
// another because both happened to have the same store paths, nothing recorded
// which paths were needed, and a GC on the host could break an installed app
// with no indication why.
//
// It was also the largest hole in the sandbox. System mode exists to run
// arbitrary third-party apps, and binding the whole store into every app's
// namespace showed each of them the complete inventory of the machine —
// every package and version installed, and any secret that ended up in a
// store path.
//
// Images now carry their own closure instead (see closureMap in default.nix),
// placed at the identical absolute paths, which is what the loader needs and
// all it needs. Statically linked apps need nothing either way.
// Writable data under an immutable image. The portable 'default' profile sets
// ProtectSystem=strict (whole namespace read-only), so the app cannot write
// into the image itself.
//
// StateDirectory= hands the directory to systemd: it creates it under
// /var/lib, chowns it to the service's (dynamic) user and binds it into the
// namespace writable, all while ProtectSystem=strict keeps the rest read-only.
// With DynamicUser the real directory lives at /var/lib/private/<...> with a
// symlink at the public path; hosty runs as root and so still reaches it.
appDir := filepath.Dir(dataPath)
stateRel := strings.TrimPrefix(appDir, "/var/lib/")
if stateRel == appDir {
return fmt.Errorf("system mode requires state dir under /var/lib (got %s)", appDir)
}
fmt.Fprintf(&b, "StateDirectory=%s\n", stateRel)
// FUSE dir bound in at a stable path; rbind captures the live submount (present
// via After=), not the empty underlying dir. It is a separate mount, so mark it
// writable explicitly to punch through ProtectSystem=strict.
fmt.Fprintf(&b, "BindPaths=%s:/hosty-fs:rbind\n", fsDir)
fmt.Fprintf(&b, "ReadWritePaths=/hosty-fs\n")
fmt.Fprintf(&b, "Environment=HOSTY_DB=%s\n", dataPath)
fmt.Fprintf(&b, "Environment=HOSTY_PORT=%s\n", port)
fmt.Fprintf(&b, "Environment=HOSTY_FS=/hosty-fs\n")
// See writeUnitFile: RootDirectory= makes the image the root here, so
// image-absolute paths need no prefix at all.
fmt.Fprintf(&b, "Environment=HOSTY_ROOT=\n")
fmt.Fprintf(&b, "Environment=DB_CONNECTION_STRING=%s\n", dataPath)
// App-specific aliases for the hosty magic vars.
if alias, err := metaGet(dataDB, "hosty_port_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=%s\n", alias, port)
}
if alias, err := metaGet(dataDB, "hosty_db_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=%s\n", alias, dataPath)
}
if alias, err := metaGet(dataDB, "hosty_fs_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=/hosty-fs\n", alias)
}
// Config env vars from _hosty_config.
cfgRows, err := dataDB.Query(`SELECT key, sensitive, default_val, value FROM _hosty_config ORDER BY key`)
if err == nil {
defer cfgRows.Close()
for cfgRows.Next() {
var key string
var sens int
var defaultVal, value sql.NullString
if err := cfgRows.Scan(&key, &sens, &defaultVal, &value); err != nil {
continue
}
if sens == 1 {
// Sensitive: LoadCredential= from the host creds file +
// Environment=KEY=%d/KEY pointing into the credentials dir.
credPath := filepath.Join(credsDir, key)
if fileExists(credPath) {
fmt.Fprintf(&b, "LoadCredential=%s:%s\n", key, credPath)
fmt.Fprintf(&b, "Environment=%s=%%d/%s\n", key, key)
}
} else {
if value.Valid {
fmt.Fprintf(&b, "Environment=%s=%s\n", key, systemdEnvValue(value.String))
} else if defaultVal.Valid {
fmt.Fprintf(&b, "Environment=%s=%s\n", key, systemdEnvValue(defaultVal.String))
}
}
}
}
return os.WriteFile(filepath.Join(dir, hostyDropinName), []byte(b.String()), 0644)
}
// writeSystemFSUnit writes the system-manager FUSE companion unit
// <name>-fs.service. Unlike the user-mode variant it is coupled to the app
// service via PartOf= so that stopping/restarting the app also stops/unmounts
// the FUSE filesystem.
func writeSystemFSUnit(name, stateDir string) error {
// /run is writable on NixOS (unlike /etc/systemd/system) and is in the
// system manager's unit search path.
unitDir := "/run/systemd/system"
if err := os.MkdirAll(unitDir, 0755); err != nil {
return err
}
hostyBin, err := os.Executable()
if err != nil {
return fmt.Errorf("resolving hosty executable: %w", err)
}
unitPath := filepath.Join(unitDir, name+"-fs.service")
contents := fmt.Sprintf(`# Written by hosty. Do not edit.
[Unit]
Description=Hosty FS: %s
# Stop/restart together with the app service.
PartOf=%s.service
[Service]
# go-fuse mounts via the setuid 'fusermount'/'fusermount3' helper, found on PATH.
# On NixOS the setuid wrapper lives in /run/wrappers/bin; keep /bin:/usr/bin as
# fallbacks for FHS hosts. Without this the unit has an empty PATH and mount fails.
Environment=PATH=/run/wrappers/bin:/bin:/usr/bin
# Provision the mount point under /run. Unlike StateDirectory=, RuntimeDirectory=
# is never relocated for DynamicUser services, so the app (DynamicUser, via a bind
# mount) and this unit (root) agree on one stable path.
RuntimeDirectory=%s
RuntimeDirectoryPreserve=restart
# The mount must survive into the app's namespace, so make it shared: the app unit
# bind-mounts it with rbind and needs to see the FUSE submount, not an empty dir.
MountFlags=shared
# The app runs as a DynamicUser, i.e. a different uid than this root-mounted FUSE
# filesystem; -allow-other lifts FUSE's default same-user-only restriction.
ExecStart=%s fs-serve -name %s -state-dir %s -fs-dir %s -allow-other
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
`, name, name, systemRuntimeSubdir(name), hostyBin, name, stateDir, systemFSDir(name))
return os.WriteFile(unitPath, []byte(contents), 0644)
}
// cleanupSystem detaches the portable image and removes the FUSE unit + host
// FUSE mount, mirroring cleanupRuntime for user mode.
func cleanupSystem(name, imageDir, fsDir, credsDir string) {
fmt.Fprintf(os.Stderr, "hosty: stopping %s (system)\n", name)
detachSystem(name, imageDir, fsDir)
// The fs unit is hosty's own, not portabled's, so it is removed here rather
// than in detachSystem: a re-attach rewrites it in place and must not have
// it vanish underneath.
_ = os.Remove(filepath.Join("/run/systemd/system", name+"-fs.service"))
_ = runCmd("systemctl", "daemon-reload")
fmt.Fprintf(os.Stderr, "hosty: removing image tree\n")
_ = os.RemoveAll(imageDir)
_ = os.RemoveAll(credsDir)
}
// detachSystem stops an app's units and detaches its portable image, in the
// order portabled requires. Used both when tearing an app down (`hosty stop`)
// and before re-attaching a changed image (`hosty start` on an installed app),
// which is why it lives apart from the rest of cleanupSystem: everything here
// is about getting the image detached, nothing about deleting it.
//
// Each step exists because portabled refuses to detach otherwise:
//
// - a running unit → "Unit file '<name>.service' is active, can't detach"
// - a live FUSE mount → "Directory not empty"
// - our own drop-in file → "Directory not empty"
//
// and a failed detach is not benign: the subsequent attach then fails with
// "Unit file '<name>.service' exists on the host already, refusing".
func detachSystem(name, imageDir, fsDir string) {
// Stop the app first (PartOf propagates the stop to the fs unit), then the
// fs unit explicitly, so the FUSE mount is gone before the detach.
_ = runCmd("systemctl", "stop", name+".service")
_ = runCmd("systemctl", "stop", name+"-fs.service")
// Best-effort unmount in case fs-serve did not shut down cleanly.
if fsDir != "" && isMountPoint(fsDir) {
_ = runCmd("fusermount", "-u", fsDir)
}
// Remove ONLY our own drop-in file, never the whole directory.
//
// portabled refuses to detach while the attached unit directory holds files
// it does not own ("Directory not empty"), so 50-hosty.conf has to go first.
// But the directory also holds portabled's own 10-profile.conf and
// 20-portable.conf, and those are its record of the attachment: deleting
// them makes it lose track of the image entirely, after which detach fails
// with "No unit files associated with '<image>' found attached to the
// system. Image not attached?" and leaves the unit symlink behind.
//
// This was an os.RemoveAll of the directory, which is precisely that
// mistake. Measured on an otherwise identical stop-then-detach:
//
// remove 50-hosty.conf only → exit 0, portabled removes the unit, both
// its drop-ins, the dir and /run/portables
// remove the whole dir → detach fails, unit symlink orphaned
//
// The stale symlinks this used to leave behind were then cleaned up by
// hand afterwards — compensating for self-inflicted damage rather than
// fixing it. Removing just our file lets portabled clean up after itself.
_ = os.Remove(filepath.Join(attachDropinDir(name), hostyDropinName))
// Detach, retrying briefly: unit/mount teardown above is asynchronous, so the
// first attempt can still race with systemd releasing the attached directory.
// Detach by image path, not by name: the name form matches loosely and can
// act on a different image than intended. The path is what identifies an
// attachment, and it still exists at this point (cleanupSystem deletes the
// image tree only after this returns).
for i := 0; i < 10 && isAttached(name); i++ {
if err := runCmd("portablectl", "detach", "--runtime", imageDir); err == nil {
break
}
time.Sleep(300 * time.Millisecond)
}
if isAttached(name) {
fmt.Fprintf(os.Stderr, "hosty: warning: %s is still attached after detach attempts\n", name)
}
// Clean up the one thing detach does not: the enablement symlink.
//
// A successful detach removes everything it created — the unit symlink, its
// own drop-ins, the drop-in directory and /run/portables/<name> — EXCEPT
// default.target.wants/<name>.service, which `attach --enable` created and
// which survives even an exit-0 detach. Measured on a bare attach/detach
// pair with no hosty involvement:
//
// attach (no --enable) → detach leaves nothing
// attach --enable → detach leaves default.target.wants/<name>.service
//
// The orphan does not block a later attach (that was verified too: attaching
// over it succeeds). It is removed because it is a dangling symlink into an
// image tree that cleanupSystem is about to delete, which systemd would
// otherwise keep reporting as a broken enablement entry.
//
// The unit symlink itself is deliberately NOT removed here. Doing so would
// paper over a failed detach — the state to fix, not to hide — and the loop
// above already warns when the image is still attached.
_ = os.Remove(filepath.Join("/run/systemd/system.attached/default.target.wants", name+".service"))
_ = runCmd("systemctl", "daemon-reload")
}
// isMountPoint reports whether path is currently a mount point.
func isMountPoint(path string) bool {
return exec.Command("mountpoint", "-q", path).Run() == nil
}
// upgradeImage replaces _hosty_image and relevant _hosty_meta keys in dataDB
// with values from fileDB.
func upgradeImage(dataDB, fileDB *sql.DB) error {
tx, err := dataDB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Full replace: delete all existing image rows, copy from new .hosty.
if _, err := tx.Exec(`DELETE FROM _hosty_image_fs`); err != nil {
return err
}
rows, err := fileDB.Query(`SELECT path, mode, mtime, symlink_target, content, content_zstd FROM _hosty_image_fs ORDER BY path`)
if err != nil {
return fmt.Errorf("reading _hosty_image_fs from new file: %w", err)
}
defer rows.Close()
for rows.Next() {
var imgPath string
var mode, mtime int64
var symlinkTarget sql.NullString
var content []byte
var contentZstd []byte
if err := rows.Scan(&imgPath, &mode, &mtime, &symlinkTarget, &content, &contentZstd); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO _hosty_image_fs (path, mode, mtime, symlink_target, content, content_zstd) VALUES (?, ?, ?, ?, ?, ?)`,
imgPath, mode, mtime, symlinkTarget, content, contentZstd,
); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
// Update meta keys from the new file.
for _, key := range []string{"version", "description", "image_sha256", "ExecStart", "hosty_port_env", "hosty_db_env", "hosty_fs_env"} {
val, err := metaGet(fileDB, key)
if err != nil {
continue // non-fatal if key missing (e.g. description)
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO _hosty_meta (key, value) VALUES (?, ?)`, key, val); err != nil {
return err
}
}
// Replace _hosty_config from the new file (new version may add/remove/change config keys).
if _, err := tx.Exec(`DELETE FROM _hosty_config`); err != nil {
return err
}
cfgRows, err := fileDB.Query(`SELECT key, description, required, sensitive, default_val FROM _hosty_config`)
if err == nil {
defer cfgRows.Close()
for cfgRows.Next() {
var key, desc string
var required, sensitive int
var defaultVal sql.NullString
if err := cfgRows.Scan(&key, &desc, &required, &sensitive, &defaultVal); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO _hosty_config (key, description, required, sensitive, default_val) VALUES (?, ?, ?, ?, ?)`,
key, desc, required, sensitive, defaultVal,
); err != nil {
return err
}
}
}
return tx.Commit()
}
// extractImageFS extracts all rows from _hosty_image_fs into rootfsDir.
// Files with content_zstd are transparently decompressed; symlinks are created;
// directory mode bits are applied. Supports both the new _hosty_image_fs format
// and legacy _hosty_image (squashfs blob) for backwards compatibility.
func extractImageFS(db *sql.DB, rootfsDir string) error {
// Check which format we have.
var hasImageFS bool
row := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_hosty_image_fs'`)
var count int
if err := row.Scan(&count); err == nil && count > 0 {
hasImageFS = true
}
if !hasImageFS {
return fmt.Errorf("no _hosty_image_fs table found in data.hosty; legacy squashfs format is no longer supported")
}
rows, err := db.Query(`SELECT path, mode, symlink_target, content, content_zstd FROM _hosty_image_fs ORDER BY path`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var imgPath string
var mode int64
var symlinkTarget sql.NullString
var content []byte
var contentZstd []byte
if err := rows.Scan(&imgPath, &mode, &symlinkTarget, &content, &contentZstd); err != nil {
return err
}
dst := filepath.Join(rootfsDir, filepath.FromSlash(imgPath))
if symlinkTarget.Valid {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
_ = os.Remove(dst) // remove if exists from prior extract
if err := os.Symlink(symlinkTarget.String, dst); err != nil {
return err
}
continue
}
fileMode := os.FileMode(mode & 0777)
if os.FileMode(mode)&os.ModeDir != 0 {
if err := os.MkdirAll(dst, fileMode); err != nil {
return err
}
continue
}
// Regular file.
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
var data []byte
if len(contentZstd) > 0 {
data, err = zstdDecompress(contentZstd)
if err != nil {
return fmt.Errorf("decompressing %s: %w", imgPath, err)
}
} else {
data = content
}
if err := os.WriteFile(dst, data, fileMode); err != nil {
return err
}
if err := os.Chmod(dst, fileMode); err != nil {
return err
}
}
return rows.Err()
}
// writeCredentials writes sensitive config values to per-key files under
// <appDir>/credentials/ (chmod 700 dir, chmod 600 files). The directory is
// ephemeral — it is reconstructed from the environment database on every hosty
// start and removed on hosty stop, so secrets are never left on disk between
// runs. Returns the credentials directory path.
//
// This is the only path by which a secret reaches an app, and it is why the
// store itself must live outside the app's own directory: these files are
// written per start, for one app, from a database the app cannot read.
func writeCredentials(appDir string, dataDB, envDB *sql.DB, app string) (string, error) {
credsDir := filepath.Join(appDir, "credentials")
if err := os.MkdirAll(credsDir, 0700); err != nil {
return "", fmt.Errorf("creating credentials dir: %w", err)
}
rows, err := dataDB.Query(`SELECT key, default_val FROM _hosty_config WHERE sensitive = 1 ORDER BY key`)
if err != nil {
return credsDir, nil // no sensitive config declared
}
defer rows.Close()
for rows.Next() {
var key string
var defaultVal sql.NullString
if err := rows.Scan(&key, &defaultVal); err != nil {
continue
}
// Value precedence: stored secret (user-set) > default_val.
var val string
if sv, err := secretGet(envDB, app, key); err == nil {
val = sv
} else if defaultVal.Valid {
val = defaultVal.String
} else {
continue // no value — skip (setup already warned)
}
credPath := filepath.Join(credsDir, key)
if err := os.WriteFile(credPath, []byte(val), 0600); err != nil {
return "", fmt.Errorf("writing credential %s: %w", key, err)
}
}
return credsDir, rows.Err()
}
// As with writeAttachDropin, no environment database here: secrets are passed
// as credential file paths, never read by the unit generator.
func writeUnitFile(name, port, rootfs, dataPath, fsDir, credsDir string, dataDB *sql.DB) error {
// Read ExecStart from _hosty_meta; fall back to /usr/bin/<name>.
execStart, err := metaGet(dataDB, "ExecStart")
if err != nil {
execStart = "/usr/bin/" + name
}
execStart = rootfs + execStart
stateDir := filepath.Dir(dataPath)
unitDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user")
if err := os.MkdirAll(unitDir, 0755); err != nil {
return err
}
var b strings.Builder
fmt.Fprintf(&b, "[Unit]\nDescription=Hosty: %s\nRequires=%s-fs.service\nAfter=%s-fs.service\n\n", name, name, name)
fmt.Fprintf(&b, "[Service]\n")
fmt.Fprintf(&b, "WorkingDirectory=%s\n", stateDir)
fmt.Fprintf(&b, "ExecStart=%s\n", execStart)
fmt.Fprintf(&b, "Environment=HOSTY_DB=%s\n", dataPath)
fmt.Fprintf(&b, "Environment=HOSTY_PORT=%s\n", port)
fmt.Fprintf(&b, "Environment=HOSTY_FS=%s\n", fsDir)
fmt.Fprintf(&b, "Environment=HOSTY_STATE_DIR=%s\n", stateDir)
// Prefix that turns an image-absolute path (/usr/bin/foo) into one the app can
// actually open. In user mode the image is an ordinary directory on the host,
// so it is that directory; in system mode RootDirectory= makes the image the
// root itself, so it is "/". Apps needing to reach their own files should use
// $HOSTY_ROOT/usr/... rather than a path relative to the working directory,
// which is not set in system mode.
fmt.Fprintf(&b, "Environment=HOSTY_ROOT=%s\n", rootfs)
fmt.Fprintf(&b, "Environment=DB_CONNECTION_STRING=%s\n", dataPath)
// Emit aliases for apps that use different env var names for the hosty magic vars.
if alias, err := metaGet(dataDB, "hosty_port_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=%s\n", alias, port)
}
if alias, err := metaGet(dataDB, "hosty_db_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=%s\n", alias, dataPath)
}
if alias, err := metaGet(dataDB, "hosty_fs_env"); err == nil && alias != "" {
fmt.Fprintf(&b, "Environment=%s=%s\n", alias, fsDir)
}
// Config env vars from _hosty_config.
cfgRows, err := dataDB.Query(`SELECT key, sensitive, default_val, value FROM _hosty_config ORDER BY key`)
if err == nil {
defer cfgRows.Close()
for cfgRows.Next() {
var key string
var sens int
var defaultVal, value sql.NullString
if err := cfgRows.Scan(&key, &sens, &defaultVal, &value); err != nil {
continue
}
if sens == 1 {
// Sensitive: use LoadCredential= + Environment=KEY=%d/KEY.
// The credential file was written by writeCredentials().
credPath := filepath.Join(credsDir, key)
if fileExists(credPath) {
fmt.Fprintf(&b, "LoadCredential=%s:%s\n", key, credPath)
fmt.Fprintf(&b, "Environment=%s=%%d/%s\n", key, key)
}
// If no credential file: omit entirely (setup already warned).
} else {
// Non-sensitive: plain Environment= line.
if value.Valid {
fmt.Fprintf(&b, "Environment=%s=%s\n", key, systemdEnvValue(value.String))
} else if defaultVal.Valid {
fmt.Fprintf(&b, "Environment=%s=%s\n", key, systemdEnvValue(defaultVal.String))
}
}
}
}
fmt.Fprintf(&b, "Restart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=default.target\n")
unitPath := filepath.Join(unitDir, name+".service")
return os.WriteFile(unitPath, []byte(b.String()), 0644)
}
// writeFSUnitFile writes the companion <name>-fs.service that mounts _hosty_fs.
func writeFSUnitFile(name, stateDir string) error {
unitDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user")
if err := os.MkdirAll(unitDir, 0755); err != nil {
return err
}
// Resolve hosty binary path (self).
hostyBin, err := os.Executable()
if err != nil {
return fmt.Errorf("resolving hosty executable: %w", err)
}
unitPath := filepath.Join(unitDir, name+"-fs.service")
contents := fmt.Sprintf(`[Unit]
Description=Hosty FS: %s
[Service]
ExecStart=%s fs-serve -name %s -state-dir %s
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
`, name, hostyBin, name, stateDir)
return os.WriteFile(unitPath, []byte(contents), 0644)
}
func removeUnitFile(name string) error {
unitPath := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", name+".service")
_ = os.Remove(unitPath)
fsUnitPath := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", name+"-fs.service")
_ = os.Remove(fsUnitPath)
return nil
}
func cleanupRuntime(name, rootfs, fsDir, credsDir string) {
fmt.Fprintf(os.Stderr, "hosty: stopping %s\n", name)
_ = runCmd("systemctl", "--user", "disable", "--now", name+".service")
_ = runCmd("systemctl", "--user", "disable", "--now", name+"-fs.service")
_ = removeUnitFile(name)
_ = runCmd("systemctl", "--user", "daemon-reload")
// Attempt to unmount the FUSE fs dir (may already be unmounted if fs-serve stopped).
if fsDir != "" {
_ = runCmd("fusermount", "-u", fsDir)
}
fmt.Fprintf(os.Stderr, "hosty: removing rootfs\n")
_ = os.RemoveAll(rootfs)
_ = os.RemoveAll(credsDir)
}
|