Add DHCP reservations to the web UI, split out a fast webui test
The DHCP page edits the reservations per LAN (checked like the module's assertions) and offers active clients for reservation. The web UI checks that need no installed system moved from apply.nix into webui.nix (one node, under a minute); apply.nix shares one apply for the pool change and a reservation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+12
-3
@@ -45,8 +45,17 @@ runs `router-apply` on the router (building a NixOS system there takes about a
|
|||||||
minute) or waits for a rollback timer. A source change rebuilds the systems
|
minute) or waits for a rollback timer. A source change rebuilds the systems
|
||||||
again on the host. So:
|
again on the host. So:
|
||||||
|
|
||||||
|
Measured (one at a time): `webui` 45 s, `basic` 107 s, `onestick` 77 s; the
|
||||||
|
ones that install and rebuild on the device are the slow ones: `apply` and
|
||||||
|
`update` several minutes, `setup` 11 minutes (three installs).
|
||||||
|
|
||||||
- Try a change with `webui-dev` or `fast` first; run a VM test only to confirm.
|
- Try a change with `webui-dev` or `fast` first; run a VM test only to confirm.
|
||||||
- Run only the test for what you changed: `update` for `router-update` and
|
- A change to the web UI only needs `webui` (a single node, no install).
|
||||||
the Update page, `setup` for `router-setup`, `apply` for the settings and
|
`apply`, `setup` and `update` are for what an installed router does:
|
||||||
the web UI, `basic` for the firewall and DHCP.
|
on-device builds, rollback, `router-setup`, `router-update`. Run them
|
||||||
|
before a release or when you touched those, not after every UI edit.
|
||||||
|
- Nix does not rerun a test whose inputs did not change, so `--all` after a
|
||||||
|
small change is cheaper than it looks, except that the web UI is part of
|
||||||
|
every installed system: any change to it invalidates `apply`, `setup`
|
||||||
|
and `update`.
|
||||||
- Use `-i` to iterate on a test script.
|
- Use `-i` to iterate on a test script.
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reservation is a fixed DHCP lease of a LAN, keyed by host name in the
|
||||||
|
// settings (the name is also published in DNS).
|
||||||
|
type Reservation struct {
|
||||||
|
LAN string
|
||||||
|
Name string
|
||||||
|
MAC string
|
||||||
|
Address string
|
||||||
|
Remove bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||||
|
macRe = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// dhcpLANs returns the LANs that serve DHCP (reservations need it), sorted.
|
||||||
|
func (s Settings) dhcpLANs() []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range s.form().LANs {
|
||||||
|
if l.DHCP {
|
||||||
|
out = append(out, l.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Settings) reservations() []Reservation {
|
||||||
|
router, _ := s["router"].(map[string]any)
|
||||||
|
lans, _ := router["lans"].(map[string]any)
|
||||||
|
var out []Reservation
|
||||||
|
for lan, v := range lans {
|
||||||
|
l, _ := v.(map[string]any)
|
||||||
|
dhcp, _ := l["dhcp"].(map[string]any)
|
||||||
|
res, _ := dhcp["reservations"].(map[string]any)
|
||||||
|
for name, rv := range res {
|
||||||
|
r, _ := rv.(map[string]any)
|
||||||
|
out = append(out, Reservation{LAN: lan, Name: name, MAC: str(r, "mac"), Address: str(r, "address")})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b Reservation) int {
|
||||||
|
if c := strings.Compare(a.LAN, b.LAN); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return strings.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyReservations replaces the reservations with rows (an empty row, the
|
||||||
|
// "add" row, is skipped). Everything else of the LANs stays as it is.
|
||||||
|
func (s Settings) applyReservations(rows []Reservation) error {
|
||||||
|
errs := FormErrors{}
|
||||||
|
router := obj(s, "router")
|
||||||
|
lans := obj(router, "lans")
|
||||||
|
|
||||||
|
type lanInfo struct {
|
||||||
|
prefix netip.Prefix
|
||||||
|
router netip.Addr
|
||||||
|
dhcp bool
|
||||||
|
}
|
||||||
|
info := map[string]lanInfo{}
|
||||||
|
for _, l := range s.form().LANs {
|
||||||
|
a, ok := ipv4(l.Address)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := 24
|
||||||
|
if n, ok, _ := optionalInt(l.Prefix, 8, 30); ok {
|
||||||
|
bits = n
|
||||||
|
}
|
||||||
|
info[l.Name] = lanInfo{prefix: netip.PrefixFrom(a, bits).Masked(), router: a, dhcp: l.DHCP}
|
||||||
|
}
|
||||||
|
|
||||||
|
names, macs, addrs := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||||
|
next := map[string]map[string]any{}
|
||||||
|
for i, r := range rows {
|
||||||
|
field := func(n string) string { return fmt.Sprintf("res[%d].%s", i, n) }
|
||||||
|
r.Name = strings.TrimSpace(r.Name)
|
||||||
|
r.MAC = strings.ToLower(strings.TrimSpace(r.MAC))
|
||||||
|
r.Address = strings.TrimSpace(r.Address)
|
||||||
|
if r.Name == "" && r.MAC == "" && r.Address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Remove {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
li, ok := info[r.LAN]
|
||||||
|
switch {
|
||||||
|
case !ok:
|
||||||
|
errs[field("lan")] = "choose a LAN"
|
||||||
|
case !li.dhcp:
|
||||||
|
errs[field("lan")] = "this LAN does not serve DHCP"
|
||||||
|
}
|
||||||
|
if !hostRe.MatchString(r.Name) {
|
||||||
|
errs[field("name")] = "letters, digits and dashes only"
|
||||||
|
} else if names[strings.ToLower(r.Name)] {
|
||||||
|
errs[field("name")] = "used twice"
|
||||||
|
}
|
||||||
|
names[strings.ToLower(r.Name)] = true
|
||||||
|
if !macRe.MatchString(r.MAC) {
|
||||||
|
errs[field("mac")] = "like aa:bb:cc:dd:ee:ff"
|
||||||
|
} else if macs[r.MAC] {
|
||||||
|
errs[field("mac")] = "used twice"
|
||||||
|
}
|
||||||
|
macs[r.MAC] = true
|
||||||
|
a, aok := ipv4(r.Address)
|
||||||
|
switch {
|
||||||
|
case !aok:
|
||||||
|
errs[field("address")] = "an IPv4 address is required"
|
||||||
|
case ok && !li.prefix.Contains(a):
|
||||||
|
errs[field("address")] = "not inside " + li.prefix.String()
|
||||||
|
case ok && a == li.router:
|
||||||
|
errs[field("address")] = "the router's own address"
|
||||||
|
case addrs[r.Address]:
|
||||||
|
errs[field("address")] = "used twice"
|
||||||
|
}
|
||||||
|
addrs[r.Address] = true
|
||||||
|
if next[r.LAN] == nil {
|
||||||
|
next[r.LAN] = map[string]any{}
|
||||||
|
}
|
||||||
|
next[r.LAN][r.Name] = map[string]any{"mac": r.MAC, "address": a.String()}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
for name, v := range lans {
|
||||||
|
l, _ := v.(map[string]any)
|
||||||
|
if l == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res := next[name]; len(res) > 0 {
|
||||||
|
obj(l, "dhcp")["reservations"] = res
|
||||||
|
} else if dhcp, ok := l["dhcp"].(map[string]any); ok {
|
||||||
|
delete(dhcp, "reservations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dhcpData struct {
|
||||||
|
Rows []Reservation
|
||||||
|
Errors FormErrors
|
||||||
|
LANs []string
|
||||||
|
Clients []Lease
|
||||||
|
}
|
||||||
|
|
||||||
|
// dhcpClients are the active leases that have no reservation yet.
|
||||||
|
func (a *app) dhcpClients(s Settings) []Lease {
|
||||||
|
if a.metrics == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reserved := map[string]bool{}
|
||||||
|
for _, r := range s.reservations() {
|
||||||
|
reserved[strings.ToLower(r.MAC)] = true
|
||||||
|
}
|
||||||
|
var out []Lease
|
||||||
|
for _, l := range readLeases(a.metrics.leasesPath, s.form().LANs, time.Now()) {
|
||||||
|
if !reserved[strings.ToLower(l.MAC)] {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) dhcpPage(w http.ResponseWriter, r *http.Request, sess session) {
|
||||||
|
p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true}
|
||||||
|
s, err := loadSettings(a.sys.settingsPath())
|
||||||
|
if err != nil {
|
||||||
|
p.Error = err.Error()
|
||||||
|
a.render(w, "dhcp", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := s.reservations()
|
||||||
|
// "Reserve" on a client prefills the row to add.
|
||||||
|
add := Reservation{LAN: r.URL.Query().Get("lan"), Name: r.URL.Query().Get("name"),
|
||||||
|
MAC: r.URL.Query().Get("mac"), Address: r.URL.Query().Get("address")}
|
||||||
|
p.Data = dhcpData{Rows: append(rows, add), LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)}
|
||||||
|
a.render(w, "dhcp", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) dhcp(w http.ResponseWriter, r *http.Request, sess session) {
|
||||||
|
p := page{Title: "DHCP", CSRF: sess.csrf, LoggedIn: true}
|
||||||
|
s, err := loadSettings(a.sys.settingsPath())
|
||||||
|
if err != nil {
|
||||||
|
p.Error = err.Error()
|
||||||
|
a.render(w, "dhcp", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||||
|
var rows []Reservation
|
||||||
|
for i := range min(n, 512) {
|
||||||
|
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("res.%d.%s", i, k)) }
|
||||||
|
rows = append(rows, Reservation{LAN: v("lan"), Name: v("name"), MAC: v("mac"), Address: v("address"), Remove: v("remove") == "on"})
|
||||||
|
}
|
||||||
|
if err := s.applyReservations(rows); err != nil {
|
||||||
|
var fe FormErrors
|
||||||
|
errors.As(err, &fe)
|
||||||
|
p.Error = "Please correct the marked fields."
|
||||||
|
p.Data = dhcpData{Rows: rows, Errors: fe, LANs: s.dhcpLANs(), Clients: a.dhcpClients(s)}
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
a.render(w, "dhcp", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||||
|
p.Error = "Saving: " + err.Error()
|
||||||
|
a.render(w, "dhcp", p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back(w, r, "Saved. Apply to make the changes take effect.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReservationsRoundTrip(t *testing.T) {
|
||||||
|
s := mustParse(t)
|
||||||
|
rows := s.reservations()
|
||||||
|
if len(rows) != 1 || rows[0] != (Reservation{LAN: "main", Name: "nas", MAC: "aa:bb:cc:dd:ee:ff", Address: "192.168.10.5"}) {
|
||||||
|
t.Fatalf("reservations = %+v", rows)
|
||||||
|
}
|
||||||
|
rows = append(rows, Reservation{LAN: "main", Name: "printer", MAC: "AA:BB:CC:00:11:22", Address: "192.168.10.6"},
|
||||||
|
Reservation{LAN: "main"}) // the empty add row
|
||||||
|
if err := s.applyReservations(rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := str(s, "router", "lans", "main", "dhcp", "reservations", "printer", "mac"); got != "aa:bb:cc:00:11:22" {
|
||||||
|
t.Errorf("mac = %q (should be lower case)", got)
|
||||||
|
}
|
||||||
|
// What else the LAN has survives.
|
||||||
|
out, _ := s.marshal()
|
||||||
|
for _, want := range []string{`"fd00::1/64"`, `"pool"`, `"wireguard"`} {
|
||||||
|
if !strings.Contains(string(out), want) {
|
||||||
|
t.Errorf("%s lost:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows[0].Remove = true
|
||||||
|
rows[1].Remove = true
|
||||||
|
if err := s.applyReservations(rows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s.reservations(); len(got) != 0 {
|
||||||
|
t.Errorf("not removed: %+v", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(mustMarshal(t, s)), `"pool"`) {
|
||||||
|
t.Error("the pool went with the reservations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshal(t *testing.T, s Settings) []byte {
|
||||||
|
t.Helper()
|
||||||
|
out, err := s.marshal()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReservationsAreChecked(t *testing.T) {
|
||||||
|
good := Reservation{LAN: "main", Name: "tv", MAC: "aa:bb:cc:dd:ee:01", Address: "192.168.10.7"}
|
||||||
|
for name, mod := range map[string]func(*Reservation){
|
||||||
|
"name": func(r *Reservation) { r.Name = "tv room" },
|
||||||
|
"mac": func(r *Reservation) { r.MAC = "aa:bb:cc" },
|
||||||
|
"address": func(r *Reservation) { r.Address = "192.168.11.7" },
|
||||||
|
"router": func(r *Reservation) { r.Address = "192.168.10.1" },
|
||||||
|
"lan": func(r *Reservation) { r.LAN = "nope" },
|
||||||
|
"dupmac": func(r *Reservation) { r.MAC = "AA:BB:CC:DD:EE:FF" },
|
||||||
|
"dupaddr": func(r *Reservation) { r.Address = "192.168.10.5" },
|
||||||
|
"dupname": func(r *Reservation) { r.Name = "NAS" },
|
||||||
|
} {
|
||||||
|
s := mustParse(t)
|
||||||
|
r := good
|
||||||
|
mod(&r)
|
||||||
|
rows := append(s.reservations(), r)
|
||||||
|
if err := s.applyReservations(rows); err == nil {
|
||||||
|
t.Errorf("%s: accepted %+v", name, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := mustParse(t)
|
||||||
|
if err := s.applyReservations(append(s.reservations(), good)); err != nil {
|
||||||
|
t.Errorf("a good reservation was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDHCPPage(t *testing.T) {
|
||||||
|
a, sys := newTestApp(t)
|
||||||
|
h := a.routes()
|
||||||
|
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
|
||||||
|
cookie := rec.Result().Cookies()[0]
|
||||||
|
|
||||||
|
body := do(h, "GET", "/dhcp?lan=main&mac=aa:bb:cc:00:00:09&address=192.168.10.9&name=cam", nil, cookie).Body.String()
|
||||||
|
for _, want := range []string{`value="nas"`, `value="aa:bb:cc:00:00:09"`, `value="cam"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("page lacks %s:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||||
|
|
||||||
|
// A lease of an unreserved client is offered for reservation.
|
||||||
|
os.WriteFile(filepath.Join(sys.StateDir, "leases"), []byte(
|
||||||
|
"address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n"+
|
||||||
|
"192.168.10.120,aa:bb:cc:00:00:20,,3600,4102444800,1,0,0,phone,0,,0\n"), 0o600)
|
||||||
|
if body := do(h, "GET", "/dhcp", nil, cookie).Body.String(); !strings.Contains(body, "aa:bb:cc:00:00:20") {
|
||||||
|
t.Errorf("the active client is not listed:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{"csrf": {token}, "rows": {"2"},
|
||||||
|
"res.0.lan": {"main"}, "res.0.name": {"nas"}, "res.0.mac": {"aa:bb:cc:dd:ee:ff"}, "res.0.address": {"192.168.10.5"},
|
||||||
|
"res.1.lan": {"main"}, "res.1.name": {"cam"}, "res.1.mac": {"aa:bb:cc:00:00:09"}, "res.1.address": {"192.168.10.300"}}
|
||||||
|
if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("bad address: %d", rec.Code)
|
||||||
|
}
|
||||||
|
form.Set("res.1.address", "192.168.10.9")
|
||||||
|
if rec := do(h, "POST", "/dhcp", form, cookie); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
s, _ := loadSettings(sys.settingsPath())
|
||||||
|
if str(s, "router", "lans", "main", "dhcp", "reservations", "cam", "address") != "192.168.10.9" {
|
||||||
|
t.Fatalf("not saved: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,7 +233,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
|||||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, page := range []string{"login", "overview", "network", "advanced", "update"} {
|
for _, page := range []string{"login", "overview", "network", "dhcp", "advanced", "update"} {
|
||||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -274,6 +274,8 @@ func (a *app) routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /network", a.auth(a.network))
|
mux.HandleFunc("POST /network", a.auth(a.network))
|
||||||
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
|
||||||
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
mux.HandleFunc("POST /advanced", a.auth(a.advanced))
|
||||||
|
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
||||||
|
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
|
||||||
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
||||||
mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
|
mux.HandleFunc("POST /prefs", a.auth(a.savePrefs))
|
||||||
mux.HandleFunc("POST /backup", a.auth(a.backup))
|
mux.HandleFunc("POST /backup", a.auth(a.backup))
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{with .Data}}
|
||||||
|
{{$e := .Errors}}
|
||||||
|
<form method="post" action="/dhcp">
|
||||||
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Reservations</legend>
|
||||||
|
{{if not .LANs}}<p class="field-error">No LAN serves DHCP; enable it on the Network page first.</p>{{end}}
|
||||||
|
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||||
|
<table>
|
||||||
|
<tr><th>LAN</th><th>Name</th><th>MAC</th><th>Address</th><th>Remove</th></tr>
|
||||||
|
{{range $i, $r := .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td><select name="res.{{$i}}.lan">
|
||||||
|
{{range $.Data.LANs}}<option value="{{.}}" {{if eq . $r.LAN}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select></td>
|
||||||
|
<td><input name="res.{{$i}}.name" value="{{$r.Name}}" size="14" placeholder="new"></td>
|
||||||
|
<td><input name="res.{{$i}}.mac" value="{{$r.MAC}}" size="17" spellcheck="false" placeholder="aa:bb:cc:dd:ee:ff"></td>
|
||||||
|
<td><input name="res.{{$i}}.address" value="{{$r.Address}}" size="13"></td>
|
||||||
|
<td>{{if $r.Name}}<input type="checkbox" name="res.{{$i}}.remove">{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{range $f := list "lan" "name" "mac" "address"}}{{with index $e (printf "res[%d].%s" $i $f)}}<tr><td colspan="5" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
<p class="hint">Fill in the last row to add one. The name is also sent to the client and published in DNS.
|
||||||
|
The address should be outside the pool.</p>
|
||||||
|
</fieldset>
|
||||||
|
<button>Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Clients without a reservation</h2>
|
||||||
|
{{if .Clients}}
|
||||||
|
<div class="scroll">
|
||||||
|
<table class="leases">
|
||||||
|
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th></th></tr>
|
||||||
|
{{range .Clients}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td>
|
||||||
|
<td><a href="/dhcp?lan={{.LAN | urlquery}}&name={{.Hostname | urlquery}}&mac={{.MAC | urlquery}}&address={{.Address | urlquery}}">Reserve</a></td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="muted">No active leases.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Overview</a>
|
<a href="/">Overview</a>
|
||||||
<a href="/network">Network</a>
|
<a href="/network">Network</a>
|
||||||
|
<a href="/dhcp">DHCP</a>
|
||||||
<a href="/update">Update</a>
|
<a href="/update">Update</a>
|
||||||
<a href="/advanced">Advanced</a>
|
<a href="/advanced">Advanced</a>
|
||||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
{{range $f := list "name" "interface" "vlan" "address" "prefix" "poolStart" "poolEnd"}}{{with index $e (printf "lans[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (reservations, zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
<p class="hint">Fill in the last row to add a LAN. Other LAN settings (zones, a fixed IPv6 address) are kept; edit them under Advanced.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button>Save</button>
|
<button>Save</button>
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
# and test_script() work; boot once, retry steps
|
# and test_script() work; boot once, retry steps
|
||||||
# router-test --list
|
# router-test --list
|
||||||
# Logs: ${TMPDIR:-/tmp}/router-test-logs/NAME.log
|
# Logs: ${TMPDIR:-/tmp}/router-test-logs/NAME.log
|
||||||
vm_tests="basic pppoe vlans onestick rollback dns installer apply setup update image wireguard"
|
vm_tests="webui basic pppoe vlans onestick rollback dns installer apply setup update image wireguard"
|
||||||
flake=${FLAKE:-.}
|
flake=${FLAKE:-.}
|
||||||
logs=${TMPDIR:-/tmp}/router-test-logs
|
logs=${TMPDIR:-/tmp}/router-test-logs
|
||||||
mkdir -p "$logs"
|
mkdir -p "$logs"
|
||||||
|
|||||||
+12
-22
@@ -5,7 +5,10 @@
|
|||||||
# - a changed setting is built offline, activated and confirmed,
|
# - a changed setting is built offline, activated and confirmed,
|
||||||
# - an unconfirmed change is rolled back,
|
# - an unconfirmed change is rolled back,
|
||||||
# - and the same through the web UI from a LAN client: discard the
|
# - and the same through the web UI from a LAN client: discard the
|
||||||
# unapplied change, edit the DHCP pool, apply, confirm.
|
# unapplied change, edit the DHCP pool and add a reservation, apply,
|
||||||
|
# confirm.
|
||||||
|
# The rest of the web UI (dashboard, backup, forms) is tested in webui.nix,
|
||||||
|
# which needs no installed system.
|
||||||
{ self, pkgs }:
|
{ self, pkgs }:
|
||||||
let
|
let
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
@@ -176,7 +179,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
assert "192.168.10.150" in target.succeed("cat /var/lib/router/config/settings.json")
|
assert "192.168.10.150" in target.succeed("cat /var/lib/router/config/settings.json")
|
||||||
assert "match the confirmed system" in curl(f"{ui}/")
|
assert "match the confirmed system" in curl(f"{ui}/")
|
||||||
|
|
||||||
with subtest("a change made in the web UI is applied and confirmed"):
|
with subtest("a pool change and a reservation made in the web UI are applied and confirmed"):
|
||||||
token = csrf("/network")
|
token = csrf("/network")
|
||||||
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
||||||
"csrf": token, "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp",
|
"csrf": token, "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp",
|
||||||
@@ -186,35 +189,22 @@ pkgs.testers.runNixOSTest {
|
|||||||
}.items())
|
}.items())
|
||||||
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
||||||
assert curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}/network") == "303"
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}/network") == "303"
|
||||||
|
# A reservation too: one apply for both changes.
|
||||||
|
reservation = " ".join(f"--data-urlencode '{k}={v}'" for k, v in {
|
||||||
|
"csrf": csrf("/dhcp"), "rows": "1", "res.0.lan": "main", "res.0.name": "printer",
|
||||||
|
"res.0.mac": "AA:BB:CC:00:11:22", "res.0.address": "192.168.10.60",
|
||||||
|
}.items())
|
||||||
|
assert curl(f"-o /dev/null -w '%{{http_code}}' {reservation} {ui}/dhcp") == "303"
|
||||||
curl(f"-d csrf={csrf('/')} {ui}/apply")
|
curl(f"-d csrf={csrf('/')} {ui}/apply")
|
||||||
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive"
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-apply.service) = inactive"
|
||||||
" && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600)
|
" && test $(systemctl show -p Result --value router-apply.service) = success", timeout=600)
|
||||||
assert pool_end() == "192.168.10.130"
|
assert pool_end() == "192.168.10.130"
|
||||||
|
target.succeed("grep -q 'aa:bb:cc:00:11:22' /etc/kea/dhcp4-server.conf && grep -q '192.168.10.60' /etc/kea/dhcp4-server.conf")
|
||||||
assert "Waiting for confirmation" in curl(f"{ui}/")
|
assert "Waiting for confirmation" in curl(f"{ui}/")
|
||||||
curl(f"-d csrf={csrf('/')} {ui}/confirm")
|
curl(f"-d csrf={csrf('/')} {ui}/confirm")
|
||||||
target.wait_until_fails("systemctl is-active router-rollback.timer", timeout=60)
|
target.wait_until_fails("systemctl is-active router-rollback.timer", timeout=60)
|
||||||
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
target.wait_until_succeeds("test $(systemctl show -p ActiveState --value router-confirm.service) = inactive")
|
||||||
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == current()
|
||||||
assert "match the confirmed system" in curl(f"{ui}/")
|
assert "match the confirmed system" in curl(f"{ui}/")
|
||||||
|
|
||||||
with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
|
|
||||||
curl(f"-d csrf={csrf('/advanced')} --data-urlencode 'pingTargets=gateway, 192.168.10.50' {ui}/prefs")
|
|
||||||
# The UI pings from its sandbox (raw socket); the client answers.
|
|
||||||
# (Into a file: grep -q would end curl with SIGPIPE, failing the pipe.)
|
|
||||||
client.wait_until_succeeds(
|
|
||||||
f"curl -sk -b /tmp/jar -o /tmp/dash '{ui}/dashboard?range=1h' && grep -q 'Ping 192.168.10.50</dt><dd>[0-9.]* ms' /tmp/dash",
|
|
||||||
timeout=60)
|
|
||||||
page = curl(f"{ui}/")
|
|
||||||
# No DHCP server on the uplink in this test.
|
|
||||||
for text in ["Not connected", "LAN main (eth2)", "Internet (eth1)", "Latency", "CPU and memory", "/static/dashboard.js"]:
|
|
||||||
assert text in page, f"{text!r} missing:\n{page}"
|
|
||||||
assert "<html" not in curl(f"'{ui}/dashboard?range=7d'")
|
|
||||||
client.succeed("curl -sfk -o /dev/null https://192.168.10.1/static/dashboard.js")
|
|
||||||
|
|
||||||
with subtest("the web UI hands out a backup of the router's state"):
|
|
||||||
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
|
|
||||||
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
|
||||||
for f in ["router/config/settings.json", "router/webui-password", "router/webui/cert.pem", "router/webui/dashboard.json"]:
|
|
||||||
assert f in files.split(), files
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
dns = import ./dns.nix { inherit self pkgs; };
|
dns = import ./dns.nix { inherit self pkgs; };
|
||||||
installer = import ./installer.nix { inherit self pkgs; };
|
installer = import ./installer.nix { inherit self pkgs; };
|
||||||
apply = import ./apply.nix { inherit self pkgs; };
|
apply = import ./apply.nix { inherit self pkgs; };
|
||||||
|
webui = import ./webui.nix { inherit self pkgs; };
|
||||||
setup = import ./setup.nix { inherit self pkgs; };
|
setup = import ./setup.nix { inherit self pkgs; };
|
||||||
update = import ./update.nix { inherit self pkgs; };
|
update = import ./update.nix { inherit self pkgs; };
|
||||||
image = import ./image.nix { inherit self pkgs; };
|
image = import ./image.nix { inherit self pkgs; };
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
# The web UI against a router's modules, without installing anything: one
|
||||||
|
# ordinary test node with the settings module, and a client on the LAN. What
|
||||||
|
# needs an installed system and on-device builds (apply, confirm, rollback)
|
||||||
|
# is tested in apply.nix; this one takes a minute instead of ten.
|
||||||
|
{ self, pkgs }:
|
||||||
|
let
|
||||||
|
common = import ./common.nix;
|
||||||
|
|
||||||
|
settings = pkgs.writeText "settings.json" (
|
||||||
|
builtins.toJSON {
|
||||||
|
system = {
|
||||||
|
hostName = "router";
|
||||||
|
stateVersion = "26.11";
|
||||||
|
};
|
||||||
|
router = {
|
||||||
|
wan.interface = "eth1";
|
||||||
|
lans.main = {
|
||||||
|
interface = "eth2";
|
||||||
|
internet = true;
|
||||||
|
ipv4.address = "192.168.10.1";
|
||||||
|
dhcp.pool = {
|
||||||
|
start = "192.168.10.100";
|
||||||
|
end = "192.168.10.199";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
in
|
||||||
|
pkgs.testers.runNixOSTest {
|
||||||
|
name = "router-webui";
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
router = {
|
||||||
|
imports = [
|
||||||
|
self.nixosModules.router
|
||||||
|
self.nixosModules.settings
|
||||||
|
];
|
||||||
|
routerSettings.file = settings;
|
||||||
|
virtualisation.vlans = [
|
||||||
|
1
|
||||||
|
2
|
||||||
|
];
|
||||||
|
# What the device has at /var/lib/router/config.
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /var/lib/router/config 0700 root root -"
|
||||||
|
"C /var/lib/router/config/settings.json 0600 root root - ${settings}"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
client =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [ common.tools ];
|
||||||
|
virtualisation.vlans = [ 2 ];
|
||||||
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||||
|
{
|
||||||
|
address = "192.168.10.50";
|
||||||
|
prefixLength = 24;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
ui = "https://192.168.10.1"
|
||||||
|
cfg = "/var/lib/router/config/settings.json"
|
||||||
|
|
||||||
|
def curl(args):
|
||||||
|
return client.succeed(f"curl -sk -b /tmp/jar -c /tmp/jar {args}")
|
||||||
|
|
||||||
|
def csrf(path):
|
||||||
|
return curl(f"{ui}{path} | grep -o 'name=\"csrf\" value=\"[^\"]*' | head -1 | cut -d'\"' -f4").strip()
|
||||||
|
|
||||||
|
def post(path, fields):
|
||||||
|
form = " ".join(f"--data-urlencode '{k}={v}'" for k, v in fields.items())
|
||||||
|
return curl(f"-o /dev/null -w '%{{http_code}}' {form} {ui}{path}")
|
||||||
|
|
||||||
|
start_all()
|
||||||
|
router.wait_for_unit("router-webui.service")
|
||||||
|
router.succeed("echo hunter2hunter2 | router-webui passwd /var/lib/router/webui-password")
|
||||||
|
router.succeed("systemctl restart router-webui.service")
|
||||||
|
router.wait_for_unit("router-webui.service")
|
||||||
|
|
||||||
|
with subtest("the web UI is reachable from the LAN and wants the password"):
|
||||||
|
client.wait_until_succeeds(f"curl -sk -o /dev/null {ui}/login", timeout=60)
|
||||||
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=wrong {ui}/login") == "401"
|
||||||
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -d password=hunter2hunter2 {ui}/login") == "303"
|
||||||
|
|
||||||
|
with subtest("the dashboard shows the uplink, the LAN and pings from the router"):
|
||||||
|
post("/prefs", {"csrf": csrf("/advanced"), "pingTargets": "gateway, 192.168.10.50"})
|
||||||
|
# The UI pings from its sandbox (raw socket); the client answers.
|
||||||
|
# (Into a file: grep -q would end curl with SIGPIPE, failing the pipe.)
|
||||||
|
client.wait_until_succeeds(
|
||||||
|
f"curl -sk -b /tmp/jar -o /tmp/dash '{ui}/dashboard?range=1h' && grep -q 'Ping 192.168.10.50</dt><dd>[0-9.]* ms' /tmp/dash",
|
||||||
|
timeout=60)
|
||||||
|
page = curl(f"{ui}/")
|
||||||
|
# No DHCP server on the uplink in this test.
|
||||||
|
for text in ["Not connected", "LAN main (eth2)", "Internet (eth1)", "Latency", "CPU and memory", "/static/dashboard.js"]:
|
||||||
|
assert text in page, f"{text!r} missing:\n{page}"
|
||||||
|
assert "<html" not in curl(f"'{ui}/dashboard?range=7d'")
|
||||||
|
client.succeed("curl -sfk -o /dev/null https://192.168.10.1/static/dashboard.js")
|
||||||
|
|
||||||
|
with subtest("a change saved in the web UI is shown as unapplied and can be discarded"):
|
||||||
|
router.succeed(f"cp {cfg} /var/lib/router/settings.confirmed.json")
|
||||||
|
assert "match the confirmed system" in curl(f"{ui}/")
|
||||||
|
fields = {
|
||||||
|
"csrf": csrf("/network"), "hostName": "router", "wan.interface": "eth1", "wan.type": "dhcp",
|
||||||
|
"lans": "1", "lan.0.name": "main", "lan.0.interface": "eth2", "lan.0.address": "192.168.10.1",
|
||||||
|
"lan.0.internet": "on", "lan.0.dhcp": "on",
|
||||||
|
"lan.0.poolStart": "192.168.10.100", "lan.0.poolEnd": "192.168.10.130",
|
||||||
|
}
|
||||||
|
assert curl(f"-o /dev/null -w '%{{http_code}}' -X POST {ui}/network") == "403"
|
||||||
|
assert post("/network", fields) == "303"
|
||||||
|
assert "192.168.10.130" in router.succeed(f"cat {cfg}")
|
||||||
|
assert "not part of the confirmed system" in curl(f"{ui}/")
|
||||||
|
post("/discard", {"csrf": csrf("/")})
|
||||||
|
assert "192.168.10.130" not in router.succeed(f"cat {cfg}")
|
||||||
|
assert "match the confirmed system" in curl(f"{ui}/")
|
||||||
|
|
||||||
|
with subtest("DHCP reservations are saved, checked and kept by the network form"):
|
||||||
|
res = {"rows": "1", "res.0.lan": "main", "res.0.name": "printer",
|
||||||
|
"res.0.mac": "AA:BB:CC:00:11:22", "res.0.address": "192.168.10.300"}
|
||||||
|
assert post("/dhcp", {"csrf": csrf("/dhcp"), **res}) == "422"
|
||||||
|
res["res.0.address"] = "192.168.10.50"
|
||||||
|
assert post("/dhcp", {"csrf": csrf("/dhcp"), **res}) == "303"
|
||||||
|
assert "printer" in curl(f"{ui}/dhcp")
|
||||||
|
saved = router.succeed(f"cat {cfg}")
|
||||||
|
assert '"aa:bb:cc:00:11:22"' in saved and '"192.168.10.50"' in saved, saved
|
||||||
|
# Saving the network form must not drop them.
|
||||||
|
assert post("/network", fields) == "303"
|
||||||
|
assert "printer" in router.succeed(f"cat {cfg}")
|
||||||
|
|
||||||
|
with subtest("the web UI hands out a backup of the router's state"):
|
||||||
|
curl(f"-sf -d csrf={csrf('/advanced')} -o /tmp/backup.tar.gz {ui}/backup")
|
||||||
|
files = client.succeed("tar -tzf /tmp/backup.tar.gz")
|
||||||
|
for f in ["router/config/settings.json", "router/webui-password", "router/webui/cert.pem", "router/webui/dashboard.json"]:
|
||||||
|
assert f in files.split(), files
|
||||||
|
'';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user