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
package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// =============================================================================
// hosty stop
// =============================================================================

func cmdStop(args []string) error {
	fset := flag.NewFlagSet("stop", flag.ExitOnError)
	fset.Usage = func() {
		fmt.Print(`Usage: hosty stop -name NAME [-state-dir DIR] [-remove-data] [-system]

Stop and uninstall a hosty service.
Without -system: stops the --user unit. With -system: detaches the root portable
service and removes its host units. Use the same -system as 'hosty start'.
Preserves data.hosty and the app's entry in the environment database by default
(app data, secrets and port assignment).
-remove-data also deletes the app data and forgets its secrets and port (irreversible).

Options:
`)
		fset.PrintDefaults()
	}
	name := fset.String("name", "", "App name (required)")
	stateDir := fset.String("state-dir", "", "State directory (default: $XDG_STATE_HOME/hosty, or /var/lib/hosty with -system)")
	removeData := fset.Bool("remove-data", false, "Also delete data.hosty and all app state (irreversible)")
	system := fset.Bool("system", false, "Detach a root portable service instead of a --user unit")

	if err := fset.Parse(args); err != nil {
		return err
	}
	if *name == "" {
		fset.Usage()
		return fmt.Errorf("-name is required")
	}

	sd, err := resolveStateDir(*stateDir, *system)
	if err != nil {
		return err
	}
	appDir := filepath.Join(sd, *name)
	fsDir := filepath.Join(appDir, "fs")
	credsDir := filepath.Join(appDir, "credentials")

	if *system {
		imageDir := filepath.Join(sd, "images", *name)
		// System mode mounts FUSE under /run (see systemFSDir), not the state dir.
		cleanupSystem(*name, imageDir, systemFSDir(*name), credsDir)
	} else {
		rootfs := filepath.Join(appDir, "rootfs")
		cleanupRuntime(*name, rootfs, fsDir, credsDir)
	}

	if *removeData {
		if err := removeAppData(appDir); err != nil {
			return fmt.Errorf("removing app data: %w", err)
		}
		// The app's secrets and port claim live in the environment database
		// now, not in the directory just deleted. Without this they would
		// survive -remove-data — which used to remove them by removing the
		// file they were in, so forgetting it here is what preserves the
		// existing meaning of the flag rather than changing it.
		//
		// ON DELETE CASCADE takes the secrets with the row; the port is freed
		// by the same delete.
		envDB, err := openEnvDB(sd)
		if err != nil {
			return err
		}
		defer envDB.Close()
		if err := appForget(envDB, *name); err != nil {
			return err
		}
		fmt.Fprintf(os.Stderr, "hosty: removed %s from %s (secrets and port assignment)\n", *name, envDBName)
	}
	return nil
}

// removeAppData deletes an app's state directory, following the StateDirectory=
// indirection systemd introduces in system mode.
//
// With DynamicUser, StateDirectory= puts the real directory at
// /var/lib/private/<...> and leaves only a symlink at the public path. Plain
// RemoveAll on that path therefore unlinks the symlink and silently leaves all
// app data (including secrets) behind — where a later fresh install then trips
// over it. Resolve the link first and remove both ends.
func removeAppData(appDir string) error {
	target, err := filepath.EvalSymlinks(appDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		// Unresolvable (e.g. dangling link): fall back to removing the path itself.
		target = appDir
	}

	if target != appDir {
		// Only follow the link if it stays inside /var/lib, so a hand-edited or
		// hostile symlink cannot redirect a recursive delete somewhere else.
		if !strings.HasPrefix(target, "/var/lib/") {
			return fmt.Errorf("refusing to remove %s: it resolves outside /var/lib (%s)", appDir, target)
		}
		fmt.Fprintf(os.Stderr, "hosty: removing app data at %s (via %s)\n", target, appDir)
		if err := os.RemoveAll(target); err != nil {
			return err
		}
		return os.Remove(appDir)
	}

	fmt.Fprintf(os.Stderr, "hosty: removing app data at %s\n", appDir)
	return os.RemoveAll(appDir)
}