208 lines
7.5 KiB
Go
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
|
|
}
|