Running an unfamiliar binary by name should tell you what it does, not bind a dozen ports and start answering the internet. --serve (or --daemon) now does that, and a bare invocation prints usage and exits 2 - non-zero on purpose, so a service manager sees a failure rather than concluding the server ran and finished cleanly. The hazard this creates is worth spelling out, because it bites once and silently: three places started the binary with no arguments - the systemd unit, the unit template, and the Dockerfile - and --self-update replaces the binary but never the unit. A routine update would therefore leave a service that cannot start, discovered whenever the host next rebooted. So the updater repairs it: after replacing the binary it appends --serve to an ExecStart that has no flags, but only in a unit this program wrote (identified by its description). Editing an operator's hand-written unit would be overreach; leaving ours broken would be negligence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
// 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
|
|
}
|