Files
nixos-router/pkgs/router-webui/logs.go
T
nilsandClaude Sonnet 5.5 b1fa7b1396 Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md
get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:06:27 +01:00

145 lines
5.5 KiB
Go

package main
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
)
// get_logs: the system journal, read-only, through journalctl with its
// arguments built here (no shell), so that nothing a caller sends can become
// an option.
var (
unitRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@:._-]{0,79}$`)
timeRe = regexp.MustCompile(`^[0-9A-Za-z :.+/_,-]{1,40}$`)
prioRe = regexp.MustCompile(`^([0-7]|emerg|alert|crit|err|warning|notice|info|debug)$`)
// The journal as a whole is not offered, only the units that run the router
// (and the kernel): a read token should not read everything on the box.
logUnits = []string{
"router-*", "kea-dhcp4-server", "unbound", "systemd-networkd", "systemd-resolved",
"systemd-timesyncd", "nftables", "ddclient", "acme-*", "sshd",
}
maxLogs = 1000
logSize = 256 << 10
)
// runJournal runs journalctl (replaced in tests).
var runJournal = command
type logQuery struct {
Unit string `json:"unit"`
Lines int `json:"lines"`
Since string `json:"since"`
Until string `json:"until"`
Priority string `json:"priority"`
Grep string `json:"grep"`
Boot *int `json:"boot"`
}
// journalArgs turns a query into journalctl's arguments.
func journalArgs(q logQuery) ([]string, error) {
if q.Lines == 0 {
q.Lines = 100
}
if q.Lines < 1 || q.Lines > maxLogs {
return nil, argError(fmt.Sprintf("lines must be 1 to %d", maxLogs))
}
args := []string{"--no-pager", "-o", "short-iso", "--lines=" + strconv.Itoa(q.Lines)}
switch {
case q.Unit == "kernel":
args = append(args, "-k")
case q.Unit == "":
return nil, argError("unit is required: kernel or one of " + strings.Join(logUnits, ", "))
case !unitRe.MatchString(q.Unit):
return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel")
case !logUnitAllowed(q.Unit):
return nil, argError("unit " + q.Unit + " is not offered; use kernel or one of " + strings.Join(logUnits, ", "))
default:
args = append(args, "--unit="+q.Unit)
}
for _, t := range []struct{ flag, v string }{{"--since=", q.Since}, {"--until=", q.Until}} {
if t.v == "" {
continue
}
if !timeRe.MatchString(t.v) {
return nil, argError(strings.TrimPrefix(strings.TrimSuffix(t.flag, "="), "--") + ": a time like \"1 hour ago\", \"-2h\", \"yesterday\" or \"2026-10-31 10:00:00\"")
}
args = append(args, t.flag+t.v)
}
if q.Priority != "" {
if !prioRe.MatchString(q.Priority) {
return nil, argError("priority: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (that level and worse)")
}
args = append(args, "--priority="+q.Priority)
}
if q.Grep != "" {
if len(q.Grep) > 200 {
return nil, argError("grep: at most 200 characters")
}
args = append(args, "--grep="+q.Grep, "--case-sensitive=false")
}
if q.Boot != nil {
if *q.Boot > 0 || *q.Boot < -50 {
return nil, argError("boot: 0 (this boot), -1 (the one before), ...")
}
args = append(args, "--boot="+strconv.Itoa(*q.Boot))
}
return args, nil
}
func logUnitAllowed(unit string) bool {
unit = strings.TrimSuffix(unit, ".service")
for _, p := range logUnits {
if prefix, ok := strings.CutSuffix(p, "*"); ok {
if strings.HasPrefix(unit, prefix) {
return true
}
} else if unit == p {
return true
}
}
return false
}
var logsTool = mcpTool{
name: "get_logs", scope: scopeRead,
description: "Lines of the system journal, oldest first (the last `lines` that match). The unit is required: `kernel` or one of router-* (router-pppoe is the PPPoE connection: connects and disconnects; router-webui, router-apply, router-update, ...), kea-dhcp4-server, unbound, systemd-networkd, systemd-resolved, systemd-timesyncd, nftables, ddclient, acme-*, sshd. Search a text with `grep`, narrow by `since` (\"1 hour ago\", \"2026-10-31 10:00:00\"), `priority` (err = errors and worse) or `boot` (0 this boot, -1 the previous).",
schema: map[string]any{
"type": "object",
"properties": map[string]any{
"unit": map[string]any{"type": "string", "description": "Required: `kernel` or a unit: router-pppoe, router-webui.service, kea-dhcp4-server, unbound, systemd-networkd, nftables, ddclient, ..."},
"lines": map[string]any{"type": "integer", "minimum": 1, "maximum": maxLogs, "default": 100, "description": "How many lines at most."},
"since": map[string]any{"type": "string", "description": "Only entries since then: \"1 hour ago\", \"yesterday\", \"2026-10-31 10:00:00\"."},
"until": map[string]any{"type": "string", "description": "Only entries until then."},
"priority": map[string]any{"type": "string", "description": "This level and worse: 0-7 or emerg, alert, crit, err, warning, notice, info, debug."},
"grep": map[string]any{"type": "string", "description": "Only entries matching this regular expression (case insensitive)."},
"boot": map[string]any{"type": "integer", "maximum": 0, "description": "0 is the current boot, -1 the one before, ... Without it all boots the journal holds."},
},
"required": []string{"unit"},
},
run: func(a *app, raw json.RawMessage) (any, error) {
var q logQuery
if err := json.Unmarshal(raw, &q); err != nil {
return nil, argError("bad arguments: " + err.Error())
}
args, err := journalArgs(q)
if err != nil {
return nil, err
}
out, err := runJournal("journalctl", args...)
if err != nil && out == "" {
return nil, fmt.Errorf("journalctl: %w", err)
}
if len(out) > logSize {
out = "[…]\n" + out[len(out)-logSize:]
}
if out == "" || strings.HasPrefix(out, "-- No entries --") {
return "No matching entries.", nil
}
return out, nil
},
}