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.") }