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:
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user