first commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nadir/internal/oscmd"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/sse"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogLines = 100
|
||||
maxLogLines = 10000
|
||||
)
|
||||
|
||||
// LogEntry is one log record. For the journal source it is distilled from
|
||||
// journalctl's JSON; for the file source only Message is set (the raw line,
|
||||
// which usually carries its own embedded timestamp).
|
||||
type LogEntry struct {
|
||||
Time string `json:"time" example:"2026-06-20T08:15:04Z" doc:"Record timestamp (RFC3339, UTC); empty for file lines"`
|
||||
Priority int `json:"priority" example:"6" doc:"syslog priority 0 (emerg) – 7 (debug); 6 for file lines"`
|
||||
Message string `json:"message" example:"Started OpenSSH server daemon."`
|
||||
}
|
||||
|
||||
// ErrorEvent is an SSE event carrying a stream-level error (e.g. bad unit).
|
||||
type ErrorEvent struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LogsInput struct {
|
||||
Unit string `path:"unit" example:"docker.service" doc:"Unit name as listed by GET /api/services; the trailing .service is optional"`
|
||||
Source string `query:"source" enum:"journal,file" default:"journal" doc:"Where to read logs from"`
|
||||
Path string `query:"path" example:"/var/log/nginx/error.log" doc:"Log file (file source only); must be allowlisted for this unit in config"`
|
||||
Lines int `query:"lines" default:"100" doc:"How many recent records to return (max 10000)"`
|
||||
Since string `query:"since" example:"-1h" doc:"journalctl time filter (journal source only)"`
|
||||
Priority int `query:"priority" default:"7" minimum:"0" maximum:"7" doc:"Max syslog priority to include: 0 emerg .. 7 debug (journal source only). 7 = all."`
|
||||
}
|
||||
|
||||
type LogsOutput struct {
|
||||
Body struct {
|
||||
Entries []LogEntry `json:"entries" doc:"Log records, oldest first"`
|
||||
}
|
||||
}
|
||||
|
||||
type LogStreamInput struct {
|
||||
Unit string `path:"unit" example:"docker.service" doc:"Unit name as listed by GET /api/services; the trailing .service is optional"`
|
||||
Source string `query:"source" enum:"journal,file" default:"journal" doc:"Where to stream logs from"`
|
||||
Path string `query:"path" example:"/var/log/nginx/error.log" doc:"Log file (file source only); must be allowlisted for this unit in config"`
|
||||
Since string `query:"since" example:"-1h" doc:"Backfill window (journal source only)"`
|
||||
Priority int `query:"priority" default:"7" minimum:"0" maximum:"7" doc:"Max syslog priority to include: 0 emerg .. 7 debug (journal source only). 7 = all."`
|
||||
}
|
||||
|
||||
func registerLogs(api huma.API, logFiles map[string][]string) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "services-logs",
|
||||
Method: "GET",
|
||||
Path: "/api/services/{unit}/logs",
|
||||
Summary: "Get recent log records for a service",
|
||||
Description: "Returns a snapshot of the unit's logs from the journal " +
|
||||
"(default) or an allowlisted file (source=file&path=). Use /logs/stream " +
|
||||
"to follow new records live.",
|
||||
Tags: []string{tagServices},
|
||||
Metadata: op("read"),
|
||||
// No 404: the journal is historical, so logs are returned even for units
|
||||
// that aren't currently loaded; an unknown unit just yields an empty list.
|
||||
Errors: []int{400, 401, 403, 500},
|
||||
}, func(ctx context.Context, in *LogsInput) (*LogsOutput, error) {
|
||||
if err := validateUnit(in.Unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
var err error
|
||||
if in.Source == "file" {
|
||||
path, perr := resolveLogPath(logFiles, in.Unit, in.Path)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
lines, err = oscmd.RunLines("tail", "-n", strconv.Itoa(clampLines(in.Lines)), "--", path)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("tail failed", err)
|
||||
}
|
||||
return fileOutput(lines), nil
|
||||
}
|
||||
|
||||
args := []string{"-u", journalUnit(in.Unit), "--no-pager", "-o", "json", "-p", strconv.Itoa(in.Priority), "-n", strconv.Itoa(clampLines(in.Lines))}
|
||||
if in.Since != "" {
|
||||
args = append(args, "--since", in.Since)
|
||||
}
|
||||
lines, err = oscmd.RunLines("journalctl", args...)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("journalctl failed", err)
|
||||
}
|
||||
out := &LogsOutput{}
|
||||
out.Body.Entries = []LogEntry{}
|
||||
for _, l := range lines {
|
||||
if e, ok := parseJournalLine([]byte(l)); ok {
|
||||
out.Body.Entries = append(out.Body.Entries, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
|
||||
// Streaming via huma's sse package keeps the route inside huma, so the RBAC
|
||||
// middleware still enforces op("read") - a raw mux handler would bypass it.
|
||||
sse.Register(api, huma.Operation{
|
||||
OperationID: "services-logs-stream",
|
||||
Method: "GET",
|
||||
Path: "/api/services/{unit}/logs/stream",
|
||||
Summary: "Stream a service's logs (Server-Sent Events)",
|
||||
Description: "Follows the unit's journal (journalctl -f) or an allowlisted " +
|
||||
"file (source=file&path=, via tail -F) and emits a `log` event per " +
|
||||
"record. Stops when the client disconnects.",
|
||||
Tags: []string{tagServices},
|
||||
Metadata: op("read"),
|
||||
}, map[string]any{
|
||||
"log": LogEntry{},
|
||||
"error": ErrorEvent{},
|
||||
}, func(ctx context.Context, in *LogStreamInput, send sse.Sender) {
|
||||
if err := validateUnit(in.Unit); err != nil {
|
||||
send.Data(ErrorEvent{Message: "invalid unit name"})
|
||||
return
|
||||
}
|
||||
|
||||
var cmd string
|
||||
var args []string
|
||||
if in.Source == "file" {
|
||||
path, perr := resolveLogPath(logFiles, in.Unit, in.Path)
|
||||
if perr != nil {
|
||||
send.Data(ErrorEvent{Message: perr.Error()})
|
||||
return
|
||||
}
|
||||
cmd, args = "tail", []string{"-n", strconv.Itoa(defaultLogLines), "-F", "--", path}
|
||||
} else {
|
||||
cmd = "journalctl"
|
||||
args = []string{"-u", journalUnit(in.Unit), "--no-pager", "-o", "json", "-p", strconv.Itoa(in.Priority), "-f"}
|
||||
if in.Since != "" {
|
||||
args = append(args, "--since", in.Since)
|
||||
}
|
||||
}
|
||||
|
||||
lines, err := oscmd.RunStream(ctx, cmd, args...)
|
||||
if err != nil {
|
||||
send.Data(ErrorEvent{Message: cmd + " failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
for l := range lines {
|
||||
e, ok := LogEntry{Priority: 6, Message: l}, true
|
||||
if in.Source != "file" {
|
||||
e, ok = parseJournalLine([]byte(l))
|
||||
}
|
||||
if ok {
|
||||
if send.Data(e) != nil {
|
||||
return // client gone; ctx cancel will kill the command
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveLogPath validates that path is allowlisted for unit. The caller never
|
||||
// gets to point exec at an arbitrary file - only paths an admin listed under
|
||||
// log_files for this unit are accepted.
|
||||
func resolveLogPath(logFiles map[string][]string, unit, path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", huma.Error400BadRequest("source=file requires a path")
|
||||
}
|
||||
// Match the allowlist key suffix-insensitively (nginx == nginx.service), so
|
||||
// it behaves like the journal source regardless of which form the caller and
|
||||
// the config author each used.
|
||||
want := journalUnit(unit)
|
||||
for key, paths := range logFiles {
|
||||
if journalUnit(key) == want && slices.Contains(paths, path) {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", huma.Error403Forbidden("log file not allowlisted for unit " + unit + ": " + path)
|
||||
}
|
||||
|
||||
func fileOutput(lines []string) *LogsOutput {
|
||||
out := &LogsOutput{}
|
||||
out.Body.Entries = make([]LogEntry, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
out.Body.Entries = append(out.Body.Entries, LogEntry{Priority: 6, Message: l})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// journalUnit normalizes a unit name for `journalctl -u`. journalctl treats a
|
||||
// bare name as a .service, and on some setups only the bare form matches the
|
||||
// recorded _SYSTEMD_UNIT, so we always strip the suffix. This is the services
|
||||
// module, so .service is the only suffix we expect.
|
||||
func journalUnit(unit string) string {
|
||||
return strings.TrimSuffix(unit, ".service")
|
||||
}
|
||||
|
||||
func clampLines(n int) int {
|
||||
switch {
|
||||
case n <= 0:
|
||||
return defaultLogLines
|
||||
case n > maxLogLines:
|
||||
return maxLogLines
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// parseJournalLine distills one journalctl `-o json` record. Returns false for
|
||||
// unparseable lines. Binary MESSAGE fields (encoded as a byte array rather than
|
||||
// a string) yield an empty message rather than an error.
|
||||
func parseJournalLine(line []byte) (LogEntry, bool) {
|
||||
var raw struct {
|
||||
Message any `json:"MESSAGE"`
|
||||
Priority string `json:"PRIORITY"`
|
||||
TS string `json:"__REALTIME_TIMESTAMP"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
return LogEntry{}, false
|
||||
}
|
||||
// Records without a PRIORITY (it's often absent) default to info (6), not
|
||||
// the zero value 0 which is emerg - that would fake critical alerts.
|
||||
e := LogEntry{Priority: 6}
|
||||
e.Message, _ = raw.Message.(string)
|
||||
if p, err := strconv.Atoi(raw.Priority); err == nil {
|
||||
e.Priority = p
|
||||
}
|
||||
if us, err := strconv.ParseInt(raw.TS, 10, 64); err == nil {
|
||||
e.Time = time.UnixMicro(us).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseJournalLine(t *testing.T) {
|
||||
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","PRIORITY":"6","MESSAGE":"Started OpenSSH server daemon.","_PID":"123"}`)
|
||||
e, ok := parseJournalLine(line)
|
||||
if !ok {
|
||||
t.Fatal("expected parse to succeed")
|
||||
}
|
||||
if e.Message != "Started OpenSSH server daemon." {
|
||||
t.Errorf("message = %q", e.Message)
|
||||
}
|
||||
if e.Priority != 6 {
|
||||
t.Errorf("priority = %d", e.Priority)
|
||||
}
|
||||
if e.Time != "2025-06-20T07:55:04Z" {
|
||||
t.Errorf("time = %q", e.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJournalLineBinaryMessage(t *testing.T) {
|
||||
// Binary MESSAGE is encoded as a byte array; we yield an empty message, not an error.
|
||||
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","PRIORITY":"3","MESSAGE":[104,105]}`)
|
||||
e, ok := parseJournalLine(line)
|
||||
if !ok || e.Message != "" || e.Priority != 3 {
|
||||
t.Errorf("got ok=%v entry=%+v", ok, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJournalLineMissingPriority(t *testing.T) {
|
||||
// PRIORITY is often absent; it must default to info (6), not emerg (0).
|
||||
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","MESSAGE":"hi"}`)
|
||||
e, ok := parseJournalLine(line)
|
||||
if !ok || e.Priority != 6 {
|
||||
t.Errorf("got ok=%v priority=%d, want priority 6", ok, e.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJournalLineGarbage(t *testing.T) {
|
||||
if _, ok := parseJournalLine([]byte("not json")); ok {
|
||||
t.Error("garbage line should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLogPath(t *testing.T) {
|
||||
allow := map[string][]string{
|
||||
"nginx.service": {"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
|
||||
}
|
||||
|
||||
// Allowlisted path resolves whether the caller uses the bare or .service
|
||||
// form, regardless of which form the config key used.
|
||||
for _, unit := range []string{"nginx.service", "nginx"} {
|
||||
if p, err := resolveLogPath(allow, unit, "/var/log/nginx/error.log"); err != nil || p != "/var/log/nginx/error.log" {
|
||||
t.Errorf("allowlisted path for %q: got %q, %v", unit, p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is rejected: empty path, non-listed path (traversal),
|
||||
// listed path but wrong unit, and unit with no allowlist at all.
|
||||
bad := []struct{ unit, path string }{
|
||||
{"nginx.service", ""},
|
||||
{"nginx.service", "/etc/shadow"},
|
||||
{"nginx.service", "/var/log/nginx/access.log/../../../etc/shadow"},
|
||||
{"sshd.service", "/var/log/nginx/error.log"},
|
||||
{"unknown.service", "/var/log/nginx/error.log"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if _, err := resolveLogPath(allow, b.unit, b.path); err == nil {
|
||||
t.Errorf("resolveLogPath(%q, %q) = nil error, want rejection", b.unit, b.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalUnit(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"docker.service": "docker",
|
||||
"docker": "docker",
|
||||
"sshd.service": "sshd",
|
||||
"foo.socket": "foo.socket", // only .service is stripped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := journalUnit(in); got != want {
|
||||
t.Errorf("journalUnit(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLines(t *testing.T) {
|
||||
cases := map[int]int{0: defaultLogLines, -5: defaultLogLines, 50: 50, 999999: maxLogLines}
|
||||
for in, want := range cases {
|
||||
if got := clampLines(in); got != want {
|
||||
t.Errorf("clampLines(%d) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"nadir/internal/rbac"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
const ModuleID = "services"
|
||||
|
||||
type Module struct {
|
||||
// logFiles is the per-unit allowlist of readable log files (from config),
|
||||
// consulted by the file log source.
|
||||
logFiles map[string][]string
|
||||
}
|
||||
|
||||
func New(logFiles map[string][]string) *Module { return &Module{logFiles: logFiles} }
|
||||
|
||||
func (m *Module) ID() string { return ModuleID }
|
||||
func (m *Module) Name() string { return "Services" }
|
||||
|
||||
// Permissions: read to list and inspect units; write to control them
|
||||
// (start/stop/restart/enable/disable).
|
||||
func (m *Module) Permissions() []rbac.Permission {
|
||||
return []rbac.Permission{rbac.Read, rbac.Write}
|
||||
}
|
||||
|
||||
func (m *Module) Register(api huma.API) {
|
||||
registerServices(api)
|
||||
registerLogs(api, m.logFiles)
|
||||
}
|
||||
|
||||
func op(permission string) map[string]any {
|
||||
return map[string]any{
|
||||
"module": ModuleID,
|
||||
"permission": permission,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"nadir/internal/oscmd"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
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 _, err := oscmd.Run("systemctl", c.action, "--", in.Unit); err != nil {
|
||||
return nil, huma.Error500InternalServerError("systemctl "+c.action+" failed", err)
|
||||
}
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nadir/internal/oscmd"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humago"
|
||||
"github.com/danielgtaylor/huma/v2/humatest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if oscmd.RunHelperProcess() {
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestServicesHandlers(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
|
||||
|
||||
// Set up allowlisted log files for testing the file source
|
||||
logFiles := map[string][]string{
|
||||
"nginx.service": {filepath.Join(t.TempDir(), "nginx-error.log")},
|
||||
}
|
||||
// Create the dummy log file
|
||||
errLogPath := logFiles["nginx.service"][0]
|
||||
if err := os.WriteFile(errLogPath, []byte("file log line 1\nfile log line 2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := New(logFiles)
|
||||
m.Register(api)
|
||||
|
||||
// 1. Test GET /api/services (list services)
|
||||
oscmd.SetMock("systemctl", func(args []string) oscmd.MockCommand {
|
||||
if reflect.DeepEqual(args, []string{"list-units", "--type=service", "--all", "-o", "json", "--no-pager"}) {
|
||||
units := []ServiceUnit{
|
||||
{Unit: "sshd.service", Load: "loaded", Active: "active", Sub: "running", Description: "OpenSSH"},
|
||||
}
|
||||
data, _ := json.Marshal(units)
|
||||
return oscmd.MockCommand{Stdout: string(data) + "\n", ExitCode: 0}
|
||||
}
|
||||
if reflect.DeepEqual(args, []string{"show", "-p", "Id", "-p", "Description", "-p", "LoadState", "-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState", "--", "sshd.service"}) {
|
||||
showOut := "Id=sshd.service\nDescription=OpenSSH\nLoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\n"
|
||||
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
|
||||
}
|
||||
if reflect.DeepEqual(args, []string{"start", "--", "sshd.service"}) {
|
||||
return oscmd.MockCommand{ExitCode: 0}
|
||||
}
|
||||
return oscmd.MockCommand{ExitCode: 1}
|
||||
})
|
||||
defer oscmd.ClearMocks()
|
||||
|
||||
resp := api.Get("/api/services")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("list services: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
var listRes ListServicesOutput
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listRes.Body.Services) != 1 || listRes.Body.Services[0].Unit != "sshd.service" {
|
||||
t.Errorf("list services output: %+v", listRes.Body)
|
||||
}
|
||||
|
||||
// 2. Test GET /api/services/{unit} (get service status)
|
||||
resp = api.Get("/api/services/sshd.service")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("get service status: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
var getRes GetServiceOutput
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &getRes.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if getRes.Body.Unit != "sshd.service" || getRes.Body.ActiveState != "active" {
|
||||
t.Errorf("get service output: %+v", getRes.Body)
|
||||
}
|
||||
|
||||
// 3. Test POST /api/services/{unit}/start
|
||||
resp = api.Post("/api/services/sshd.service/start", struct{}{})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("start service: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// 4. Test GET /api/services/{unit}/logs (journal source)
|
||||
oscmd.SetMock("journalctl", func(args []string) oscmd.MockCommand {
|
||||
if strings.Contains(strings.Join(args, " "), "-f") {
|
||||
// Streaming mock
|
||||
lines := []string{
|
||||
`{"MESSAGE":"streaming line 1","PRIORITY":"6","__REALTIME_TIMESTAMP":"1718873704000000"}`,
|
||||
}
|
||||
return oscmd.MockCommand{Lines: lines, DelayMs: 1, ExitCode: 0}
|
||||
}
|
||||
// Regular snapshot mock
|
||||
lines := []string{
|
||||
`{"MESSAGE":"journal line 1","PRIORITY":"6","__REALTIME_TIMESTAMP":"1718873704000000"}`,
|
||||
}
|
||||
return oscmd.MockCommand{Stdout: strings.Join(lines, "\n") + "\n", ExitCode: 0}
|
||||
})
|
||||
|
||||
resp = api.Get("/api/services/sshd.service/logs")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("get journal logs: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
var logsRes LogsOutput
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &logsRes.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logsRes.Body.Entries) != 1 || logsRes.Body.Entries[0].Message != "journal line 1" {
|
||||
t.Errorf("journal logs output: %+v", logsRes.Body)
|
||||
}
|
||||
|
||||
// 5. Test GET /api/services/{unit}/logs (file source)
|
||||
oscmd.SetMock("tail", func(args []string) oscmd.MockCommand {
|
||||
if strings.Contains(strings.Join(args, " "), "-F") {
|
||||
// Streaming mock
|
||||
return oscmd.MockCommand{Lines: []string{"stream file line 1"}, DelayMs: 1, ExitCode: 0}
|
||||
}
|
||||
return oscmd.MockCommand{Stdout: "file log line 1\nfile log line 2\n", ExitCode: 0}
|
||||
})
|
||||
|
||||
resp = api.Get("/api/services/nginx.service/logs?source=file&path=" + errLogPath)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("get file logs: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &logsRes.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logsRes.Body.Entries) != 2 || logsRes.Body.Entries[0].Message != "file log line 1" {
|
||||
t.Errorf("file logs output: %+v", logsRes.Body)
|
||||
}
|
||||
|
||||
// 6. Test GET /api/services/{unit}/logs/stream (journal stream)
|
||||
resp = api.Get("/api/services/sshd.service/logs/stream")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("stream journal logs: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
bodyStr := resp.Body.String()
|
||||
if !strings.Contains(bodyStr, "streaming line 1") {
|
||||
t.Errorf("stream journal logs missing message, got: %q", bodyStr)
|
||||
}
|
||||
|
||||
// 7. Test GET /api/services/{unit}/logs/stream (file stream)
|
||||
resp = api.Get("/api/services/nginx.service/logs/stream?source=file&path=" + errLogPath)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("stream file logs: got %d, want %d", resp.Code, http.StatusOK)
|
||||
}
|
||||
bodyStr = resp.Body.String()
|
||||
if !strings.Contains(bodyStr, "stream file line 1") {
|
||||
t.Errorf("stream file logs missing message, got: %q", bodyStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateUnit(t *testing.T) {
|
||||
valid := []string{"sshd.service", "getty@tty1.service", "foo.bar:baz-1.service", "a_b.timer"}
|
||||
for _, u := range valid {
|
||||
if err := validateUnit(u); err != nil {
|
||||
t.Errorf("validateUnit(%q) = %v, want nil", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty, flag-injection, and anything with shell/path metacharacters must
|
||||
// be rejected before reaching systemctl.
|
||||
invalid := []string{"", "-rf", "--now", "a b", "foo;rm -rf /", "a/b", "naughty$()", "x|y"}
|
||||
for _, u := range invalid {
|
||||
if err := validateUnit(u); err == nil {
|
||||
t.Errorf("validateUnit(%q) = nil, want error", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user