Files
nadir-agent/internal/modules/services/services.go
T
urania d4364a6cb7
build-and-release / release (push) Successful in 2m39s
feat(system): enhance system architecture
2026-06-25 14:44:47 +02:00

208 lines
7.5 KiB
Go

package services
import (
"context"
"encoding/json"
"regexp"
"strings"
"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 (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 404, 500}
)
// unitNameRe matches valid systemd unit names. Combined with a leading-dash
// reject and the "--" separator on every systemctl call, it keeps user-supplied
// unit names from being interpreted as options.
var unitNameRe = regexp.MustCompile(`^[a-zA-Z0-9@._:-]+$`)
// ServiceUnit mirrors one entry of `systemctl list-units --type=service -o json`.
type ServiceUnit struct {
Unit string `json:"unit" example:"sshd.service" doc:"Unit name"`
Load string `json:"load" example:"loaded" doc:"Load state"`
Active string `json:"active" example:"active" doc:"High-level active state"`
Sub string `json:"sub" example:"running" doc:"Low-level sub state"`
Description string `json:"description" example:"OpenSSH server daemon" doc:"Unit description"`
}
type ListServicesOutput struct {
Body struct {
Services []ServiceUnit `json:"services" doc:"All service units, active and inactive"`
}
}
// ServiceStatusBody is the detailed status of a single unit from `systemctl show`.
type ServiceStatusBody struct {
Unit string `json:"unit" example:"sshd.service"`
Description string `json:"description" example:"OpenSSH server daemon"`
LoadState string `json:"load_state" example:"loaded" doc:"loaded / not-found / masked"`
ActiveState string `json:"active_state" example:"active" doc:"active / inactive / failed"`
SubState string `json:"sub_state" example:"running"`
UnitFileState string `json:"unit_file_state" example:"enabled" doc:"enabled / disabled / static"`
}
type GetServiceOutput struct{ Body ServiceStatusBody }
// UnitPath is the shared path parameter for per-unit operations.
type UnitPath struct {
Unit string `path:"unit" example:"sshd.service" doc:"systemd unit name"`
}
func registerServices(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "services-list",
Method: "GET",
Path: "/api/services",
Summary: "List service units",
Description: "Returns all service units (active and inactive) via " +
"`systemctl list-units --type=service --all`.",
Tags: []string{tagServices},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListServicesOutput, error) {
out, err := oscmd.Run("systemctl", "list-units", "--type=service", "--all", "-o", "json", "--no-pager")
if err != nil {
return nil, huma.Error500InternalServerError("systemctl list-units failed", err)
}
var units []ServiceUnit
if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, huma.Error500InternalServerError("parse systemctl json failed", err)
}
res := &ListServicesOutput{}
res.Body.Services = units
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "services-get",
Method: "GET",
Path: "/api/services/{unit}",
Summary: "Get a service's status",
Description: "Returns load/active/sub/unit-file state for one unit via " +
"`systemctl show`. Returns 404 when the unit does not exist.",
Tags: []string{tagServices},
Metadata: op("read"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *UnitPath) (*GetServiceOutput, error) {
if err := validateUnit(in.Unit); err != nil {
return nil, err
}
m, err := showUnit(in.Unit)
if err != nil {
return nil, huma.Error500InternalServerError("systemctl show failed", err)
}
if m["LoadState"] == "not-found" {
return nil, huma.Error404NotFound("unit not found: " + in.Unit)
}
out := &GetServiceOutput{Body: ServiceStatusBody{
Unit: m["Id"],
Description: m["Description"],
LoadState: m["LoadState"],
ActiveState: m["ActiveState"],
SubState: m["SubState"],
UnitFileState: m["UnitFileState"],
}}
return out, nil
})
controls := []struct{ action, summary, desc string }{
{"start", "Start a service", "Starts the unit (`systemctl start`)."},
{"stop", "Stop a service", "Stops the unit (`systemctl stop`)."},
{"restart", "Restart a service", "Restarts the unit (`systemctl restart`)."},
{"enable", "Enable a service at boot", "Enables the unit (`systemctl enable`)."},
{"disable", "Disable a service at boot", "Disables the unit (`systemctl disable`)."},
}
for _, c := range controls {
huma.Register(api, huma.Operation{
OperationID: "services-" + c.action,
Method: "POST",
Path: "/api/services/{unit}/" + c.action,
Summary: c.summary,
Description: c.desc + " Returns 404 when the unit does not exist.",
Tags: []string{tagServices},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *UnitPath) (*oscmd.StatusOutput, error) {
if err := validateUnit(in.Unit); err != nil {
return nil, err
}
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)
}
return oscmd.OK(), nil
})
}
}
// 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) {
out, err := oscmd.RunDetached("systemctl", action, "--", unit)
if err != nil {
return nil, huma.Error500InternalServerError("could not start detached systemctl", err)
}
return out, nil
}
// validateUnit guards against empty, flag-like, or malformed unit names.
func validateUnit(unit string) error {
if unit == "" || strings.HasPrefix(unit, "-") || !unitNameRe.MatchString(unit) {
return huma.Error400BadRequest("invalid unit name: " + unit)
}
return nil
}
// showUnit returns selected properties of a unit as a key=value map. systemctl
// show exits 0 even for unknown units (LoadState=not-found), so callers must
// check LoadState to detect non-existence.
func showUnit(unit string) (map[string]string, error) {
lines, err := oscmd.RunLines("systemctl", "show",
"-p", "Id", "-p", "Description", "-p", "LoadState",
"-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState",
"--", unit)
if err != nil {
return nil, err
}
return oscmd.ParseKV(lines), nil
}
// ensureExists returns a 404 if the unit is unknown, mapping the systemctl
// show probe to an HTTP error for the control endpoints.
func ensureExists(unit string) error {
m, err := showUnit(unit)
if err != nil {
return huma.Error500InternalServerError("systemctl show failed", err)
}
if m["LoadState"] == "not-found" {
return huma.Error404NotFound("unit not found: " + unit)
}
return nil
}