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
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -201,8 +202,9 @@ func openAPISpec(version string) map[string]any {
|
||||
switch rt.method {
|
||||
case "GET":
|
||||
props, _ := tool.schema["properties"].(map[string]any)
|
||||
required, _ := tool.schema["required"].([]string)
|
||||
for name, p := range props {
|
||||
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"]})
|
||||
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"], "required": slices.Contains(required, name)})
|
||||
}
|
||||
if rt.tool == "get_logs" {
|
||||
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rest sends a request to the REST API with a token.
|
||||
@@ -99,15 +100,19 @@ func TestLogs(t *testing.T) {
|
||||
want []string
|
||||
bad bool
|
||||
}{
|
||||
{logQuery{}, []string{"--lines=100"}, false},
|
||||
{logQuery{Unit: "router-webui"}, []string{"--lines=100"}, false},
|
||||
{logQuery{}, nil, true},
|
||||
{logQuery{Unit: "sshd.service"}, []string{"--unit=sshd.service"}, false},
|
||||
{logQuery{Unit: "systemd-journald"}, nil, true},
|
||||
{logQuery{Unit: "getty@tty1"}, nil, true},
|
||||
{logQuery{Unit: "router-pppoe", Lines: 5, Since: "1 hour ago", Priority: "err", Grep: "LCP", Boot: &one},
|
||||
[]string{"--lines=5", "--unit=router-pppoe", "--since=1 hour ago", "--priority=err", "--grep=LCP", "--boot=0"}, false},
|
||||
{logQuery{Unit: "kernel"}, []string{"-k"}, false},
|
||||
{logQuery{Unit: "--vacuum-time=1s"}, nil, true},
|
||||
{logQuery{Unit: "a b"}, nil, true},
|
||||
{logQuery{Lines: 5000}, nil, true},
|
||||
{logQuery{Priority: "loud"}, nil, true},
|
||||
{logQuery{Since: "$(reboot)"}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Lines: 5000}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Priority: "loud"}, nil, true},
|
||||
{logQuery{Unit: "router-webui", Since: "$(reboot)"}, nil, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
args, err := journalArgs(c.q)
|
||||
@@ -178,3 +183,54 @@ func TestOpenAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiry(t *testing.T) {
|
||||
a, _ := newTestApp(t)
|
||||
h := a.routes()
|
||||
tok, secret, err := a.tokens.CreateFor("short", "read", time.Hour)
|
||||
if err != nil || tok.Expires.IsZero() {
|
||||
t.Fatalf("create: %v %+v", err, tok)
|
||||
}
|
||||
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
|
||||
t.Fatalf("valid token: %d", rec.Code)
|
||||
}
|
||||
// Move its end into the past.
|
||||
toks := a.tokens.load()
|
||||
toks[0].Expires = time.Now().Add(-time.Minute)
|
||||
if err := a.tokens.save(toks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired token: %d", rec.Code)
|
||||
}
|
||||
if rec, _ := rpc(h, secret, "ping", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired token on MCP: %d", rec.Code)
|
||||
}
|
||||
_, forever, _ := a.tokens.Create("long", "read")
|
||||
if rec := rest(h, forever, "GET", "/api/v1/status", "", nil); rec.Code != 200 {
|
||||
t.Fatalf("token without expiry: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenEditNote(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
data, _ := os.ReadFile(sys.settingsPath())
|
||||
os.WriteFile(sys.confirmedPath(), data, 0o600)
|
||||
_, tok, _ := a.tokens.Create("assistant", "change")
|
||||
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
|
||||
e := rec.Header().Get("ETag")
|
||||
if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": e}); rec.Code != 200 {
|
||||
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
cur, _ := os.ReadFile(sys.settingsPath())
|
||||
if note := sys.tokenEditNote(cur); !strings.Contains(note, "assistant") {
|
||||
t.Fatalf("no note: %q", note)
|
||||
}
|
||||
// A later edit by a person makes the note stale.
|
||||
other := append([]byte(nil), cur...)
|
||||
other = append(other, '\n', ' ')
|
||||
if note := sys.tokenEditNote(other); note != "" {
|
||||
t.Fatalf("stale note: %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,3 +216,14 @@ func (s Settings) duplicateName() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// devLabel is how a device is shown in a list: a VLAN with a chosen name as
|
||||
// "dmz (lan.1002)", anything else as it is.
|
||||
func devLabel(devices []Device, name string) string {
|
||||
for _, d := range devices {
|
||||
if d.Name == name && d.Custom != "" {
|
||||
return d.Custom + " (" + name + ")"
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -129,3 +129,12 @@ func TestRenameVLAN(t *testing.T) {
|
||||
t.Error("a card and a VLAN with the same name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevLabel(t *testing.T) {
|
||||
devs := []Device{{Name: "lan.30", Kind: "VLAN", Custom: "cams"}, {Name: "lan.40", Kind: "VLAN"}, {Name: "lan", Kind: "physical"}}
|
||||
for name, want := range map[string]string{"lan.30": "cams (lan.30)", "lan.40": "lan.40", "lan": "lan", "other": "other"} {
|
||||
if got := devLabel(devs, name); got != want {
|
||||
t.Errorf("%s: %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,15 @@ import (
|
||||
// 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)$`)
|
||||
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
|
||||
)
|
||||
@@ -45,10 +51,13 @@ func journalArgs(q logQuery) ([]string, error) {
|
||||
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")
|
||||
}
|
||||
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}} {
|
||||
@@ -81,13 +90,27 @@ func journalArgs(q logQuery) ([]string, error) {
|
||||
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). 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).",
|
||||
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": "A systemd unit (router-pppoe, router-webui.service, ...) or `kernel`."},
|
||||
"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."},
|
||||
@@ -95,6 +118,7 @@ var logsTool = mcpTool{
|
||||
"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
|
||||
|
||||
@@ -252,6 +252,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
"chartH": func() float64 { return chartH },
|
||||
"join": strings.Join,
|
||||
"choices": choices,
|
||||
"devLabel": devLabel,
|
||||
"tform": func(t WGTunnel, csrf string, isNew bool) map[string]any {
|
||||
return map[string]any{"T": t, "CSRF": csrf, "New": isNew}
|
||||
},
|
||||
|
||||
@@ -246,6 +246,13 @@ func (a *app) runTool(tok Token, tool *mcpTool, args json.RawMessage) (any, erro
|
||||
entry.Error = err.Error()
|
||||
}
|
||||
a.sys.audit(entry)
|
||||
if err == nil && tool.name == "update_settings" {
|
||||
if m, ok := res.(map[string]any); ok {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
a.sys.noteTokenEdit(tok.Name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,13 @@ nav .group > span { display: block; padding: 0 .7rem .25rem; font-size: .7rem; t
|
||||
nav form { margin-top: 1rem; }
|
||||
nav button.link { background: none; border: 0; padding: .38rem .7rem; font-weight: 400; cursor: pointer; }
|
||||
|
||||
main { max-width: 74rem; padding: 1.5rem 2rem 3rem; }
|
||||
main { padding: 1.5rem clamp(1rem, 3vw, 3rem) 3rem; }
|
||||
/* Pages of independent panels flow into up to three columns on wide screens */
|
||||
.cols { columns: 38rem 3; column-gap: 1.5rem; }
|
||||
.cols > section, .cols > fieldset, .cols > form > fieldset { break-inside: avoid; }
|
||||
.cols > section.busy, .cols > section.pending { column-span: all; }
|
||||
.menu-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.menu-button { display: none; }
|
||||
h1 { font-size: 1.45rem; font-weight: 600; letter-spacing: -.01em; margin: .25rem 0 1.25rem; }
|
||||
h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .6rem; }
|
||||
section { margin: 0 0 1.5rem; }
|
||||
@@ -66,6 +72,10 @@ dd { margin: 0; overflow-wrap: anywhere; }
|
||||
fieldset { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); margin: 0 0 1rem; padding: 0 0 1rem; min-width: 0; overflow: hidden; }
|
||||
legend { float: left; width: 100%; margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); }
|
||||
legend ~ * { clear: both; }
|
||||
/* A section as a panel (its h2 is the header bar), for pages made of sections */
|
||||
section.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; padding: 0 0 1rem; }
|
||||
section.panel > h2 { margin: 0 0 1rem; padding: .6rem 1rem; font-size: .95rem; font-weight: 600; background: var(--head); border-bottom: 1px solid var(--line); }
|
||||
section.panel > :not(h2) { margin-left: 1rem; margin-right: 1rem; }
|
||||
fieldset > :not(legend):not(input[type=hidden]) { margin-left: 1rem; margin-right: 1rem; }
|
||||
fieldset > .scroll { margin-left: 0; margin-right: 0; padding: 0 1rem; overflow-x: auto; }
|
||||
|
||||
@@ -75,6 +85,7 @@ input, select, textarea, button { font: inherit; color: var(--fg); background: v
|
||||
input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--fg) 30%, var(--line)); }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent); }
|
||||
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 65%, transparent); }
|
||||
textarea[rows] { height: min(calc(1.45em * var(--rows, 30)), 65vh); }
|
||||
textarea { width: 100%; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
|
||||
.narrow { max-width: 24rem; display: flex; flex-direction: column; gap: .8rem; }
|
||||
.narrow label { margin: 0; }
|
||||
@@ -116,17 +127,20 @@ pre code { background: none; padding: 0; }
|
||||
body.bare { padding-left: 0; }
|
||||
.bare header { position: static; width: auto; flex-direction: row; background: none; padding: 1rem 1.25rem; }
|
||||
.bare header > strong { color: var(--fg); }
|
||||
.bare main { margin: 0 auto; }
|
||||
.bare main { margin: 0 auto; max-width: 30rem; }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
body { padding-left: 0; }
|
||||
header { position: static; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; padding: .6rem 1rem; }
|
||||
nav { flex-direction: row; flex-wrap: wrap; align-items: center; gap: .1rem .25rem; }
|
||||
nav > a, nav .group a, nav button.link { width: auto; padding: .3rem .55rem; }
|
||||
nav .group { display: contents; }
|
||||
nav .group > span, nav form { display: none; }
|
||||
nav form:has(button) { display: block; margin: 0; }
|
||||
main { padding: 1rem; }
|
||||
header { position: sticky; top: 0; bottom: auto; width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .25rem 1rem; padding: .5rem .75rem; max-height: 100vh; }
|
||||
.menu-button { display: inline-block; margin: 0; padding: .3rem .85rem; border: 1px solid var(--side-muted); border-radius: var(--radius); color: var(--side-fg); font-size: .9rem; font-weight: 500; cursor: pointer; }
|
||||
header nav { display: none; width: 100%; max-height: calc(100vh - 4rem); overflow-y: auto; }
|
||||
.menu-toggle:checked ~ nav { display: flex; }
|
||||
.menu-toggle:focus-visible ~ .menu-button { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||
main { padding: .75rem .6rem 2rem; }
|
||||
h1 { font-size: 1.25rem; margin-bottom: .75rem; }
|
||||
fieldset > :not(legend):not(input[type=hidden]), section.panel > :not(h2) { margin-left: .75rem; margin-right: .75rem; }
|
||||
fieldset > .scroll { padding: 0 .75rem; }
|
||||
.cols { columns: auto; }
|
||||
}
|
||||
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
|
||||
.ranges a { padding: .25rem .8rem; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; color: var(--fg); background: var(--surface); }
|
||||
|
||||
@@ -59,6 +59,7 @@ type Status struct {
|
||||
Log string
|
||||
RollbackIn time.Duration // > 0 while a change awaits confirmation
|
||||
Unapplied bool // settings.json differs from the confirmed settings
|
||||
TokenEdit string // who edited them through the API, when the saved settings are still that edit
|
||||
ConfirmRunning bool
|
||||
}
|
||||
|
||||
@@ -81,6 +82,9 @@ func (s System) Status() Status {
|
||||
cur, err1 := os.ReadFile(s.settingsPath())
|
||||
conf, err2 := os.ReadFile(s.confirmedPath())
|
||||
st.Unapplied = err1 == nil && err2 == nil && !sameJSON(cur, conf)
|
||||
if st.Unapplied {
|
||||
st.TokenEdit = s.tokenEditNote(cur)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "content"}}
|
||||
<section>
|
||||
<section class="panel">
|
||||
<h2>Settings</h2>
|
||||
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
|
||||
They are checked when applied; nothing changes until then.</p>
|
||||
@@ -10,7 +10,9 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="cols">
|
||||
|
||||
<section class="panel">
|
||||
<h2>Appearance</h2>
|
||||
<form method="post" action="/prefs" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
@@ -26,7 +28,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="panel">
|
||||
<h2>Dashboard</h2>
|
||||
<form method="post" action="/prefs" class="narrow">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
@@ -37,7 +39,7 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="panel">
|
||||
<h2>Password</h2>
|
||||
<p>One password for this web UI and for root on the console and over SSH.</p>
|
||||
<form method="post" action="/password" class="narrow">
|
||||
@@ -49,17 +51,18 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="panel">
|
||||
<h2>Reboot</h2>
|
||||
<p>Restarts the router; routing stops for a minute or two. A new kernel from an update only runs after this.</p>
|
||||
<form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="panel">
|
||||
<h2>Backup</h2>
|
||||
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
|
||||
certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run
|
||||
<code>router-setup</code> from the installer: it offers to install from it.</p>
|
||||
<form method="post" action="/backup"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Download backup</button></form>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}{{$f := .Form}}
|
||||
<section>
|
||||
@@ -42,4 +43,5 @@
|
||||
{{else}}<p class="muted">Nothing yet. After you save and apply, the router orders the certificate (a minute or two); renewals follow 30 days before it expires and need no restart.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{{if .Diff}}
|
||||
<p>The saved settings differ from the confirmed (running) ones: <code>-</code> lines go away, <code>+</code> lines are new.
|
||||
Nothing is applied until you press Apply.</p>
|
||||
{{with .TokenEdit}}<p class="error">{{.}}</p>{{end}}
|
||||
<pre class="log">{{.Diff}}</pre>
|
||||
<div class="actions">
|
||||
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Apply</button></form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}{{$f := .Form}}
|
||||
<p class="hint">Keeps DNS records at your provider pointing at the router's internet addresses: it checks regularly and
|
||||
@@ -41,4 +42,5 @@
|
||||
{{else}}<p class="muted">Nothing yet. Once applied, the log of ddclient shows up here.</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<header>
|
||||
<strong>router</strong>
|
||||
{{if .LoggedIn}}
|
||||
<input type="checkbox" id="menu" class="menu-toggle" aria-label="Menu">
|
||||
<label for="menu" class="menu-button">Menu</label>
|
||||
<nav>
|
||||
<a href="/"{{if eq .Title "Overview"}} aria-current="page"{{end}}>Overview</a>
|
||||
<div class="group"><span>Network</span>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<legend>Uplink (WAN)</legend>
|
||||
<label>Interface
|
||||
<select name="wan.interface">{{$wan := .Form.WAN.Interface}}
|
||||
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{.}}</option>{{end}}
|
||||
{{range choices $.Data.Devices $wan}}<option value="{{.}}" {{if eq . $wan}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
{{with index $e "wan.interface"}}<span class="field-error">{{.}}</span>{{end}}
|
||||
@@ -95,7 +95,7 @@
|
||||
<td><input name="lan.{{$i}}.name" value="{{$l.Name}}" size="8" placeholder="new"></td>
|
||||
<td><select name="lan.{{$i}}.interface">{{$cur := $l.Interface}}
|
||||
{{if not $cur}}<option value="" selected></option>{{end}}
|
||||
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}
|
||||
{{range choices $.Data.Devices $cur}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{devLabel $.Data.Devices .}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="lan.{{$i}}.address" value="{{$l.Address}}" size="13"></td>
|
||||
<td><input name="lan.{{$i}}.prefix" value="{{$l.Prefix}}" size="2" inputmode="numeric" placeholder="24"></td>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{$s := .Data.Status}}
|
||||
{{if $s.Applying}}
|
||||
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
|
||||
@@ -14,6 +15,7 @@
|
||||
{{else if $s.Unapplied}}
|
||||
<section class="pending">
|
||||
<p>The settings have changes that are not part of the confirmed system. <a href="/changes">Review the changes</a>.</p>
|
||||
{{with $s.TokenEdit}}<p><strong>{{.}}</strong></p>{{end}}
|
||||
<div class="actions">
|
||||
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
|
||||
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
|
||||
@@ -44,4 +46,5 @@
|
||||
</section>
|
||||
{{end}}
|
||||
<script src="/static/dashboard.js" defer></script>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
{{if .Tokens}}
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<tr><th>Name</th><th>Access</th><th>Created</th><th>Last used</th><th></th></tr>
|
||||
<tr><th>Name</th><th>Access</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
|
||||
{{range .Tokens}}<tr><td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created.Format "2006-01-02"}}</td>
|
||||
<td>{{if .Expires.IsZero}}<span class="hint">never</span>{{else if .Expired}}<span class="bad">expired {{.Expires.Format "2006-01-02"}}</span>{{else}}{{.Expires.Format "2006-01-02"}}{{end}}</td>
|
||||
<td>{{if .LastUsed.IsZero}}<span class="hint">never</span>{{else}}{{.LastUsed.Format "2006-01-02 15:04"}}{{end}}</td>
|
||||
<td><form method="post" action="/tokens/revoke" data-confirm="Revoke this token?"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><button class="link">revoke</button></form></td></tr>{{end}}
|
||||
</table>
|
||||
@@ -43,6 +44,15 @@
|
||||
<option value="change">change: also edit the settings</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Valid for
|
||||
<select name="valid">
|
||||
<option value="1d">1 day</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d" selected>30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="never">no limit</option>
|
||||
</select>
|
||||
</label>
|
||||
<button>Create token</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="cols">
|
||||
{{$d := .Data}}
|
||||
<section>
|
||||
<h2>Installed</h2>
|
||||
@@ -60,4 +61,5 @@
|
||||
<pre class="log">{{$d.Status.Log}}</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -132,7 +132,6 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>New tunnel</h2>
|
||||
{{template "tunnelform" (tform .New $.CSRF true)}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -40,8 +40,12 @@ type Token struct {
|
||||
Hash string `json:"hash"` // hex SHA-256 of the secret
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Expires time.Time `json:"expires,omitzero"` // zero: never
|
||||
}
|
||||
|
||||
// Expired tells whether the token's time is up.
|
||||
func (k Token) Expired() bool { return !k.Expires.IsZero() && time.Now().After(k.Expires) }
|
||||
|
||||
func (s System) tokensPath() string { return filepath.Join(s.StateDir, "webui", "tokens.json") }
|
||||
func (s System) auditPath() string { return filepath.Join(s.StateDir, "webui", "audit.log") }
|
||||
|
||||
@@ -91,8 +95,14 @@ func (t *tokenStore) List() []Token {
|
||||
return t.load()
|
||||
}
|
||||
|
||||
// Create makes a token and returns its secret, which is not stored.
|
||||
// Create makes a token that does not expire and returns its secret, which is
|
||||
// not stored.
|
||||
func (t *tokenStore) Create(name, scope string) (Token, string, error) {
|
||||
return t.CreateFor(name, scope, 0)
|
||||
}
|
||||
|
||||
// CreateFor makes a token that stops working after valid (0: never).
|
||||
func (t *tokenStore) CreateFor(name, scope string, valid time.Duration) (Token, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
switch {
|
||||
case name == "" || len(name) > 40:
|
||||
@@ -116,6 +126,9 @@ func (t *tokenStore) Create(name, scope string) (Token, string, error) {
|
||||
}
|
||||
secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
||||
tok := Token{ID: hex.EncodeToString(id), Name: name, Scope: scope, Hash: hashSecret(secret), Created: time.Now().UTC().Truncate(time.Second)}
|
||||
if valid > 0 {
|
||||
tok.Expires = tok.Created.Add(valid)
|
||||
}
|
||||
return tok, secret, t.save(append(toks, tok))
|
||||
}
|
||||
|
||||
@@ -146,7 +159,7 @@ func (t *tokenStore) Lookup(secret string) (Token, bool) {
|
||||
found = i
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
if found < 0 || toks[found].Expired() {
|
||||
return Token{}, false
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
@@ -224,3 +237,35 @@ func (s System) RecentAudit(n int) []AuditEntry {
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// A note of the last settings edit made through the API, so that the web UI
|
||||
// can tell the person who reviews it where it came from. It only counts while
|
||||
// the saved settings are still what that edit left (etag): a later edit in
|
||||
// the web UI or an apply makes it stale.
|
||||
type tokenEdit struct {
|
||||
Token string `json:"token"`
|
||||
Time time.Time `json:"time"`
|
||||
Etag string `json:"etag"`
|
||||
}
|
||||
|
||||
func (s System) tokenEditPath() string { return filepath.Join(s.StateDir, "webui", "token-edit.json") }
|
||||
|
||||
func (s System) noteTokenEdit(token, etag string) {
|
||||
data, _ := json.Marshal(tokenEdit{Token: token, Time: time.Now().UTC().Truncate(time.Second), Etag: etag})
|
||||
_ = os.MkdirAll(filepath.Dir(s.tokenEditPath()), 0o700)
|
||||
_ = writeFileAtomic(s.tokenEditPath(), append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// tokenEditNote says who made the unapplied changes, or "" when the saved
|
||||
// settings (cur) are not the API's edit.
|
||||
func (s System) tokenEditNote(cur []byte) string {
|
||||
data, err := os.ReadFile(s.tokenEditPath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var e tokenEdit
|
||||
if json.Unmarshal(data, &e) != nil || e.Etag != etag(cur) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("These changes were made through the API with the token %q at %s.", e.Token, e.Time.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How long a new token works (the form's choices; 0 is never).
|
||||
var tokenLifetimes = map[string]time.Duration{
|
||||
"1d": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "90d": 90 * 24 * time.Hour, "never": 0,
|
||||
}
|
||||
|
||||
type tokensData struct {
|
||||
Tokens []Token
|
||||
Audit []AuditEntry
|
||||
@@ -24,7 +30,11 @@ func (a *app) tokensPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
}
|
||||
|
||||
func (a *app) tokenCreate(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
tok, secret, err := a.tokens.Create(r.PostFormValue("name"), r.PostFormValue("scope"))
|
||||
valid, ok := tokenLifetimes[r.PostFormValue("valid")]
|
||||
if !ok {
|
||||
valid = tokenLifetimes["30d"]
|
||||
}
|
||||
tok, secret, err := a.tokens.CreateFor(r.PostFormValue("name"), r.PostFormValue("scope"), valid)
|
||||
p := page{Title: "API access", CSRF: sess.csrf, LoggedIn: true}
|
||||
d := a.tokensData(r)
|
||||
if err != nil {
|
||||
@@ -50,6 +60,6 @@ func (a *app) tokenRevoke(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
// ones, for a person to review before applying.
|
||||
func (a *app) changesPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
a.render(w, "changes", page{Title: "Changes", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{
|
||||
"Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied,
|
||||
"Diff": a.sys.pendingChanges(), "Unapplied": a.sys.Status().Unapplied, "TokenEdit": a.sys.Status().TokenEdit,
|
||||
}})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user