fix: networking / services / and testing
build-and-release / release (push) Successful in 2m40s

This commit is contained in:
2026-06-23 17:16:01 +02:00
parent 088880f584
commit c4180bada1
5 changed files with 117 additions and 2 deletions
+32
View File
@@ -3,14 +3,22 @@ package services
import (
"context"
"encoding/json"
"os/exec"
"regexp"
"strings"
"syscall"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// selfUnit is nadir's own systemd unit name. Acting on it via the normal
// synchronous path would have systemd SIGTERM the very process serving the
// request, so the client sees a dropped connection / 500 even though the
// action succeeded. We detach those calls into a Setsid subprocess instead.
const selfUnit = "nadir"
const tagServices = "Services"
var (
@@ -136,6 +144,9 @@ func registerServices(api huma.API) {
if err := ensureExists(in.Unit); err != nil {
return nil, err
}
if isSelf(in.Unit) {
return runDetached(c.action, in.Unit)
}
if _, err := oscmd.Run("systemctl", c.action, "--", in.Unit); err != nil {
return nil, huma.Error500InternalServerError("systemctl "+c.action+" failed", err)
}
@@ -144,6 +155,27 @@ func registerServices(api huma.API) {
}
}
// isSelf reports whether unit names nadir's own service, with or without the
// .service suffix.
func isSelf(unit string) bool {
return unit == selfUnit || unit == selfUnit+".service"
}
// runDetached fires systemctl in a new session so a "systemctl restart nadir"
// (or stop) doesn't kill its own caller before the HTTP response is written.
// Returns success once the subprocess has *started* — the actual systemd
// operation may complete after the response is sent, which is the whole point.
func runDetached(action, unit string) (*oscmd.StatusOutput, error) {
cmd := exec.Command("systemctl", action, "--", unit)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return nil, huma.Error500InternalServerError("could not start detached systemctl", err)
}
// Reap in the background so the child doesn't become a zombie.
go cmd.Wait()
return oscmd.OK(), nil
}
// validateUnit guards against empty, flag-like, or malformed unit names.
func validateUnit(unit string) error {
if unit == "" || strings.HasPrefix(unit, "-") || !unitNameRe.MatchString(unit) {
@@ -19,3 +19,22 @@ func TestValidateUnit(t *testing.T) {
}
}
}
// TestIsSelf pins the dispatch that detaches stop/restart-of-self into a
// Setsid subprocess. Both "nadir" and "nadir.service" must match; anything
// else (including substrings) must not, or unrelated services would also get
// detached and bypass the synchronous error path.
func TestIsSelf(t *testing.T) {
yes := []string{"nadir", "nadir.service"}
for _, u := range yes {
if !isSelf(u) {
t.Errorf("isSelf(%q) = false, want true", u)
}
}
no := []string{"", "sshd.service", "nadir-something.service", "nadir.timer", "not-nadir.service"}
for _, u := range no {
if isSelf(u) {
t.Errorf("isSelf(%q) = true, want false", u)
}
}
}