package main import ( "bytes" "encoding/json" "fmt" "maps" "net/netip" "os" "path/filepath" "regexp" "slices" "strconv" "strings" ) // Settings is settings.json kept generic: the forms change the keys they know // and everything else (WireGuard, port forwards, ...) survives untouched. type Settings map[string]any func loadSettings(path string) (Settings, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } return parseSettings(data) } func parseSettings(data []byte) (Settings, error) { var s Settings if err := json.Unmarshal(data, &s); err != nil { return nil, fmt.Errorf("settings are not valid JSON: %w", err) } if s == nil { return nil, fmt.Errorf("settings must be a JSON object") } return s, nil } func (s Settings) marshal() ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetIndent("", " ") enc.SetEscapeHTML(false) if err := enc.Encode(s); err != nil { return nil, err } return buf.Bytes(), nil } // writeFileAtomic replaces path so that readers never see a partial file. func writeFileAtomic(path string, data []byte, mode os.FileMode) error { tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Chmod(mode); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) } func (s Settings) save(path string) error { data, err := s.marshal() if err != nil { return err } return writeFileAtomic(path, data, 0o600) } // obj returns the object at key, creating it if needed. func obj(m map[string]any, key string) map[string]any { if v, ok := m[key].(map[string]any); ok { return v } v := map[string]any{} m[key] = v return v } func str(m map[string]any, keys ...string) string { for _, k := range keys[:len(keys)-1] { next, ok := m[k].(map[string]any) if !ok { return "" } m = next } switch v := m[keys[len(keys)-1]].(type) { case string: return v case float64: // as decoded from JSON return strconv.FormatFloat(v, 'f', -1, 64) case int: // as set by apply return strconv.Itoa(v) } return "" } func boolean(m map[string]any, def bool, keys ...string) bool { for _, k := range keys[:len(keys)-1] { next, ok := m[k].(map[string]any) if !ok { return def } m = next } if v, ok := m[keys[len(keys)-1]].(bool); ok { return v } return def } // Form is what the network page edits. type Form struct { HostName string Domain string // the local domain (router.dns.domain) WAN WANForm LANs []LANForm } type WANForm struct { Interface string // a device: "enp2s0", or "enp2s0.1001" for a VLAN Type string // "dhcp" or "pppoe" IPv6 bool PPPoEUser string PPPoEPassword string // write-only: empty keeps the stored one PasswordSet bool } type LANForm struct { Name string Interface string // a device: "eth2", or "eth2.20" for a VLAN Address string Prefix string Internet bool IPv6PD bool // delegate the ISP's prefix to this LAN DHCP bool PoolStart string PoolEnd string Remove bool } func (s Settings) form() Form { router, _ := s["router"].(map[string]any) if router == nil { router = map[string]any{} } f := Form{ HostName: str(s, "system", "hostName"), Domain: str(router, "dns", "domain"), WAN: WANForm{ Interface: str(router, "wan", "interface"), Type: str(router, "wan", "ipv4", "type"), IPv6: boolean(router, false, "wan", "ipv6", "enable"), PPPoEUser: str(router, "wan", "ipv4", "pppoe", "username"), }, } if vlan := str(router, "wan", "ipv4", "pppoe", "vlan"); vlan != "" && f.WAN.Type == "pppoe" { f.WAN.Interface += "." + vlan } if f.WAN.Type == "" { f.WAN.Type = "dhcp" } f.WAN.PasswordSet = str(router, "wan", "ipv4", "pppoe", "passwordFile") != "" lans, _ := router["lans"].(map[string]any) names := make([]string, 0, len(lans)) for name := range lans { names = append(names, name) } slices.Sort(names) for _, name := range names { l, _ := lans[name].(map[string]any) if l == nil { continue } dev := str(l, "interface") if vlan := str(l, "vlan"); vlan != "" { dev += "." + vlan } f.LANs = append(f.LANs, LANForm{ Name: name, Interface: dev, Address: str(l, "ipv4", "address"), Prefix: str(l, "ipv4", "prefixLength"), Internet: boolean(l, false, "internet"), IPv6PD: boolean(l, false, "ipv6", "pd"), DHCP: boolean(l, true, "dhcp", "enable"), PoolStart: str(l, "dhcp", "pool", "start"), PoolEnd: str(l, "dhcp", "pool", "end"), }) } return f } var ( nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) // A DNS name of up to 253 characters. domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) ifaceRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,15}$`) ) // FormErrors maps a field to what is wrong with it. type FormErrors map[string]string func (e FormErrors) Error() string { keys := make([]string, 0, len(e)) for k := range e { keys = append(keys, k) } slices.Sort(keys) var b strings.Builder for _, k := range keys { fmt.Fprintf(&b, "%s: %s; ", k, e[k]) } return strings.TrimSuffix(b.String(), "; ") } func optionalInt(v string, lo, hi int) (int, bool, error) { v = strings.TrimSpace(v) if v == "" { return 0, false, nil } n, err := strconv.Atoi(v) if err != nil || n < lo || n > hi { return 0, false, fmt.Errorf("must be a number from %d to %d", lo, hi) } return n, true, nil } // splitVLAN splits "enp2s0.1001" into the interface and its VLAN ID; a name // without one has VLAN 0. func splitVLAN(dev string) (string, int) { dev = strings.TrimSpace(dev) i := strings.LastIndex(dev, ".") if i <= 0 { return dev, 0 } id, err := strconv.Atoi(dev[i+1:]) if err != nil || id < 1 || id > 4094 { return dev, 0 } return dev[:i], id } // badVLAN reports a device name that looks like a VLAN (interface.digits) but // whose ID is not from 1 to 4094. func badVLAN(dev string) bool { i := strings.LastIndex(dev, ".") if i <= 0 { return false } if _, err := strconv.Atoi(dev[i+1:]); err != nil { return false } _, id := splitVLAN(dev) return id == 0 } // Device is a row of the interface list: a physical NIC or a VLAN on one. type Device struct { Name string Kind string // "physical" or "VLAN" Role string // "WAN", "LAN lan", or "" Removable bool // a VLAN defined in the UI that nothing uses } // devices lists the physical interfaces plus the VLAN devices the settings // use or the UI defined (defined), each with the role it plays. func (f Form) devices(physical, defined []string) []Device { roles := map[string][]string{} add := func(dev, role string) { roles[dev] = append(roles[dev], role) } add(f.WAN.Interface, "WAN") for _, l := range f.LANs { if l.Name == "" || l.Remove { continue } add(l.Interface, "LAN "+l.Name) } // Interfaces the settings or the defined VLANs refer to are listed even // when this machine lacks them (other hardware). names := slices.Clone(physical) var vlans, extra []string for _, dev := range append(slices.Collect(maps.Keys(roles)), defined...) { base, id := splitVLAN(dev) if id != 0 && !slices.Contains(vlans, dev) { vlans = append(vlans, dev) } if !slices.Contains(names, base) && !slices.Contains(extra, base) { extra = append(extra, base) } } slices.Sort(vlans) slices.Sort(extra) names = append(names, extra...) var out []Device for _, n := range names { out = append(out, Device{Name: n, Kind: "physical", Role: strings.Join(roles[n], ", ")}) for _, v := range vlans { if parent, _ := splitVLAN(v); parent == n { out = append(out, Device{Name: v, Kind: "VLAN", Role: strings.Join(roles[v], ", "), Removable: len(roles[v]) == 0 && slices.Contains(defined, v)}) } } } return out } func ipv4(v string) (netip.Addr, bool) { a, err := netip.ParseAddr(strings.TrimSpace(v)) return a, err == nil && a.Is4() } // apply writes the form into the settings. Only basic checks happen here; the // build on the device validates the rest (and the router's assertions). // It returns the PPPoE password to store, if one was entered. func (s Settings) apply(f Form, passwordFile string) (string, error) { errs := FormErrors{} system := obj(s, "system") router := obj(s, "router") wan := obj(router, "wan") if f.HostName = strings.TrimSpace(f.HostName); f.HostName != "" { if !regexp.MustCompile(`^[a-zA-Z0-9-]{1,63}$`).MatchString(f.HostName) { errs["hostName"] = "letters, digits and dashes only" } system["hostName"] = f.HostName } if f.Domain = strings.ToLower(strings.TrimSpace(f.Domain)); f.Domain == "" { if dns, ok := router["dns"].(map[string]any); ok { delete(dns, "domain") } } else if !domainRe.MatchString(f.Domain) { errs["domain"] = "letters, digits and dashes, in labels separated by dots (home.example.org)" } else { obj(router, "dns")["domain"] = f.Domain } // A VLAN device (enp2s0.1001) is the physical interface plus the // PPPoE session's VLAN. wanDev, wanVLAN := splitVLAN(f.WAN.Interface) if !ifaceRe.MatchString(f.WAN.Interface) { errs["wan.interface"] = "an interface name is required" } else if badVLAN(f.WAN.Interface) { errs["wan.interface"] = "the VLAN ID must be from 1 to 4094" } wan["interface"] = wanDev ipv4cfg := obj(wan, "ipv4") if wanVLAN != 0 && f.WAN.Type != "pppoe" { errs["wan.interface"] = "an uplink on a VLAN needs PPPoE" } switch f.WAN.Type { case "dhcp": ipv4cfg["type"] = "dhcp" case "pppoe": ipv4cfg["type"] = "pppoe" pppoe := obj(ipv4cfg, "pppoe") pppoe["username"] = strings.TrimSpace(f.WAN.PPPoEUser) if pppoe["username"] == "" { errs["wan.pppoe.username"] = "required for PPPoE" } if wanVLAN != 0 { pppoe["vlan"] = wanVLAN } else { delete(pppoe, "vlan") } if f.WAN.PPPoEPassword != "" { pppoe["passwordFile"] = passwordFile } else if str(pppoe, "passwordFile") == "" { errs["wan.pppoe.password"] = "required for PPPoE" } default: errs["wan.type"] = "must be dhcp or pppoe" } if f.WAN.IPv6 { v6 := obj(wan, "ipv6") v6["enable"] = true // PPPoE carries IPv6 inside the session; otherwise it is native. if f.WAN.Type == "pppoe" { v6["transport"] = "ppp" } else { v6["transport"] = "native" } } else if v6, ok := wan["ipv6"].(map[string]any); ok { v6["enable"] = false } lans := obj(router, "lans") keep := map[string]bool{} for i, lf := range f.LANs { field := func(n string) string { return fmt.Sprintf("lans[%d].%s", i, n) } lf.Name = strings.TrimSpace(lf.Name) if lf.Name == "" && lf.Interface == "" && lf.Address == "" { continue // the empty "add a LAN" row } if !nameRe.MatchString(lf.Name) { errs[field("name")] = "lowercase letters, digits and dashes, starting with a letter" continue } if keep[lf.Name] { errs[field("name")] = "used twice" continue } if lf.Remove { continue } keep[lf.Name] = true l := obj(lans, lf.Name) if !ifaceRe.MatchString(lf.Interface) { errs[field("interface")] = "an interface name is required" } else if badVLAN(lf.Interface) { errs[field("interface")] = "the VLAN ID must be from 1 to 4094" } lanDev, lanVLAN := splitVLAN(lf.Interface) l["interface"] = lanDev if lanVLAN != 0 { l["vlan"] = lanVLAN } else { delete(l, "vlan") } l["internet"] = lf.Internet if lf.IPv6PD { obj(l, "ipv6")["pd"] = true } else if v6, ok := l["ipv6"].(map[string]any); ok { delete(v6, "pd") if len(v6) == 0 { delete(l, "ipv6") } } v4 := obj(l, "ipv4") if _, ok := ipv4(lf.Address); !ok { errs[field("address")] = "an IPv4 address is required" } v4["address"] = strings.TrimSpace(lf.Address) if p, ok, err := optionalInt(lf.Prefix, 8, 30); err != nil { errs[field("prefix")] = err.Error() } else if ok { v4["prefixLength"] = p } else { delete(v4, "prefixLength") } dhcp := obj(l, "dhcp") dhcp["enable"] = lf.DHCP if lf.DHCP { start, ok1 := ipv4(lf.PoolStart) end, ok2 := ipv4(lf.PoolEnd) if !ok1 { errs[field("poolStart")] = "an IPv4 address is required" } if !ok2 { errs[field("poolEnd")] = "an IPv4 address is required" } if ok1 && ok2 && end.Less(start) { errs[field("poolEnd")] = "must not be before the start" } pool := obj(dhcp, "pool") pool["start"] = strings.TrimSpace(lf.PoolStart) pool["end"] = strings.TrimSpace(lf.PoolEnd) } } for name := range lans { if !keep[name] { delete(lans, name) } } if len(keep) == 0 { errs["lans"] = "at least one LAN is required" } if len(errs) > 0 { return "", errs } return f.WAN.PPPoEPassword, nil }