// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package system implements native-host lifecycle: systemd unit install / // uninstall, plus an optional self-update timer. Linux-only by nature; on // other OSes the commands fail with a clear message rather than pretending. package system import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" ) const ( unitPath = "/etc/systemd/system/echolot-server.service" updateUnitPath = "/etc/systemd/system/echolot-server-update.service" updateTimerPath = "/etc/systemd/system/echolot-server-update.timer" envFilePath = "/etc/echolot-server.env" ) const unitTemplate = `[Unit] Description=Echolot probe server Documentation=https://echo-lot.app After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=%s --serve Restart=on-failure RestartSec=5 StateDirectory=echolot-server Environment=ECHOLOT_STATE_DIR=/var/lib/echolot-server # Host-specific config (listen addresses etc.) lives here, not in the unit: EnvironmentFile=-%s # Hardening — the server needs sockets and its state dir, nothing else. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/echolot-server PrivateTmp=true [Install] WantedBy=multi-user.target ` const updateUnitTemplate = `[Unit] Description=Echolot server self-update After=network-online.target [Service] Type=oneshot ExecStart=%s --self-update --self-update-api=%s # The updater only replaces the binary; the restart activates it. ExecStartPost=/usr/bin/systemctl try-restart echolot-server.service ` const updateTimerTemplate = `[Unit] Description=Daily Echolot server self-update check [Timer] OnCalendar=daily RandomizedDelaySec=1h Persistent=true [Install] WantedBy=timers.target ` const envFileTemplate = `# Echolot server host configuration (systemd EnvironmentFile). # Bind explicit addresses on multi-IP hosts — a wildcard would also claim # management-only addresses. Comma-separated lists are supported. #ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443 #ECHOLOT_UDP_LISTEN=203.0.113.10:8442,[2001:db8::10]:8442 #ECHOLOT_TCP_LISTEN=203.0.113.10:8441,[2001:db8::10]:8441 #ECHOLOT_ADMIN_LISTEN=127.0.0.1:8444 #ECHOLOT_NAME=my-server ` // InstallSystemd writes the unit(s) for THIS binary (absolute path), reloads // systemd, and enables the service. When selfUpdateAPI is non-empty, a daily // self-update timer is installed alongside. Idempotent. func InstallSystemd(selfUpdateAPI string) error { if runtime.GOOS != "linux" { return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS) } self, err := os.Executable() if err != nil { return err } self, err = filepath.EvalSymlinks(self) if err != nil { return err } if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, self, envFilePath)), 0o644); err != nil { return fmt.Errorf("writing %s (need root?): %w", unitPath, err) } // Seed the env file once; never overwrite an existing one. if _, err := os.Stat(envFilePath); os.IsNotExist(err) { _ = os.WriteFile(envFilePath, []byte(envFileTemplate), 0o644) } cmds := [][]string{ {"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "echolot-server.service"}, } if selfUpdateAPI != "" { if err := os.WriteFile(updateUnitPath, []byte(fmt.Sprintf(updateUnitTemplate, self, selfUpdateAPI)), 0o644); err != nil { return err } if err := os.WriteFile(updateTimerPath, []byte(updateTimerTemplate), 0o644); err != nil { return err } cmds = append(cmds, []string{"systemctl", "enable", "--now", "echolot-server-update.timer"}) } for _, cmd := range cmds { if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { return fmt.Errorf("%v: %s: %w", cmd, out, err) } } fmt.Printf("installed echolot-server.service (ExecStart=%s, config: %s)\n", self, envFilePath) if selfUpdateAPI != "" { fmt.Println("installed echolot-server-update.timer (daily, randomized)") } return nil } func UninstallSystemd() error { if runtime.GOOS != "linux" { return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS) } // Stop/disable first; ignore "not loaded" errors so uninstall is idempotent. _ = exec.Command("systemctl", "disable", "--now", "echolot-server-update.timer").Run() _ = exec.Command("systemctl", "disable", "--now", "echolot-server.service").Run() for _, p := range []string{unitPath, updateUnitPath, updateTimerPath} { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return err } } _ = exec.Command("systemctl", "daemon-reload").Run() fmt.Println("removed echolot-server units (state dir and env file left in place)") return nil } // RepairExecStart brings an already-installed unit up to date with the current invocation. // // Serving became an explicit verb (--serve), which means every unit written before that change // would start the binary with no arguments — and the binary now answers that with usage and a // non-zero exit. A self-update replaces the binary but never the unit, so without this a routine // update would leave a service that cannot start, discovered whenever the host next reboots. // // Only a unit this program wrote is touched, identified by its description line. Editing an // operator's hand-written unit would be overreach; leaving ours broken would be negligence. func RepairExecStart() (repaired bool, err error) { b, err := os.ReadFile(unitPath) if err != nil { return false, nil // no unit installed: nothing to repair, and not an error } text := string(b) if !strings.Contains(text, "Echolot probe server") { return false, nil // somebody else's unit } lines := strings.Split(text, "\n") changed := false for i, ln := range lines { t := strings.TrimSpace(ln) // Only the serving unit's ExecStart; the timer's own line already carries its verb. if strings.HasPrefix(t, "ExecStart=") && !strings.Contains(t, "--") { lines[i] = ln + " --serve" changed = true } } if !changed { return false, nil } if err := os.WriteFile(unitPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil { return false, fmt.Errorf("updating %s: %w", unitPath, err) } _ = exec.Command("systemctl", "daemon-reload").Run() return true, nil }