mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-29 04:47:46 +02:00
feat: show systemd service logs in service details (#2440)
Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
119
agent/systemd.go
119
agent/systemd.go
@@ -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") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user