Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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)$`)
|
||||
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 != "":
|
||||
if !unitRe.MatchString(q.Unit) {
|
||||
return nil, argError("unit: a unit name like router-webui or router-pppoe.service, or kernel")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
var logsTool = mcpTool{
|
||||
name: "get_logs", scope: scopeRead,
|
||||
description: "Lines of the system journal, oldest first (the last `lines` that match). Without `unit` all units. Useful units: router-pppoe (the PPPoE connection: connects and disconnects), router-webui, router-apply, systemd-networkd, kea-dhcp4-server, nftables, wireguard-*; `kernel` for the kernel log. 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": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."},
|
||||
"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."},
|
||||
},
|
||||
},
|
||||
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
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user