A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

harden systemd units

Aly Raffauf (Jun 19, 2026, 1:00 PM EDT) 4fda6a24 a718417a

+161 -21
+8 -8
cmd/appherder/cli.go
··· 33 33 newMigrateCommand(a), 34 34 newUpgradeCommand(a), 35 35 newRollbackCommand(a), 36 - newAutosyncCommand(), 37 - newAutoupgradeCommand(), 36 + newAutosyncCommand(a), 37 + newAutoupgradeCommand(a), 38 38 ) 39 39 return cmd 40 40 } ··· 253 253 } 254 254 } 255 255 256 - func newAutosyncCommand() *cobra.Command { 256 + func newAutosyncCommand(a appherder.App) *cobra.Command { 257 257 return newAutoCommand( 258 258 "autosync", 259 259 "Enable or disable automatic sync when AppImages change", 260 - "Installs a systemd user unit that watches ~/AppImages and runs sync whenever a\n"+ 261 - "file is added or removed. No root required.", 260 + "Installs a systemd user unit that watches the configured AppImages directory\n"+ 261 + "and runs sync whenever a file is added or removed. No root required.", 262 262 "Disable and remove the autosync watcher", 263 - func() error { return enableUnits(syncUnits) }, 263 + func() error { return enableUnits(a, syncUnits) }, 264 264 func() error { return disableUnits(syncUnits) }, 265 265 ) 266 266 } 267 267 268 - func newAutoupgradeCommand() *cobra.Command { 268 + func newAutoupgradeCommand(a appherder.App) *cobra.Command { 269 269 return newAutoCommand( 270 270 "autoupgrade", 271 271 "Enable or disable daily automatic upgrades", 272 272 "Installs a systemd user timer that checks for and installs AppImage updates\n"+ 273 273 "once a day. No root required.", 274 274 "Disable and remove the upgrade timer", 275 - func() error { return enableUnits(upgradeUnits) }, 275 + func() error { return enableUnits(a, upgradeUnits) }, 276 276 func() error { return disableUnits(upgradeUnits) }, 277 277 ) 278 278 }
+75 -7
cmd/appherder/systemd.go
··· 6 6 "os" 7 7 "os/exec" 8 8 "path/filepath" 9 + "strconv" 9 10 "strings" 11 + 12 + "github.com/alyraffauf/appherder/internal/appherder" 10 13 ) 11 14 12 15 //go:embed systemd/* ··· 22 25 "appherder-upgrade.service", 23 26 } 24 27 25 - func enableUnits(units []string) error { 26 - if err := writeUnitFiles(units); err != nil { 28 + func enableUnits(a appherder.App, units []string) error { 29 + if err := ensureServiceWritePaths(a); err != nil { 30 + return err 31 + } 32 + if err := writeUnitFiles(a, units); err != nil { 27 33 return err 28 34 } 29 35 if err := runSystemctl("daemon-reload"); err != nil { ··· 32 38 return runSystemctl("enable", "--now", units[0]) 33 39 } 34 40 41 + func ensureServiceWritePaths(a appherder.App) error { 42 + for _, path := range a.ServiceWritePaths() { 43 + if err := os.MkdirAll(path, 0o755); err != nil { 44 + return fmt.Errorf("create service write directory %s: %w", path, err) 45 + } 46 + } 47 + return nil 48 + } 49 + 35 50 func disableUnits(units []string) error { 36 51 if err := runSystemctl("disable", "--now", units[0]); err != nil { 37 52 return err ··· 56 71 return bin, nil 57 72 } 58 73 59 - // writeUnitFiles renders the named templates with the appherder binary path 60 - // and writes them to the systemd user directory. 61 - func writeUnitFiles(names []string) error { 74 + // writeUnitFiles renders the named templates with the appherder binary and 75 + // configured directories, then writes them to the systemd user directory. 76 + func writeUnitFiles(a appherder.App, names []string) error { 62 77 bin, err := binaryPath() 63 78 if err != nil { 64 79 return err ··· 75 90 if err != nil { 76 91 return fmt.Errorf("read %s: %w", name, err) 77 92 } 78 - escaped := strings.ReplaceAll(bin, "%", "%%") 79 - rendered := strings.ReplaceAll(string(data), "{{BIN}}", escaped) 93 + rendered, err := renderUnitTemplate(string(data), bin, a) 94 + if err != nil { 95 + return fmt.Errorf("render %s: %w", name, err) 96 + } 80 97 dest := filepath.Join(userDir, name) 81 98 if err := os.WriteFile(dest, []byte(rendered), 0o644); err != nil { 82 99 return fmt.Errorf("write %s: %w", dest, err) 83 100 } 84 101 } 85 102 return nil 103 + } 104 + 105 + func renderUnitTemplate(template, bin string, a appherder.App) (string, error) { 106 + appimagesDir, err := systemdPathValue(a.AppImagesDir()) 107 + if err != nil { 108 + return "", err 109 + } 110 + writePaths, err := systemdPathList(a.ServiceWritePaths()) 111 + if err != nil { 112 + return "", err 113 + } 114 + 115 + rendered := strings.ReplaceAll(template, "{{BIN}}", systemdSpecifierEscape(bin)) 116 + rendered = strings.ReplaceAll(rendered, "{{APPIMAGES_DIR}}", appimagesDir) 117 + rendered = strings.ReplaceAll(rendered, "{{READ_WRITE_PATHS}}", writePaths) 118 + return rendered, nil 119 + } 120 + 121 + func systemdPathList(paths []string) (string, error) { 122 + seen := make(map[string]bool, len(paths)) 123 + values := make([]string, 0, len(paths)) 124 + for _, path := range paths { 125 + if seen[path] { 126 + continue 127 + } 128 + seen[path] = true 129 + value, err := systemdPathValue(path) 130 + if err != nil { 131 + return "", err 132 + } 133 + values = append(values, value) 134 + } 135 + return strings.Join(values, " "), nil 136 + } 137 + 138 + func systemdPathValue(path string) (string, error) { 139 + if path == "" { 140 + return "", fmt.Errorf("empty path") 141 + } 142 + if strings.ContainsAny(path, "\n\r") { 143 + return "", fmt.Errorf("path contains a newline: %q", path) 144 + } 145 + escaped := systemdSpecifierEscape(path) 146 + if strings.ContainsAny(escaped, " \t\"'\\") { 147 + return strconv.Quote(escaped), nil 148 + } 149 + return escaped, nil 150 + } 151 + 152 + func systemdSpecifierEscape(value string) string { 153 + return strings.ReplaceAll(value, "%", "%%") 86 154 } 87 155 88 156 func removeUnitFiles(names []string) error {
+2 -5
cmd/appherder/systemd/appherder-sync.path
··· 1 1 [Unit] 2 - Description=Watch ~/AppImages for appherder sync 2 + Description=Watch AppImages for appherder sync 3 3 4 4 [Path] 5 - # PathChanged watches the directory and everything under it via inotify. If 6 - # ~/AppImages does not yet exist, systemd watches its parent and picks the 7 - # directory up when it is created. 8 - PathChanged=%h/AppImages 5 + PathChanged={{APPIMAGES_DIR}} 9 6 Unit=appherder-sync.service 10 7 11 8 [Install]
+8 -1
cmd/appherder/systemd/appherder-sync.service
··· 1 1 [Unit] 2 - Description=Reconcile ~/AppImages with installed applications 2 + Description=Reconcile AppImages with installed applications 3 3 4 4 [Service] 5 5 Type=oneshot 6 6 ExecStart={{BIN}} sync 7 + NoNewPrivileges=true 8 + PrivateTmp=true 9 + ProtectSystem=strict 10 + RestrictSUIDSGID=true 11 + LockPersonality=true 12 + SystemCallArchitectures=native 13 + ReadWritePaths={{READ_WRITE_PATHS}}
+7
cmd/appherder/systemd/appherder-upgrade.service
··· 4 4 [Service] 5 5 Type=oneshot 6 6 ExecStart={{BIN}} upgrade 7 + NoNewPrivileges=true 8 + PrivateTmp=true 9 + ProtectSystem=strict 10 + RestrictSUIDSGID=true 11 + LockPersonality=true 12 + SystemCallArchitectures=native 13 + ReadWritePaths={{READ_WRITE_PATHS}}
+50
cmd/appherder/systemd_test.go
··· 1 + package main 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/alyraffauf/appherder/internal/appherder" 8 + ) 9 + 10 + func TestRenderUnitTemplateUsesConfiguredPaths(t *testing.T) { 11 + app := appherder.NewAppWithDirs( 12 + "/tmp/App Images/%apps", 13 + "/tmp/data/applications", 14 + "/tmp/App Images/%apps/.icons", 15 + "/tmp/bin dir", 16 + ) 17 + template := strings.Join([]string{ 18 + "ExecStart={{BIN}} sync", 19 + "PathChanged={{APPIMAGES_DIR}}", 20 + "ReadWritePaths={{READ_WRITE_PATHS}}", 21 + }, "\n") 22 + 23 + got, err := renderUnitTemplate(template, "/opt/app%herder", app) 24 + if err != nil { 25 + t.Fatalf("renderUnitTemplate: %v", err) 26 + } 27 + 28 + for _, want := range []string{ 29 + "ExecStart=/opt/app%%herder sync", 30 + `PathChanged="/tmp/App Images/%%apps"`, 31 + `ReadWritePaths="/tmp/App Images/%%apps" /tmp/data/applications "/tmp/bin dir"`, 32 + } { 33 + if !strings.Contains(got, want) { 34 + t.Fatalf("rendered unit missing %q:\n%s", want, got) 35 + } 36 + } 37 + } 38 + 39 + func TestRenderUnitTemplateRejectsNewlinePaths(t *testing.T) { 40 + app := appherder.NewAppWithDirs( 41 + "/tmp/AppImages\nbad", 42 + "/tmp/data/applications", 43 + "/tmp/AppImages/.icons", 44 + "/tmp/bin", 45 + ) 46 + 47 + if _, err := renderUnitTemplate("PathChanged={{APPIMAGES_DIR}}", "/bin/appherder", app); err == nil { 48 + t.Fatal("expected newline path error") 49 + } 50 + }
+11
internal/appherder/app.go
··· 56 56 a.progress = p 57 57 return a 58 58 } 59 + 60 + // AppImagesDir is the directory appherder manages as the source of truth. 61 + func (a App) AppImagesDir() string { 62 + return a.appimagesDir 63 + } 64 + 65 + // ServiceWritePaths returns the directories systemd services need writable for 66 + // sync and upgrade. Icons and saved versions live under AppImagesDir. 67 + func (a App) ServiceWritePaths() []string { 68 + return []string{a.appimagesDir, a.applicationsDir, a.binDir} 69 + }