Files
nixos-router/pkgs/router-webui/tokens.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

272 lines
7.2 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
// API tokens for programmatic access (the MCP endpoint). A token is shown once
// when it is made; only its SHA-256 is stored. Scopes:
//
// read state and settings
// change also edit the settings (they stay unapplied until a person applies
// them in the web UI)
//
// An "apply" scope is reserved for later.
const (
scopeRead = "read"
scopeChange = "change"
tokenPrefix = "rtr_"
maxTokens = 32
)
type Token struct {
ID string `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
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") }
// tokenStore keeps the tokens in a file; every call reads it, so edits and
// restarts need no care.
type tokenStore struct {
mu sync.Mutex
path string
lastSave map[string]time.Time // when LastUsed of a token was last written
}
func newTokenStore(path string) *tokenStore {
return &tokenStore{path: path, lastSave: map[string]time.Time{}}
}
func (t *tokenStore) load() []Token {
data, err := os.ReadFile(t.path)
if err != nil {
return nil
}
var toks []Token
if json.Unmarshal(data, &toks) != nil {
return nil
}
return toks
}
func (t *tokenStore) save(toks []Token) error {
data, err := json.MarshalIndent(toks, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(t.path), 0o700); err != nil {
return err
}
return writeFileAtomic(t.path, append(data, '\n'), 0o600)
}
func hashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}
func (t *tokenStore) List() []Token {
t.mu.Lock()
defer t.mu.Unlock()
return t.load()
}
// 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:
return Token{}, "", errors.New("give the token a name of up to 40 characters")
case scope != scopeRead && scope != scopeChange:
return Token{}, "", fmt.Errorf("unknown scope %q", scope)
}
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
if len(toks) >= maxTokens {
return Token{}, "", errors.New("too many tokens; revoke one first")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return Token{}, "", err
}
id := make([]byte, 4)
if _, err := rand.Read(id); err != nil {
return Token{}, "", err
}
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))
}
func (t *tokenStore) Revoke(id string) error {
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
n := len(toks)
toks = slices.DeleteFunc(toks, func(k Token) bool { return k.ID == id })
if len(toks) == n {
return errors.New("no such token")
}
return t.save(toks)
}
// Lookup finds the token for a secret and notes that it was used.
func (t *tokenStore) Lookup(secret string) (Token, bool) {
if !strings.HasPrefix(secret, tokenPrefix) {
return Token{}, false
}
want := hashSecret(secret)
t.mu.Lock()
defer t.mu.Unlock()
toks := t.load()
found := -1
for i, k := range toks {
if subtle.ConstantTimeCompare([]byte(k.Hash), []byte(want)) == 1 {
found = i
}
}
if found < 0 || toks[found].Expired() {
return Token{}, false
}
now := time.Now().UTC().Truncate(time.Second)
tok := toks[found]
tok.LastUsed = now
// Writing on every call would wear the disk; once in a few minutes is enough.
if now.Sub(t.lastSave[tok.ID]) > 5*time.Minute {
toks[found] = tok
if t.save(toks) == nil {
t.lastSave[tok.ID] = now
}
}
return tok, true
}
func (k Token) allows(scope string) bool {
return scope == scopeRead || k.Scope == scopeChange
}
// Audit log: one JSON line per call of a tool, kept to the last 1 MB (and the
// file before it).
type AuditEntry struct {
Time time.Time `json:"time"`
Token string `json:"token"`
Tool string `json:"tool"`
Args string `json:"args,omitempty"`
Error string `json:"error,omitempty"`
}
var auditMu sync.Mutex
func (s System) audit(e AuditEntry) {
auditMu.Lock()
defer auditMu.Unlock()
if len(e.Args) > 300 {
e.Args = e.Args[:300] + "…"
}
path := s.auditPath()
if fi, err := os.Stat(path); err == nil && fi.Size() > 1<<20 {
_ = os.Rename(path, path+".1")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
line, _ := json.Marshal(e)
fmt.Fprintf(f, "%s\n", line)
}
// RecentAudit returns the last n entries, newest first.
func (s System) RecentAudit(n int) []AuditEntry {
auditMu.Lock()
defer auditMu.Unlock()
f, err := os.Open(s.auditPath())
if err != nil {
return nil
}
defer f.Close()
var all []AuditEntry
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
var e AuditEntry
if json.Unmarshal(sc.Bytes(), &e) == nil {
all = append(all, e)
}
}
slices.Reverse(all)
if len(all) > n {
all = all[:n]
}
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"))
}