feat: show systemd service logs in service details (#2440)

Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
spatiumstas
2026-09-28 20:57:36 +03:00
committed by GitHub
parent ad215788ed
commit 7626e5e3c8
19 changed files with 618 additions and 78 deletions

View File

@@ -151,6 +151,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
if err != nil {
slog.Debug("Systemd", "err", err)
}
if agent.systemdManager != nil {
agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled
}
agent.smartManager, err = NewSmartManager()
if err != nil {

View File

@@ -53,6 +53,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{})
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
@@ -245,6 +246,33 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdLogsHandler handles recent systemd service log requests.
type GetSystemdLogsHandler struct{}
func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(logs, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
type SyncNetworkMonitorsHandler struct{}

View File

@@ -3,12 +3,15 @@
package agent
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"maps"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
@@ -20,6 +23,50 @@ import (
)
var errNoActiveTime = errors.New("no active time")
var errSystemdLogLimitReached = errors.New("systemd log size limit reached")
const systemdLogsTail = 200
// canReadSystemJournal probes whether the agent's current credentials can read
// the system journal. A successful empty result is still readable: entries may
// be written after the agent starts.
func canReadSystemJournal() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
_, err := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--lines", "1").Output()
return err == nil
}
// systemdLogsEnabled reports whether service logs can be served to the hub.
func systemdLogsEnabled() bool {
if skip, _ := utils.GetEnv("SKIP_SYSTEMD_LOGS"); skip == "true" {
return false
}
return canReadSystemJournal()
}
// limitedBuffer bounds command output before it is sent over the agent connection.
type limitedBuffer struct {
buffer bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
return 0, errSystemdLogLimitReached
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
return remaining, errSystemdLogLimitReached
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string {
return b.buffer.String()
}
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
@@ -27,6 +74,7 @@ type systemdManager struct {
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS
patterns []string
}
@@ -68,6 +116,7 @@ func newSystemdManager() (*systemdManager, error) {
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
logsEnabled: systemdLogsEnabled(),
patterns: getServicePatterns(),
}
@@ -232,6 +281,14 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat
return service, nil
}
// serviceUnitName preserves monitored timer units and defaults bare names to services.
func serviceUnitName(name string) string {
if strings.HasSuffix(name, ".service") || strings.HasSuffix(name, ".timer") {
return name
}
return name + ".service"
}
// getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
@@ -240,10 +297,7 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
}
defer conn.Close()
unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
unitName := serviceUnitName(serviceName)
ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
@@ -278,6 +332,63 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
return details, nil
}
// monitoredUnitName resolves a service name to the unit name of a monitored
// service. Only monitored units are accepted so a request can't read other
// journal entries (journalctl --unit also accepts glob patterns).
func (sm *systemdManager) monitoredUnitName(serviceName string) (string, bool) {
sm.Lock()
defer sm.Unlock()
unitName := serviceUnitName(serviceName)
if _, ok := sm.serviceStatsMap[unitName]; ok {
return unitName, true
}
// Service names are unescaped, so match against the stored name as well.
for unitName, service := range sm.serviceStatsMap {
if service.Name == serviceName {
return unitName, true
}
}
return "", false
}
// getServiceLogs returns the newest journal entries for a monitored service.
// journalctl receives the unit name as an argument (rather than through a
// shell), so a service name can never alter the command being run.
func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) {
if !sm.logsEnabled {
return "", errors.New("systemd logs disabled")
}
unitName, ok := sm.monitoredUnitName(serviceName)
if !ok {
return "", fmt.Errorf("service %q is not monitored", serviceName)
}
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
output := limitedBuffer{limit: maxTotalLogSize}
cmd.Stdout = &output
stderr := limitedBuffer{limit: 1024}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, errSystemdLogLimitReached) {
return output.String(), nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("journalctl failed: %s", message)
}
return "", fmt.Errorf("journalctl failed: %w", err)
}
return output.String(), nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") {

View File

@@ -11,6 +11,7 @@ import (
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
logsEnabled bool
}
// newSystemdManager creates a new systemdManager.
@@ -36,3 +37,7 @@ func (sm *systemdManager) getFailedServiceCount() uint16 {
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}
func (sm *systemdManager) getServiceLogs(string) (string, error) {
return "", errors.New("systemd manager unavailable")
}

View File

@@ -3,10 +3,14 @@
package agent
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
@@ -32,6 +36,127 @@ func TestUnescapeServiceName(t *testing.T) {
}
}
func TestLimitedBuffer(t *testing.T) {
buffer := limitedBuffer{limit: 5}
n, err := buffer.Write([]byte("abcdef"))
assert.Equal(t, 5, n)
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
n, err = buffer.Write([]byte("g"))
assert.Zero(t, n)
assert.True(t, errors.Is(err, errSystemdLogLimitReached))
}
func TestLimitedBufferCapsExecOutput(t *testing.T) {
buffer := limitedBuffer{limit: 5}
cmd := exec.Command("sh", "-c", "printf 'abcdef'")
cmd.Stdout = &buffer
err := cmd.Run()
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
}
func TestServiceUnitName(t *testing.T) {
tests := map[string]string{
"nginx": "nginx.service",
"nginx.service": "nginx.service",
"backup.timer": "backup.timer",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, serviceUnitName(input))
})
}
}
func TestCanReadSystemJournal(t *testing.T) {
tests := []struct {
name string
script string
want bool
}{
{"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true},
{"empty", "#!/bin/sh\nexit 0\n", true},
{"denied", "#!/bin/sh\nexit 1\n", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.Equal(t, test.want, canReadSystemJournal())
})
}
}
func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) {
// Fake journalctl prints the unit it was asked for
dir := t.TempDir()
script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n"
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{
"nginx.service": {Name: "nginx"},
"backup.timer": {Name: "backup.timer"},
"foo\\x2dbar.service": {Name: "foo-bar"},
"getty@tty1.service": {Name: "getty@tty1"},
}}
tests := []struct {
name string
want string
}{
{"nginx", "nginx.service"},
{"nginx.service", "nginx.service"},
{"backup.timer", "backup.timer"},
{"foo-bar", "foo\\x2dbar.service"},
{"getty@tty1", "getty@tty1.service"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
logs, err := sm.getServiceLogs(test.name)
assert.NoError(t, err)
assert.Equal(t, test.want, logs)
})
}
for _, name := range []string{"sshd", "*", "*.service", "nginx*"} {
t.Run("rejects "+name, func(t *testing.T) {
logs, err := sm.getServiceLogs(name)
assert.Error(t, err)
assert.Empty(t, logs)
})
}
t.Run("disabled", func(t *testing.T) {
sm.logsEnabled = false
logs, err := sm.getServiceLogs("nginx")
assert.Error(t, err)
assert.Empty(t, logs)
})
}
func TestSystemdLogsEnabled(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.True(t, systemdLogsEnabled())
t.Setenv("SKIP_SYSTEMD_LOGS", "true")
assert.False(t, systemdLogsEnabled())
}
func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string
invalidInputs := []string{