first commit

This commit is contained in:
2026-06-22 16:06:57 +02:00
commit fe485dd86d
90 changed files with 11404 additions and 0 deletions
+237
View File
@@ -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
}
+96
View File
@@ -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)
}
}
}
+38
View File
@@ -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,
}
}
+180
View File
@@ -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)
}
}
}