server: multi-address listeners, env-file config, self-update timer + checksums
- Comma-separated ECHOLOT_{CONTROL,UDP,TCP}_LISTEN; one listener/socket per
address. Explicit binds matter on multi-IP hosts (a wildcard would also
claim the SSH-only management address) and per-address UDP sockets are
the substrate stun-5780 needs.
- systemd unit reads /etc/echolot-server.env (seeded once, never
overwritten); --install-systemd with --self-update-api also installs a
daily randomized update timer that try-restarts the service.
- selfupdate: SHA256SUMS verification is now mandatory before the atomic
replace (integrity, not authenticity — signing still TODO).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4de3064f71
commit
43e1ba778a
+16
-1
@@ -65,7 +65,22 @@ sudo /usr/local/bin/echolot-server --uninstall-systemd
|
|||||||
```
|
```
|
||||||
|
|
||||||
Config precedence: flags > `ECHOLOT_*` env > defaults. Every flag has an env twin
|
Config precedence: flags > `ECHOLOT_*` env > defaults. Every flag has an env twin
|
||||||
(`--udp-listen` ↔ `ECHOLOT_UDP_LISTEN`).
|
(`--udp-listen` ↔ `ECHOLOT_UDP_LISTEN`). Host config lives in `/etc/echolot-server.env`
|
||||||
|
(seeded by `--install-systemd`, never overwritten).
|
||||||
|
|
||||||
|
**Multi-IP hosts:** listen specs are comma-separated, and you should bind explicit addresses —
|
||||||
|
a wildcard bind would also claim management-only IPs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ECHOLOT_CONTROL_LISTEN=203.0.113.10:8443,[2001:db8::10]:8443
|
||||||
|
ECHOLOT_UDP_LISTEN=203.0.113.10:8442,203.0.113.11:8442,[2001:db8::10]:8442,[2001:db8::11]:8442
|
||||||
|
```
|
||||||
|
|
||||||
|
Passing `--self-update-api` to `--install-systemd` additionally installs a daily randomized
|
||||||
|
self-update timer (`echolot-server-update.timer`) that restarts the service after a successful
|
||||||
|
update. Updates are checksum-verified against the release's `SHA256SUMS` (integrity, not
|
||||||
|
authenticity — signature verification remains TODO before treating the update source as
|
||||||
|
untrusted).
|
||||||
|
|
||||||
### Self-update (opt-in, native only)
|
### Self-update (opt-in, native only)
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ func run() error {
|
|||||||
fmt.Println(Version)
|
fmt.Println(Version)
|
||||||
return nil
|
return nil
|
||||||
case actions.InstallSystemd:
|
case actions.InstallSystemd:
|
||||||
// The unit runs this same binary in serve mode with env-based config.
|
// The unit runs this same binary in serve mode; host config comes
|
||||||
return system.InstallSystemd(nil)
|
// from /etc/echolot-server.env. A self-update timer is installed
|
||||||
|
// only when an update API is configured.
|
||||||
|
return system.InstallSystemd(cfg.SelfUpdateAPI)
|
||||||
case actions.UninstallSystemd:
|
case actions.UninstallSystemd:
|
||||||
return system.UninstallSystemd()
|
return system.UninstallSystemd()
|
||||||
case actions.SelfUpdate:
|
case actions.SelfUpdate:
|
||||||
@@ -95,20 +97,31 @@ func serve(cfg *config.Config) error {
|
|||||||
sessions := session.NewManager(15 * time.Minute)
|
sessions := session.NewManager(15 * time.Minute)
|
||||||
ctl := &control.Server{
|
ctl := &control.Server{
|
||||||
Store: st, Sessions: sessions, Name: cfg.Name,
|
Store: st, Sessions: sessions, Name: cfg.Name,
|
||||||
UDPPort: mustPort(cfg.UDPListen), TCPPort: mustPort(cfg.TCPListen), PinB64: pin,
|
UDPPort: mustPort(firstAddr(cfg.UDPListen)), TCPPort: mustPort(firstAddr(cfg.TCPListen)), PinB64: pin,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
errCh := make(chan error, 4)
|
ctlAddrs := config.Addrs(cfg.ControlListen)
|
||||||
|
udpAddrs := config.Addrs(cfg.UDPListen)
|
||||||
|
errCh := make(chan error, len(ctlAddrs)+len(udpAddrs)+2)
|
||||||
|
|
||||||
// Control plane (HTTPS, pin-based trust)
|
// Control plane (HTTPS, pin-based trust) — one shared server, one
|
||||||
|
// listener per configured address; Shutdown closes them all.
|
||||||
ctlSrv := &http.Server{
|
ctlSrv := &http.Server{
|
||||||
Addr: cfg.ControlListen, Handler: ctl.Handler(),
|
Handler: ctl.Handler(),
|
||||||
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
go func() { errCh <- fmt.Errorf("control: %w", ctlSrv.ListenAndServeTLS("", "")) }()
|
for _, addr := range ctlAddrs {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("control listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
go func(a string, l net.Listener) {
|
||||||
|
errCh <- fmt.Errorf("control %s: %w", a, ctlSrv.ServeTLS(l, "", ""))
|
||||||
|
}(addr, ln)
|
||||||
|
}
|
||||||
|
|
||||||
// Admin/health (plain HTTP, localhost by default; spec §7)
|
// Admin/health (plain HTTP, localhost by default; spec §7)
|
||||||
admin := http.NewServeMux()
|
admin := http.NewServeMux()
|
||||||
@@ -129,20 +142,28 @@ func serve(cfg *config.Config) error {
|
|||||||
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
adminSrv := &http.Server{Addr: cfg.AdminListen, Handler: admin, ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
go func() { errCh <- fmt.Errorf("admin: %w", adminSrv.ListenAndServe()) }()
|
||||||
|
|
||||||
// UDP data plane
|
// UDP data plane — one socket per configured address. Distinct sockets
|
||||||
udpAddr, err := net.ResolveUDPAddr("udp", cfg.UDPListen)
|
// (not wildcard) also guarantee responses leave from the address the
|
||||||
|
// request arrived on, which stun-5780 will rely on.
|
||||||
|
var udpConns []*net.UDPConn
|
||||||
|
for _, addr := range udpAddrs {
|
||||||
|
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("udp addr %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
udpConn, err := net.ListenUDP("udp", udpAddr)
|
conn, err := net.ListenUDP("udp", udpAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("udp listen: %w", err)
|
return fmt.Errorf("udp listen %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
udpConns = append(udpConns, conn)
|
||||||
dp := &dataplane.Server{Sessions: sessions}
|
dp := &dataplane.Server{Sessions: sessions}
|
||||||
go func() { errCh <- fmt.Errorf("udp: %w", dp.Serve(udpConn)) }()
|
go func(a string, c *net.UDPConn) {
|
||||||
|
errCh <- fmt.Errorf("udp %s: %w", a, dp.Serve(c))
|
||||||
|
}(addr, conn)
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("listening",
|
slog.Info("listening",
|
||||||
"control", cfg.ControlListen, "admin", cfg.AdminListen, "udp", cfg.UDPListen)
|
"control", ctlAddrs, "admin", cfg.AdminListen, "udp", udpAddrs)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -151,13 +172,22 @@ func serve(cfg *config.Config) error {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
_ = ctlSrv.Shutdown(shutCtx)
|
_ = ctlSrv.Shutdown(shutCtx)
|
||||||
_ = adminSrv.Shutdown(shutCtx)
|
_ = adminSrv.Shutdown(shutCtx)
|
||||||
_ = udpConn.Close()
|
for _, c := range udpConns {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstAddr(spec string) string {
|
||||||
|
if a := config.Addrs(spec); len(a) > 0 {
|
||||||
|
return a[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func mustPort(listen string) int {
|
func mustPort(listen string) int {
|
||||||
_, p, err := net.SplitHostPort(listen)
|
_, p, err := net.SplitHostPort(listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
c := &Config{}
|
c := &Config{}
|
||||||
a := &Actions{}
|
a := &Actions{}
|
||||||
|
|
||||||
fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address")
|
fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address(es), comma-separated")
|
||||||
fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)")
|
fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)")
|
||||||
fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)")
|
fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)")
|
||||||
fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address")
|
fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address(es), comma-separated")
|
||||||
fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address")
|
fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address(es), comma-separated")
|
||||||
fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)")
|
fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)")
|
||||||
fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)")
|
fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)")
|
||||||
fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")
|
fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")
|
||||||
@@ -88,6 +88,20 @@ func Load(args []string) (*Config, *Actions, error) {
|
|||||||
return c, a, nil
|
return c, a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Addrs splits a comma-separated listen spec into individual addresses.
|
||||||
|
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
||||||
|
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
||||||
|
// SSH-only management IP).
|
||||||
|
func Addrs(spec string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, a := range strings.Split(spec, ",") {
|
||||||
|
if a = strings.TrimSpace(a); a != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Actions are one-shot verbs that exit instead of serving.
|
// Actions are one-shot verbs that exit instead of serving.
|
||||||
type Actions struct {
|
type Actions struct {
|
||||||
InstallSystemd bool
|
InstallSystemd bool
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
package selfupdate
|
package selfupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -69,6 +71,35 @@ func Run(api, currentVersion string) error {
|
|||||||
return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
|
return fmt.Errorf("release %s has no asset %q", rel.TagName, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The release must carry SHA256SUMS; refuse to update without it. This
|
||||||
|
// protects download integrity (truncation, proxy mangling). It is NOT a
|
||||||
|
// defense against a compromised Gitea — both files come from the same
|
||||||
|
// place; a detached signature would be needed for that (still TODO).
|
||||||
|
var sums string
|
||||||
|
for _, a := range rel.Assets {
|
||||||
|
if a.Name == "SHA256SUMS" {
|
||||||
|
resp, err := client.Get(a.URL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetching SHA256SUMS: %w", err)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sums = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantSum := ""
|
||||||
|
for _, line := range strings.Split(sums, "\n") {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 && fields[1] == want {
|
||||||
|
wantSum = fields[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wantSum == "" {
|
||||||
|
return fmt.Errorf("release %s has no SHA256SUMS entry for %q — refusing to update", rel.TagName, want)
|
||||||
|
}
|
||||||
|
|
||||||
self, err := os.Executable()
|
self, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -85,15 +116,18 @@ func Run(api, currentVersion string) error {
|
|||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = io.Copy(f, dl.Body)
|
h := sha256.New()
|
||||||
|
_, err = io.Copy(io.MultiWriter(f, h), dl.Body)
|
||||||
dl.Body.Close()
|
dl.Body.Close()
|
||||||
f.Close()
|
f.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// TODO(security): verify a detached signature/checksum asset before the
|
if got := hex.EncodeToString(h.Sum(nil)); got != wantSum {
|
||||||
// rename — a Gitea compromise currently equals code execution here.
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("checksum mismatch for %s: got %s want %s", want, got, wantSum)
|
||||||
|
}
|
||||||
if err := os.Rename(tmp, self); err != nil {
|
if err := os.Rename(tmp, self); err != nil {
|
||||||
os.Remove(tmp)
|
os.Remove(tmp)
|
||||||
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
|
return fmt.Errorf("atomic replace failed (filesystem boundaries?): %w", err)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
// Package system implements native-host lifecycle: systemd unit install /
|
// Package system implements native-host lifecycle: systemd unit install /
|
||||||
// uninstall. Linux-only by nature; on other OSes the commands fail with a
|
// uninstall, plus an optional self-update timer. Linux-only by nature; on
|
||||||
// clear message rather than pretending.
|
// other OSes the commands fail with a clear message rather than pretending.
|
||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,7 +14,12 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
const unitPath = "/etc/systemd/system/echolot-server.service"
|
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]
|
const unitTemplate = `[Unit]
|
||||||
Description=Echolot probe server
|
Description=Echolot probe server
|
||||||
@@ -29,6 +34,8 @@ Restart=on-failure
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
StateDirectory=echolot-server
|
StateDirectory=echolot-server
|
||||||
Environment=ECHOLOT_STATE_DIR=/var/lib/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.
|
# Hardening — the server needs sockets and its state dir, nothing else.
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
@@ -40,9 +47,43 @@ PrivateTmp=true
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`
|
`
|
||||||
|
|
||||||
// InstallSystemd writes the unit for THIS binary (absolute path), reloads
|
const updateUnitTemplate = `[Unit]
|
||||||
// systemd, and enables the service. Idempotent.
|
Description=Echolot server self-update
|
||||||
func InstallSystemd(extraArgs []string) error {
|
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" {
|
if runtime.GOOS != "linux" {
|
||||||
return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS)
|
return fmt.Errorf("--install-systemd is Linux-only (this is %s)", runtime.GOOS)
|
||||||
}
|
}
|
||||||
@@ -54,22 +95,36 @@ func InstallSystemd(extraArgs []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
execStart := self
|
if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, self, envFilePath)), 0o644); err != nil {
|
||||||
for _, a := range extraArgs {
|
|
||||||
execStart += " " + a
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(unitPath, []byte(fmt.Sprintf(unitTemplate, execStart)), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("writing %s (need root?): %w", unitPath, err)
|
return fmt.Errorf("writing %s (need root?): %w", unitPath, err)
|
||||||
}
|
}
|
||||||
for _, cmd := range [][]string{
|
// 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", "daemon-reload"},
|
||||||
{"systemctl", "enable", "--now", "echolot-server.service"},
|
{"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 {
|
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("%v: %s: %w", cmd, out, err)
|
return fmt.Errorf("%v: %s: %w", cmd, out, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("installed + started echolot-server.service (ExecStart=%s)\n", execStart)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +133,14 @@ func UninstallSystemd() error {
|
|||||||
return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS)
|
return fmt.Errorf("--uninstall-systemd is Linux-only (this is %s)", runtime.GOOS)
|
||||||
}
|
}
|
||||||
// Stop/disable first; ignore "not loaded" errors so uninstall is idempotent.
|
// 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()
|
_ = exec.Command("systemctl", "disable", "--now", "echolot-server.service").Run()
|
||||||
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
for _, p := range []string{unitPath, updateUnitPath, updateTimerPath} {
|
||||||
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ = exec.Command("systemctl", "daemon-reload").Run()
|
_ = exec.Command("systemctl", "daemon-reload").Run()
|
||||||
fmt.Println("removed echolot-server.service (state dir left in place)")
|
fmt.Println("removed echolot-server units (state dir and env file left in place)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user