diff --git a/VERSION b/VERSION index 9972f12..7eeb2c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.61 +1.0.62 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 4865c7d..168d369 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -37,6 +37,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" @@ -49,7 +50,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.61" +var version = "1.0.62" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -190,6 +191,9 @@ func main() { fwLogTailer := firewalllog.NewTailer(firewalllog.DefaultLogPath, 1000) handlers.StartFirewallLogTailer(context.Background(), fwLogTailer) handlers.NewFirewallLogHandler(fwLogTailer, firewalllog.DefaultLogPath).Register(authed) + + // /logs (Phase 4): aggregierter Reader für journalctl + audit_log + handlers.NewLogsHandler(syslogs.New(auditRepo)).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) // Firewall reload: nach jeder Mutation den Renderer neu fahren // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index cc3d513..c044320 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.61" +var version = "1.0.62" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 4fdded2..9e40c55 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -24,7 +24,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.61" +var version = "1.0.62" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/logs.go b/internal/handlers/logs.go new file mode 100644 index 0000000..2d66d41 --- /dev/null +++ b/internal/handlers/logs.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs" +) + +// LogsHandler exposes: +// +// GET /api/v1/logs?sources=edgeguard-api,haproxy,... +// &levels=error,warn +// &since=2026-05-12T20:00:00Z +// &until=2026-05-12T21:00:00Z +// &grep=503 +// &limit=500 +// +// Aggregiert journalctl-Output für die EdgeGuard-Services + die +// audit_log-Tabelle in ein einheitliches Entry-Format. +type LogsHandler struct { + Reader *syslogs.Reader +} + +func NewLogsHandler(r *syslogs.Reader) *LogsHandler { + return &LogsHandler{Reader: r} +} + +func (h *LogsHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/logs") + g.GET("", h.List) + g.GET("/sources", h.Sources) +} + +// Sources gibt die statische Quellen-Liste fürs UI-Dropdown zurück — +// das UI muss die nicht hartcodieren. +func (h *LogsHandler) Sources(c *gin.Context) { + out := make([]string, 0, len(syslogs.AllSources)) + for _, s := range syslogs.AllSources { + out = append(out, string(s)) + } + response.OK(c, gin.H{"sources": out}) +} + +// List baut den Filter aus den Query-Params und führt Reader.Query aus. +// Errors aus einzelnen Sources sind nicht-fatal (Reader.Query logged +// sie selbst); wir liefern was vorhanden ist. +func (h *LogsHandler) List(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + f := syslogs.Filter{ + Grep: c.Query("grep"), + } + if s := c.Query("sources"); s != "" { + for _, raw := range strings.Split(s, ",") { + s := strings.TrimSpace(raw) + if s != "" { + f.Sources = append(f.Sources, syslogs.Source(s)) + } + } + } + if s := c.Query("levels"); s != "" { + for _, raw := range strings.Split(s, ",") { + s := strings.TrimSpace(raw) + if s != "" { + f.Levels = append(f.Levels, syslogs.Level(s)) + } + } + } + if s := c.Query("since"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + f.Since = t + } + } + if s := c.Query("until"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + f.Until = t + } + } + if s := c.Query("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + f.Limit = n + } + } + entries, _ := h.Reader.Query(ctx, f) + response.OK(c, gin.H{"entries": entries, "count": len(entries)}) +} diff --git a/internal/services/syslogs/syslogs.go b/internal/services/syslogs/syslogs.go new file mode 100644 index 0000000..0ab46f5 --- /dev/null +++ b/internal/services/syslogs/syslogs.go @@ -0,0 +1,347 @@ +// Package syslogs aggregiert Log-Entries aus systemd-journal (per +// `journalctl --output=json`) und der audit_log-Tabelle in ein +// einheitliches Entry-Format. Phase 4 des Log-Systems. +// +// Quellen sind statisch konfiguriert — alles was zur EdgeGuard-Box +// gehört. ulogd2 ist NICHT enthalten, weil dessen Daten als +// strukturiertes File von /firewall-live gehandhabt werden. +// +// journalctl-Aufruf läuft als edgeguard-User; das funktioniert nur +// wenn der in der Gruppe `systemd-journal` (oder `adm`) ist — der +// postinst legt das an. +package syslogs + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" +) + +// Source bezeichnet eine logische Log-Quelle, die das UI als Filter +// auswählt. "audit" ist die DB-Tabelle, alle anderen sind systemd- +// Units. +type Source string + +const ( + SourceAPI Source = "edgeguard-api" + SourceScheduler Source = "edgeguard-scheduler" + SourceHAProxy Source = "haproxy" + SourceSquid Source = "squid" + SourceUnbound Source = "unbound" + SourceChrony Source = "chrony" + SourceWireguard Source = "wg-quick" // bezieht alle wg-quick@*.service + SourceUlogd Source = "ulogd2" + SourceAudit Source = "audit" +) + +// AllSources ist die Reihenfolge fürs UI-Dropdown. +var AllSources = []Source{ + SourceAPI, SourceScheduler, + SourceHAProxy, SourceSquid, SourceUnbound, SourceChrony, SourceWireguard, SourceUlogd, + SourceAudit, +} + +// Level mappt syslog-PRIORITY-Werte auf eine UI-freundliche +// Kategorie. journalctl liefert Strings; wir normalisieren auf +// debug/info/warn/error. +type Level string + +const ( + LevelDebug Level = "debug" + LevelInfo Level = "info" + LevelWarn Level = "warn" + LevelError Level = "error" +) + +// Entry ist die unified Sicht — sortiert by Timestamp DESC im +// Endergebnis. +type Entry struct { + Timestamp time.Time `json:"timestamp"` + Source Source `json:"source"` + Level Level `json:"level"` + Message string `json:"message"` + Host string `json:"host,omitempty"` + // Audit-spezifisch — optional gesetzt für SourceAudit. UI rendert + // das in einer eigenen Tooltip. + Actor string `json:"actor,omitempty"` + Action string `json:"action,omitempty"` + Subject string `json:"subject,omitempty"` +} + +// Filter ist der Query-Container für GET /logs. +type Filter struct { + Sources []Source + Levels []Level + Since time.Time + Until time.Time + Grep string // case-insensitive substring auf Message + Actor/Action/Subject + Limit int // pro-Source-Limit, default 200, max 1000 +} + +// Reader bündelt alle Quellen-Zugriffe. +type Reader struct { + Audit *audit.Repo +} + +func New(auditRepo *audit.Repo) *Reader { + return &Reader{Audit: auditRepo} +} + +// Query führt den Filter über alle gewählten Quellen aus und liefert +// eine zusammengeführte, by-Timestamp-DESC sortierte Liste. Pro +// Source-Slot maximal Filter.Limit Entries; das End-Result kann also +// in der Theorie len(Sources)*Limit groß sein — UI cappt selbst auf +// f.Limit beim Render. +func (r *Reader) Query(ctx context.Context, f Filter) ([]Entry, error) { + if f.Limit <= 0 { + f.Limit = 200 + } + if f.Limit > 1000 { + f.Limit = 1000 + } + sources := f.Sources + if len(sources) == 0 { + sources = AllSources + } + + var ( + out []Entry + mu sync.Mutex + wg sync.WaitGroup + ) + errs := make(chan error, len(sources)) + + for _, s := range sources { + s := s + wg.Add(1) + go func() { + defer wg.Done() + var entries []Entry + var err error + if s == SourceAudit { + entries, err = r.queryAudit(ctx, f) + } else { + entries, err = readJournal(ctx, s, f) + } + if err != nil { + errs <- fmt.Errorf("%s: %w", s, err) + return + } + // Level + Grep nochmal filtern (journalctl-Output kommt + // roh durch). + entries = filterClient(entries, f) + mu.Lock() + out = append(out, entries...) + mu.Unlock() + }() + } + wg.Wait() + close(errs) + + // Nicht-fatal — failed Source = einfach leere Liste für die, + // Operator sieht die Box "kein Eintrag von ". Aber wir + // flag'en den letzten Fehler oben für slog.Warn. + var lastErr error + for e := range errs { + lastErr = e + } + + sort.Slice(out, func(i, j int) bool { + return out[i].Timestamp.After(out[j].Timestamp) + }) + if len(out) > f.Limit { + out = out[:f.Limit] + } + return out, lastErr +} + +// queryAudit füllt Entries aus der audit_log-Tabelle. +func (r *Reader) queryAudit(ctx context.Context, f Filter) ([]Entry, error) { + if r.Audit == nil { + return nil, nil + } + // audit.ListRecent hat einen 100-Max-Limit-Guard, was für unsere + // Aggregations-Use-Case zu wenig sein kann. Da wir nur die + // letzten Einträge brauchen reicht das aber als Source. + limit := f.Limit + if limit > 100 { + limit = 100 + } + rows, err := r.Audit.ListRecent(ctx, limit) + if err != nil { + return nil, err + } + out := make([]Entry, 0, len(rows)) + for _, row := range rows { + // Since/Until-Filter direkt anwenden (DB-Repo kennt das + // nicht — wir holen die letzten N und filtern hier). + if !f.Since.IsZero() && row.CreatedAt.Before(f.Since) { + continue + } + if !f.Until.IsZero() && row.CreatedAt.After(f.Until) { + continue + } + subject := "" + if row.Subject != nil { + subject = *row.Subject + } + msg := row.Action + if subject != "" { + msg = row.Action + ": " + subject + } + out = append(out, Entry{ + Timestamp: row.CreatedAt, + Source: SourceAudit, + Level: LevelInfo, + Message: msg, + Actor: row.Actor, + Action: row.Action, + Subject: subject, + }) + } + return out, nil +} + +// readJournal ruft `journalctl --output=json -u --since=...` +// auf. Mehrere Units (wg-quick@*) sind erlaubt via mehreren -u-Flags; +// für SourceWireguard wickeln wir das mit `-u wg-quick@*` ab. +func readJournal(ctx context.Context, s Source, f Filter) ([]Entry, error) { + args := []string{"--output=json", "--no-pager"} + unit := string(s) + ".service" + if s == SourceWireguard { + // alle wg-quick-Instanzen — glob-Pattern wird vom systemd + // matcher unterstützt. + args = append(args, "-u", "wg-quick@*.service") + } else { + args = append(args, "-u", unit) + } + if !f.Since.IsZero() { + args = append(args, "--since", f.Since.Format(time.RFC3339)) + } + if !f.Until.IsZero() { + args = append(args, "--until", f.Until.Format(time.RFC3339)) + } + // journalctl -n N gibt die letzten N. Wir nehmen N=Limit als + // Obergrenze pro Source. + args = append(args, "-n", strconv.Itoa(f.Limit)) + + cmd := exec.CommandContext(ctx, "journalctl", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + defer func() { _ = cmd.Wait() }() + + out := make([]Entry, 0, f.Limit) + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 || line[0] != '{' { + continue + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(line, &raw); err != nil { + continue + } + e := parseJournalEntry(s, raw) + if e.Timestamp.IsZero() { + continue + } + out = append(out, e) + } + if err := sc.Err(); err != nil && !errors.Is(err, bufio.ErrTooLong) { + return out, err + } + return out, nil +} + +func parseJournalEntry(s Source, raw map[string]json.RawMessage) Entry { + e := Entry{Source: s} + // __REALTIME_TIMESTAMP ist string von Mikrosekunden seit epoch. + if v, ok := raw["__REALTIME_TIMESTAMP"]; ok { + var ts string + if err := json.Unmarshal(v, &ts); err == nil { + if usec, err := strconv.ParseInt(ts, 10, 64); err == nil { + e.Timestamp = time.UnixMicro(usec) + } + } + } + if v, ok := raw["MESSAGE"]; ok { + var s string + if json.Unmarshal(v, &s) == nil { + e.Message = s + } + } + if v, ok := raw["_HOSTNAME"]; ok { + var s string + if json.Unmarshal(v, &s) == nil { + e.Host = s + } + } + if v, ok := raw["PRIORITY"]; ok { + var s string + if json.Unmarshal(v, &s) == nil { + e.Level = priorityToLevel(s) + } + } + if e.Level == "" { + e.Level = LevelInfo + } + return e +} + +func priorityToLevel(p string) Level { + switch p { + case "0", "1", "2", "3": + return LevelError + case "4": + return LevelWarn + case "7": + return LevelDebug + default: + return LevelInfo + } +} + +// filterClient wendet Level + Grep clientseitig an (journalctl filtert +// die nicht direkt; das einfacher als --priority + --grep zu jonglieren). +func filterClient(in []Entry, f Filter) []Entry { + hasLevel := len(f.Levels) > 0 + levelSet := map[Level]bool{} + for _, l := range f.Levels { + levelSet[l] = true + } + grep := strings.ToLower(strings.TrimSpace(f.Grep)) + + out := in[:0] + for _, e := range in { + if hasLevel && !levelSet[e.Level] { + continue + } + if grep != "" { + hay := strings.ToLower(e.Message) + if !strings.Contains(hay, grep) && + !strings.Contains(strings.ToLower(e.Actor), grep) && + !strings.Contains(strings.ToLower(e.Action), grep) && + !strings.Contains(strings.ToLower(e.Subject), grep) { + continue + } + } + out = append(out, e) + } + return out +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index db06898..c7e12da 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -26,6 +26,7 @@ const DNSPage = lazy(() => import('./pages/DNS')) const NTPPage = lazy(() => import('./pages/NTP')) const ClusterPage = lazy(() => import('./pages/Cluster')) const FirewallLivePage = lazy(() => import('./pages/FirewallLive')) +const LogsPage = lazy(() => import('./pages/Logs')) const LicensePage = lazy(() => import('./pages/License')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -111,6 +112,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 648d714..2fcfb75 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -18,6 +18,7 @@ const PAGE_TITLES: Record = { '/ip-addresses': 'nav.ipAddresses', '/firewall-live': 'nav.firewallLive', '/cluster': 'nav.cluster', + '/logs': 'nav.logs', '/license': 'nav.license', '/settings': 'nav.settings', } diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 9c86364..bed2512 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { CrownOutlined, DashboardOutlined, EyeOutlined, + FileSearchOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined, @@ -71,13 +72,14 @@ const NAV: NavSection[] = [ labelKey: 'nav.section.system', items: [ { path: '/cluster', labelKey: 'nav.cluster', icon: }, + { path: '/logs', labelKey: 'nav.logs', icon: }, { path: '/license', labelKey: 'nav.license', icon: }, { path: '/settings', labelKey: 'nav.settings', icon: }, ], }, ] -const VERSION = '1.0.61' +const VERSION = '1.0.62' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -