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:
+106
-9
@@ -83,6 +83,7 @@ func main() {
|
||||
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
|
||||
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
|
||||
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
|
||||
options := flag.String("options", "", "JSON documentation of the router options (offered to MCP clients)")
|
||||
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
|
||||
flag.Parse()
|
||||
if *dev != "" {
|
||||
@@ -106,7 +107,7 @@ func main() {
|
||||
metrics := newCollector(sys, *leases)
|
||||
srv := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: newApp(sys, metrics, *dev != "").routes(),
|
||||
Handler: withOptions(newApp(sys, metrics, *dev != ""), *options).routes(),
|
||||
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
@@ -217,10 +218,19 @@ type app struct {
|
||||
pagesMu sync.Mutex
|
||||
pages map[string]*template.Template
|
||||
loginMu sync.Mutex // one login attempt at a time
|
||||
|
||||
tokens *tokenStore
|
||||
settingsMu sync.Mutex // edits through the API read, patch and write the settings file
|
||||
optionsFile string // JSON documentation of the router options, for the MCP resource ("" if none)
|
||||
}
|
||||
|
||||
func withOptions(a *app, file string) *app {
|
||||
a.optionsFile = file
|
||||
return a
|
||||
}
|
||||
|
||||
func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute)}
|
||||
a := &app{sys: sys, metrics: metrics, dev: dev, sessions: newSessions(30 * time.Minute), tokens: newTokenStore(sys.tokensPath())}
|
||||
a.funcs = template.FuncMap{
|
||||
"short": func(p string) string {
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 && len(p) > i+34 {
|
||||
@@ -257,7 +267,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update"} {
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} {
|
||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||
if err != nil {
|
||||
@@ -301,6 +311,7 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
||||
mux.HandleFunc("POST /vlan", a.auth(a.vlan))
|
||||
mux.HandleFunc("POST /interfaces", a.auth(a.interfaces))
|
||||
mux.HandleFunc("GET /dns", a.auth(a.dnsPage))
|
||||
mux.HandleFunc("POST /dns", a.auth(a.dns))
|
||||
mux.HandleFunc("GET /wireguard", a.auth(a.wireguardPage))
|
||||
@@ -326,6 +337,12 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("POST /update/check", a.auth(a.updateCheck))
|
||||
mux.HandleFunc("POST /update/install", a.auth(a.updateInstall))
|
||||
mux.HandleFunc("POST /update/upload", a.updateUpload)
|
||||
mux.HandleFunc("/mcp", a.mcp)
|
||||
a.registerAPI(mux)
|
||||
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
|
||||
mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate))
|
||||
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
|
||||
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
@@ -349,9 +366,11 @@ type page struct {
|
||||
Error string
|
||||
LoggedIn bool
|
||||
Data any
|
||||
Theme string // set by render: "", "light" or "dark"
|
||||
}
|
||||
|
||||
func (a *app) render(w http.ResponseWriter, name string, p page) {
|
||||
p.Theme = a.sys.loadPrefs().Theme
|
||||
if err := a.page(name).Execute(w, p); err != nil {
|
||||
log.Printf("rendering %s: %v", name, err)
|
||||
}
|
||||
@@ -555,8 +574,85 @@ type networkData struct {
|
||||
}
|
||||
|
||||
// devices lists what the interface dropdowns offer for the form f.
|
||||
func (a *app) devices(f Form) []Device {
|
||||
return f.devices(Interfaces(), a.sys.loadPrefs().VLANs)
|
||||
func (a *app) devices(s Settings, f Form) []Device {
|
||||
names := s.cardNames()
|
||||
var physical []string
|
||||
nics := map[string]NIC{}
|
||||
for _, n := range NICs() {
|
||||
eff := effectiveName(names, n)
|
||||
physical = append(physical, eff)
|
||||
nics[eff] = n
|
||||
}
|
||||
devs := f.devices(physical, a.sys.loadPrefs().VLANs)
|
||||
custom := s.vlanNames()
|
||||
for i, d := range devs {
|
||||
if d.Kind == "VLAN" {
|
||||
devs[i].Custom = custom[d.Name]
|
||||
} else if n, ok := nics[d.Name]; ok {
|
||||
devs[i].Kernel, devs[i].MAC = n.Kernel, n.MAC
|
||||
}
|
||||
}
|
||||
return devs
|
||||
}
|
||||
|
||||
// kernelName is the kernel's name for the card with this MAC address.
|
||||
func kernelName(mac string) string {
|
||||
for _, n := range NICs() {
|
||||
if n.MAC == mac {
|
||||
return n.Kernel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// interfaces saves the names typed into the interface table. A new name is
|
||||
// used by the router after the next apply and reboot.
|
||||
func (a *app) interfaces(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
s, err := loadSettings(a.sys.settingsPath())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||
var rows []Rename
|
||||
for i := range min(n, 256) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("%s.%d", k, i)) }
|
||||
rows = append(rows, Rename{Dev: v("dev"), MAC: v("mac"), From: v("from"), To: v("name")})
|
||||
}
|
||||
moved, err := s.rename(rows, kernelName)
|
||||
msg := ""
|
||||
if err == nil {
|
||||
err = s.save(a.sys.settingsPath())
|
||||
}
|
||||
if err == nil {
|
||||
// The UI's defined VLANs follow a renamed card.
|
||||
prefs := a.sys.loadPrefs()
|
||||
for i, v := range prefs.VLANs {
|
||||
if parent, id := splitVLAN(v); id != 0 && moved[parent] != "" {
|
||||
prefs.VLANs[i] = fmt.Sprintf("%s.%d", moved[parent], id)
|
||||
}
|
||||
}
|
||||
slices.Sort(prefs.VLANs)
|
||||
err = a.sys.savePrefs(prefs)
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
msg = "Names not saved: " + err.Error()
|
||||
case !renamedAny(rows):
|
||||
msg = "Nothing to change."
|
||||
default:
|
||||
msg = "Saved. Apply, then reboot: the new names only exist after a restart."
|
||||
}
|
||||
http.Redirect(w, r, "/network?msg="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func renamedAny(rows []Rename) bool {
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.To) != r.From {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vlan defines or removes a VLAN device for the dropdowns. It lives in the
|
||||
@@ -588,7 +684,7 @@ func (a *app) vlan(w http.ResponseWriter, r *http.Request, _ session) {
|
||||
}
|
||||
case r.PostFormValue("remove") != "":
|
||||
dev := r.PostFormValue("remove")
|
||||
for _, d := range s.form().devices(Interfaces(), prefs.VLANs) {
|
||||
for _, d := range a.devices(s, s.form()) {
|
||||
if d.Name == dev && !d.Removable {
|
||||
msg = dev + " is in use."
|
||||
}
|
||||
@@ -627,7 +723,7 @@ func (a *app) networkPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
f := s.form()
|
||||
f.LANs = append(f.LANs, LANForm{DHCP: true}) // a row to add a LAN
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(f)}
|
||||
p.Data = networkData{Form: f, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
a.render(w, "network", p)
|
||||
}
|
||||
|
||||
@@ -678,7 +774,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
f.WAN.PPPoEPassword = ""
|
||||
f.WAN.PasswordSet = s.form().WAN.PasswordSet
|
||||
p.Error = "Please correct the marked fields."
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(f)}
|
||||
p.Data = networkData{Form: f, Errors: fe, Interfaces: Interfaces(), Devices: a.devices(s, f)}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
a.render(w, "network", p)
|
||||
return
|
||||
@@ -701,6 +797,7 @@ func (a *app) network(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
type advancedData struct {
|
||||
Settings string
|
||||
PingTargets string
|
||||
Theme string
|
||||
}
|
||||
|
||||
func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
@@ -714,7 +811,7 @@ func (a *app) advancedPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
|
||||
func (a *app) advancedData(settings string) advancedData {
|
||||
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", ")}
|
||||
return advancedData{Settings: settings, PingTargets: strings.Join(a.sys.loadPrefs().PingTargets, ", "), Theme: a.sys.loadPrefs().Theme}
|
||||
}
|
||||
|
||||
func (a *app) advanced(w http.ResponseWriter, r *http.Request, sess session) {
|
||||
|
||||
Reference in New Issue
Block a user