diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index c4cd2ce..123378c 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -142,7 +142,7 @@ let ); wgs = lib.attrValues cfg.wireguard; - zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs); + zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs ++ cfg.firewall.zones); isV6 = lib.hasInfix ":"; v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null; @@ -227,7 +227,11 @@ let message = "router: ${where}: ports are only valid with protocol tcp or udp."; } { - assertion = lib.all portOk r.ports; + assertion = r.sourcePorts == [ ] || ((r.protocol == "tcp" || r.protocol == "udp") && !r.dnat); + message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward."; + } + { + assertion = lib.all portOk (r.ports ++ r.sourcePorts); message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high)."; } { diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 2cf57fc..857274e 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -62,15 +62,17 @@ let setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }"; - # Layer 4 match; `ports` may be ints or "a-b" ranges. + # Layer 4 match; `ports` and `sports` may be ints or "a-b" ranges. l4 = - proto: ports: + proto: ports: sports: if proto == "any" then "" else if proto == "icmp" then "meta l4proto { icmp, ipv6-icmp } " else - "${proto} dport ${if ports == [ ] then "1-65535" else setOf ports} "; + "${lib.optionalString (sports != [ ]) "${proto} sport ${setOf sports} "}${proto} dport ${ + if ports == [ ] then "1-65535" else setOf ports + } "; # Destination match, by family of the address. daddrOf = @@ -121,7 +123,7 @@ let toDevs = zoneDevices r.to; # After DNAT the packet carries the translated port. fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports; - base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts}${lib.optionalString r.dnat "ct status dnat "}"; + base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts r.sourcePorts}${lib.optionalString r.dnat "ct status dnat "}"; logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " ''; limitLines = v: @@ -150,19 +152,22 @@ let }"; in { - forward = lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r); + # A zone nothing is assigned to yet has no devices; the rule waits for one. + forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) ( + lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r) + ); prerouting = lib.optionals r.dnat ( - map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports}${dnatTarget} # ${label}") ( + map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports [ ]}${dnatTarget} # ${label}") ( variantsOf r ) ) # NAT loopback: internal clients addressing the router's public address. ++ lib.optional (r.dnat && r.hairpin) - "iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports}${dnatTarget} # hairpin ${label}"; - hairpinForward = lib.optional (r.dnat && r.hairpin) ( - "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts}ct status dnat accept # hairpin ${label}" + "iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports [ ]}${dnatTarget} # hairpin ${label}"; + hairpinForward = lib.optional (r.dnat && r.hairpin && toDevs != [ ]) ( + "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts [ ]}ct status dnat accept # hairpin ${label}" ); }; diff --git a/modules/router/options.nix b/modules/router/options.nix index 4162949..d607470 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -382,6 +382,17 @@ in ); }; + firewall.zones = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "guests" ]; + description = '' + Zones to define besides those of the LANs and tunnels (`zone` there + assigns them), so rules can name them before anything is assigned. A + zone without members matches nothing. + ''; + }; + firewall.rules = mkOption { default = [ ]; description = '' @@ -426,6 +437,12 @@ in public address. ''; }; + sourcePorts = mkOption { + type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+")); + default = [ ]; + example = [ 53 ]; + description = "Source ports or ranges (tcp/udp only); empty means any. Not for port forwards."; + }; destination = mkOption { type = types.nullOr types.str; default = null; diff --git a/pkgs/router-webui/collect.go b/pkgs/router-webui/collect.go index 4f85d71..8cb2fe5 100644 --- a/pkgs/router-webui/collect.go +++ b/pkgs/router-webui/collect.go @@ -70,7 +70,8 @@ type SystemStatus struct { CPU float64 // percent; NaN until the second sample MemUsed uint64 MemTotal uint64 - Temperature float64 // °C, NaN if no sensor + Temperature float64 // °C, the hottest sensor; NaN if there is none + Sensors []Sensor DiskUsed uint64 DiskTotal uint64 } @@ -166,6 +167,9 @@ func (c *Collector) sample(now time.Time) { c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal)) } c.store.Add("sys:temp", now, sys.Temperature) + for _, t := range sys.Sensors { + c.store.Add("temp:"+t.Key, now, t.Temp) + } up := uplinkStatus(f.WAN) <-done @@ -241,7 +245,8 @@ func cpuTimes() (busy, total uint64) { } func systemStatus() SystemStatus { - s := SystemStatus{Temperature: temperature()} + s := SystemStatus{Sensors: sensors()} + s.Temperature = hottest(s.Sensors) if data, err := os.ReadFile("/proc/uptime"); err == nil { if f := strings.Fields(string(data)); len(f) > 0 { secs, _ := strconv.ParseFloat(f[0], 64) @@ -277,23 +282,73 @@ func systemStatus() SystemStatus { return s } -// temperature is the hottest thermal zone, or hwmon sensor if there is none. -func temperature() float64 { - hottest := math.NaN() - for _, pattern := range []string{"/sys/class/thermal/thermal_zone*/temp", "/sys/class/hwmon/hwmon*/temp*_input"} { - files, _ := filepath.Glob(pattern) - for _, f := range files { - if v, err := readUint(f); err == nil && v > 0 && v < 150000 { - if t := float64(v) / 1000; math.IsNaN(hottest) || t > hottest { - hottest = t - } - } - } - if !math.IsNaN(hottest) { - break +// Sensor is one temperature the machine reports. +type Sensor struct { + Key string // stable name of its series + Label string + Temp float64 // °C +} + +// sensors lists the thermal zones and hwmon temperature inputs, sorted by +// label. Two sensors with the same name get a number. +func sensors() []Sensor { + return readSensors("/sys/class/thermal", "/sys/class/hwmon") +} + +func readSensors(thermalDir, hwmonDir string) []Sensor { + var out []Sensor + add := func(label, file string) { + if v, err := readUint(file); err == nil && v > 0 && v < 150000 { + out = append(out, Sensor{Label: label, Temp: float64(v) / 1000}) } } - return hottest + text := func(file string) string { + data, _ := os.ReadFile(file) + return strings.TrimSpace(string(data)) + } + zones, _ := filepath.Glob(filepath.Join(thermalDir, "thermal_zone*")) + for _, z := range zones { + label := text(filepath.Join(z, "type")) + if label == "" { + label = filepath.Base(z) + } + add(label, filepath.Join(z, "temp")) + } + inputs, _ := filepath.Glob(filepath.Join(hwmonDir, "hwmon*", "temp*_input")) + for _, in := range inputs { + dir, base := filepath.Split(in) + chip := text(filepath.Join(dir, "name")) + if chip == "" { + chip = filepath.Base(dir) + } + name := strings.TrimSuffix(base, "_input") + sub := text(filepath.Join(dir, name+"_label")) + if sub == "" { + sub = name + } + add(chip+" "+sub, in) + } + slices.SortStableFunc(out, func(a, b Sensor) int { return strings.Compare(a.Label, b.Label) }) + count := map[string]int{} + for i := range out { + base := out[i].Label + count[base]++ + if n := count[base]; n > 1 { + out[i].Label = fmt.Sprintf("%s %d", base, n) + } + out[i].Key = out[i].Label + } + return out +} + +func hottest(sensors []Sensor) float64 { + h := math.NaN() + for _, t := range sensors { + if math.IsNaN(h) || t.Temp > h { + h = t.Temp + } + } + return h } // uplinkDevice is where the router's IPv4 internet traffic leaves. diff --git a/pkgs/router-webui/dashboard.go b/pkgs/router-webui/dashboard.go index 1660b25..0efaada 100644 --- a/pkgs/router-webui/dashboard.go +++ b/pkgs/router-webui/dashboard.go @@ -120,14 +120,18 @@ type seriesSpec struct { Scale float64 // e.g. 8 for bytes/s -> bit/s } -func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesSpec) Chart { +func (a *app) chart(title, unit string, rg timeRange, now time.Time, specs []seriesSpec) Chart { c := Chart{Title: title, Unit: unit} var start time.Time var step time.Duration cols := make([][]float64, len(specs)) ymax := 0.0 for i, sp := range specs { - st, stp, vals := a.metrics.store.Read(sp.Name, tier, now) + st, stp, vals := a.metrics.store.Read(sp.Name, rg.Tier, now) + if keep := rg.slots(); keep < len(vals) { + st = st.Add(stp * time.Duration(len(vals)-keep)) + vals = vals[len(vals)-keep:] + } start, step = st, stp cols[i] = downsample(vals, chartCols, sp.Scale) for _, v := range cols[i] { @@ -137,7 +141,7 @@ func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesS } } n := chartCols - span := step * time.Duration(tiers[tier].Slots) + span := step * time.Duration(rg.slots()) colDur := span / time.Duration(n) if unit == "%" { ymax = math.Max(ymax, 1) @@ -148,7 +152,7 @@ func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesS ymax = niceCeil(ymax) c.Empty = true for i, sp := range specs { - line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i), Now: "–"} + line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i%10), Now: "–"} var path strings.Builder pen := false for j, v := range cols[i] { @@ -316,18 +320,18 @@ type dashboard struct { } func (a *app) dashboardData(rangeName string) dashboard { - tier, rangeName := rangeTier(rangeName) + rg := findRange(rangeName) now := time.Now() s, _ := loadSettings(a.sys.settingsPath()) f := s.form() - d := dashboard{Range: rangeName, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()} + d := dashboard{Range: rg.Name, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()} // Newest first, the last few. ev := d.Snap.Uplink.Events d.Events = make([]Event, 0, 10) for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- { d.Events = append(d.Events, ev[i]) } - if tier > 0 { + if rg.Tier > 0 { d.Interval = 60 } for _, r := range ranges { @@ -336,7 +340,7 @@ func (a *app) dashboardData(rangeName string) dashboard { d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now) traffic := func(title, dev string) Chart { - return a.chart(title, "bit/s", tier, now, []seriesSpec{ + return a.chart(title, "bit/s", rg, now, []seriesSpec{ {"if:" + dev + ":rx", "down", 8}, {"if:" + dev + ":tx", "up", 8}, }) @@ -359,12 +363,16 @@ func (a *app) dashboardData(rangeName string) dashboard { loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1}) } if len(rtt) > 0 { - d.Charts = append(d.Charts, a.chart("Latency", "ms", tier, now, rtt), a.chart("Packet loss", "%", tier, now, loss)) + d.Charts = append(d.Charts, a.chart("Latency", "ms", rg, now, rtt), a.chart("Packet loss", "%", rg, now, loss)) } d.Charts = append(d.Charts, - a.chart("CPU and memory", "%", tier, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}})) - if temp := a.chart("Temperature", "°C", tier, now, []seriesSpec{{"sys:temp", "hottest sensor", 1}}); !temp.Empty || !math.IsNaN(d.Snap.System.Temperature) { - d.Charts = append(d.Charts, temp) + a.chart("CPU and memory", "%", rg, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}})) + var temps []seriesSpec + for _, t := range d.Snap.System.Sensors { + temps = append(temps, seriesSpec{"temp:" + t.Key, t.Label, 1}) + } + if len(temps) > 0 { + d.Charts = append(d.Charts, a.chart("Temperature", "°C", rg, now, temps)) } return d } diff --git a/pkgs/router-webui/firewall.go b/pkgs/router-webui/firewall.go index 4c37f34..c75ebb9 100644 --- a/pkgs/router-webui/firewall.go +++ b/pkgs/router-webui/firewall.go @@ -12,42 +12,76 @@ import ( ) // FirewallRule is an allow rule between two zones, as the page edits it: -// from, to, protocol, ports and destination. Rules with more (port forwards, -// sources, rate limits, logging) are kept as they are and only listed. +// zones, addresses and ports on both sides, and the protocol. Rules with more +// (port forwards, rate limits, logging) are kept as they are and only listed. type FirewallRule struct { - From string - To string + From string // zone + Sources string // addresses or CIDRs, empty: any + SourcePorts string + To string // zone + Destination string // one address or CIDR, empty: the whole zone + Ports string // destination ports Protocol string - Ports string - Destination string Remove bool } +// ZoneMember is a LAN or a tunnel; its zone is what the firewall sees. +type ZoneMember struct { + Kind string // "lan" or "wireguard" + Name string + Device string + Zone string +} + var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`) // simpleRuleKeys are the settings keys the page edits. -var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "destination"} +var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources"} -// zones returns the firewall zones of the LANs and tunnels, sorted: a zone is -// named after its LAN or tunnel unless it says otherwise. -func (s Settings) zones() []string { +// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone +// named after itself unless it says otherwise. +func (s Settings) zoneMembers() []ZoneMember { router, _ := s["router"].(map[string]any) - var out []string - for _, kind := range []string{"lans", "wireguard"} { - m, _ := router[kind].(map[string]any) - for name, v := range m { - member, _ := v.(map[string]any) - zone := str(member, "zone") - if zone == "" { - zone = name - } - if !slices.Contains(out, zone) { - out = append(out, zone) - } - } + var out []ZoneMember + lans := s.form().LANs + for _, l := range lans { + out = append(out, ZoneMember{Kind: "lan", Name: l.Name, Device: l.Interface, Zone: orName(str(router, "lans", l.Name, "zone"), l.Name)}) + } + wgs, _ := router["wireguard"].(map[string]any) + names := make([]string, 0, len(wgs)) + for n := range wgs { + names = append(names, n) + } + slices.Sort(names) + for _, n := range names { + dev := orName(str(router, "wireguard", n, "interface"), "wg-"+n) + out = append(out, ZoneMember{Kind: "wireguard", Name: n, Device: dev, Zone: orName(str(router, "wireguard", n, "zone"), n)}) + } + return out +} + +func orName(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +// declaredZones are the zones listed in the settings (they may be empty). +func (s Settings) declaredZones() []string { + router, _ := s["router"].(map[string]any) + fw, _ := router["firewall"].(map[string]any) + return stringList(fw, "zones") +} + +// zones returns every firewall zone, sorted. +func (s Settings) zones() []string { + out := slices.Clone(s.declaredZones()) + for _, m := range s.zoneMembers() { + out = append(out, m.Zone) } slices.Sort(out) - return out + return slices.Compact(out) } func ruleSimple(r map[string]any) bool { @@ -94,6 +128,9 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) { if d := str(r, "destination"); d != "" { desc += " " + d } + if d := strings.Join(stringList(r, "sources"), ", "); d != "" { + desc += " from " + d + } if p := str(r, "protocol"); p != "" && p != "any" { desc += " " + p } @@ -111,26 +148,133 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) { proto = "any" } simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto, - Ports: portsText(r["ports"]), Destination: str(r, "destination")}) + Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), + Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination")}) } return simple, other } -// applyFirewall replaces the rules the page edits with rows (an untouched -// "add" row is skipped); the other rules stay as they are. -func (s Settings) applyFirewall(rows []FirewallRule) error { +// ZoneForm is what the page changes about the zones: the zone of each member, +// zones to add (separated by commas or spaces) and empty zones to drop. +type ZoneForm struct { + Members []ZoneMember + New string + Remove []string +} + +// portList parses "22, 443 5000-5100" into settings values (numbers, and +// strings for ranges). +func portList(v string) ([]any, string) { + var ports []any + for _, p := range splitList(v) { + if !portRangeRe.MatchString(p) { + return nil, "numbers or ranges like 5000-5100, separated by commas" + } + lo, hi, _ := strings.Cut(p, "-") + l, _ := strconv.Atoi(lo) + h := l + if hi != "" { + h, _ = strconv.Atoi(hi) + } + if l < 1 || h > 65535 || (hi != "" && h <= l) { + return nil, p + " is not a valid port or range" + } + if hi == "" { + ports = append(ports, l) + } else { + ports = append(ports, p) + } + } + return ports, "" +} + +func validAddress(v string) bool { + if _, err := netip.ParsePrefix(v); err == nil { + return true + } + _, err := netip.ParseAddr(v) + return err == nil +} + +// applyZones assigns the members' zones and declares or drops zones. Errors +// are keyed "member[i]" and "newzone". +func (s Settings) applyZones(zf ZoneForm, errs FormErrors) { + router := obj(s, "router") + used := map[string]bool{} + for i, m := range zf.Members { + m.Zone = strings.TrimSpace(m.Zone) + switch { + case !nameRe.MatchString(m.Zone): + errs[fmt.Sprintf("member[%d]", i)] = "letters, digits and dashes, starting with a letter" + continue + case m.Zone == "wan": + errs[fmt.Sprintf("member[%d]", i)] = "wan is the uplink's zone and cannot be assigned" + continue + } + used[m.Zone] = true + kind := map[string]string{"lan": "lans", "wireguard": "wireguard"}[m.Kind] + members, _ := router[kind].(map[string]any) + v, _ := members[m.Name].(map[string]any) + if v == nil { + continue // vanished since the page was drawn + } + if m.Zone == m.Name { + delete(v, "zone") + } else { + v["zone"] = m.Zone + } + } + declared := slices.Clone(s.declaredZones()) + for _, z := range splitList(zf.New) { + switch { + case !nameRe.MatchString(z) || z == "wan": + errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan is taken)" + case !slices.Contains(declared, z): + declared = append(declared, z) + } + } + declared = slices.DeleteFunc(declared, func(z string) bool { + if !slices.Contains(zf.Remove, z) { + return false + } + if used[z] { + errs["zone."+z] = "still has members" + return false + } + return true + }) + // What a member already names needs no declaration. + declared = slices.DeleteFunc(declared, func(z string) bool { return used[z] }) + slices.Sort(declared) + fw := obj(router, "firewall") + if len(declared) == 0 { + delete(fw, "zones") + } else { + list := make([]any, len(declared)) + for i, z := range declared { + list[i] = z + } + fw["zones"] = list + } +} + +// applyFirewall changes the zones, then replaces the rules the page edits with +// rows (an untouched "add" row is skipped); the other rules stay as they are. +func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule) error { errs := FormErrors{} + s.applyZones(zf, errs) zones := s.zones() var next []any seen := map[FirewallRule]bool{} for i, r := range rows { field := func(n string) string { return fmt.Sprintf("rule[%d].%s", i, n) } r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To) - r.Ports, r.Destination = strings.Join(splitList(r.Ports), ", "), strings.TrimSpace(r.Destination) + r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ") + r.Sources, r.Destination = strings.Join(splitList(r.Sources), ", "), strings.TrimSpace(r.Destination) if r.Protocol == "" { r.Protocol = "any" } - if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.Destination == "") { + if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") { continue } if !slices.Contains(zones, r.From) { @@ -145,37 +289,27 @@ func (s Settings) applyFirewall(rows []FirewallRule) error { if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) { errs[field("protocol")] = "any, tcp, udp or icmp" } - var ports []any - for _, p := range splitList(r.Ports) { - if !portRangeRe.MatchString(p) { - errs[field("ports")] = "numbers or ranges like 5000-5100, separated by commas" - break - } - lo, hi, _ := strings.Cut(p, "-") - l, _ := strconv.Atoi(lo) - h := l - if hi != "" { - h, _ = strconv.Atoi(hi) - } - if l < 1 || h > 65535 || h < l { - errs[field("ports")] = p + " is not a valid port or range" - break - } - if hi == "" { - ports = append(ports, l) - } else { - ports = append(ports, p) - } + ports, msg := portList(r.Ports) + if msg != "" { + errs[field("ports")] = msg } - if len(ports) > 0 && r.Protocol != "tcp" && r.Protocol != "udp" { + sports, msg := portList(r.SourcePorts) + if msg != "" { + errs[field("sourcePorts")] = msg + } + if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" { errs[field("ports")] = "ports need protocol tcp or udp" } - if r.Destination != "" { - if _, err := netip.ParsePrefix(r.Destination); err != nil { - if _, err := netip.ParseAddr(r.Destination); err != nil { - errs[field("destination")] = "an address or CIDR, or empty for the whole zone" - } + var sources []any + for _, a := range splitList(r.Sources) { + if !validAddress(a) { + errs[field("sources")] = a + " is not an address or CIDR" + break } + sources = append(sources, a) + } + if r.Destination != "" && !validAddress(r.Destination) { + errs[field("destination")] = "one address or CIDR, or empty for the whole zone" } if seen[r] { errs[field("from")] = "the same rule twice" @@ -188,38 +322,70 @@ func (s Settings) applyFirewall(rows []FirewallRule) error { if len(ports) > 0 { rule["ports"] = ports } + if len(sports) > 0 { + rule["sourcePorts"] = sports + } + if len(sources) > 0 { + rule["sources"] = sources + } if r.Destination != "" { rule["destination"] = r.Destination } next = append(next, rule) } - if len(errs) > 0 { - return errs - } router := obj(s, "router") fw := obj(router, "firewall") old, _ := fw["rules"].([]any) for _, v := range old { - if r, _ := v.(map[string]any); r != nil && !ruleSimple(r) { - next = append(next, v) + r, _ := v.(map[string]any) + if r == nil || ruleSimple(r) { + continue } + // Port forwards and the like are not edited here, but must keep a zone to point at. + if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) { + errs["zone."+f] = "a rule that is not editable here still uses this zone" + } + if t := str(r, "to"); !slices.Contains(zones, t) { + errs["zone."+t] = "a rule that is not editable here still uses this zone" + } + next = append(next, v) + } + if len(errs) > 0 { + return errs } if len(next) == 0 { delete(fw, "rules") - if len(fw) == 0 { - delete(router, "firewall") - } - return nil + } else { + fw["rules"] = next + } + if len(fw) == 0 { + delete(router, "firewall") } - fw["rules"] = next return nil } type firewallData struct { - Rows []FirewallRule - Other []string - Zones []string - Errors FormErrors + Rows []FirewallRule + Other []string + Zones []string + Members []ZoneMember + Empty []string // zones without members, which can be removed + New string + Errors FormErrors +} + +func (s Settings) firewallData(rows []FirewallRule, members []ZoneMember, errs FormErrors) firewallData { + _, other := s.firewallRules() + d := firewallData{Rows: rows, Other: other, Zones: s.zones(), Members: members, Errors: errs} + if d.Members == nil { + d.Members = s.zoneMembers() + } + for _, z := range s.declaredZones() { + if !slices.ContainsFunc(s.zoneMembers(), func(m ZoneMember) bool { return m.Zone == z }) { + d.Empty = append(d.Empty, z) + } + } + return d } func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) { @@ -230,9 +396,9 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) a.render(w, "firewall", p) return } - rows, other := s.firewallRules() + rows, _ := s.firewallRules() add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"} - p.Data = firewallData{Rows: append(rows, add), Other: other, Zones: s.zones()} + p.Data = s.firewallData(append(rows, add), nil, nil) a.render(w, "firewall", p) } @@ -244,19 +410,28 @@ func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) { a.render(w, "firewall", p) return } + zf := ZoneForm{New: r.PostFormValue("newzones"), Remove: r.PostForm["rmzone"]} + nm, _ := strconv.Atoi(r.PostFormValue("members")) + for i := range min(nm, 512) { + v := func(k string) string { return r.PostFormValue(fmt.Sprintf("member.%d.%s", i, k)) } + zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")}) + } n, _ := strconv.Atoi(r.PostFormValue("rows")) var rows []FirewallRule for i := range min(n, 512) { v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) } - rows = append(rows, FirewallRule{From: v("from"), To: v("to"), Protocol: v("protocol"), - Ports: v("ports"), Destination: v("destination"), Remove: v("remove") == "on"}) + rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"), + To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"), Remove: v("remove") == "on"}) } - if err := s.applyFirewall(rows); err != nil { + if err := s.applyFirewall(zf, rows); err != nil { var fe FormErrors errors.As(err, &fe) - _, other := s.firewallRules() + // Show what was entered; the zone lists are those before this attempt. + s, _ = loadSettings(a.sys.settingsPath()) p.Error = "Please correct the marked fields." - p.Data = firewallData{Rows: rows, Other: other, Zones: s.zones(), Errors: fe} + d := s.firewallData(rows, zf.Members, fe) + d.New = zf.New + p.Data = d w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "firewall", p) return diff --git a/pkgs/router-webui/firewall_test.go b/pkgs/router-webui/firewall_test.go index 22cb031..21950e3 100644 --- a/pkgs/router-webui/firewall_test.go +++ b/pkgs/router-webui/firewall_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" "testing" + "time" ) func TestFirewallRulesRoundTrip(t *testing.T) { @@ -21,7 +22,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) { if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") { t.Fatalf("rows=%+v other=%v", rows, other) } - err := s.applyFirewall([]FirewallRule{ + err := s.applyFirewall(ZoneForm{}, []FirewallRule{ {From: "main", To: "vpn", Protocol: "any"}, {From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"}, {Protocol: "any"}, // the empty add row @@ -43,13 +44,13 @@ func TestFirewallRulesRoundTrip(t *testing.T) { } } rows[0].Remove, rows[1].Remove = true, true - if err := s.applyFirewall(rows); err != nil { + if err := s.applyFirewall(ZoneForm{}, rows); err != nil { t.Fatal(err) } if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 { t.Errorf("rows=%+v other=%v", rows, other) } - if err := s.applyFirewall(nil); err != nil { + if err := s.applyFirewall(ZoneForm{}, nil); err != nil { t.Fatal(err) } if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok { @@ -68,7 +69,7 @@ func TestFirewallRulesAreChecked(t *testing.T) { } { r := good mod(&r) - err := mustParse(t).applyFirewall([]FirewallRule{r}) + err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}) fe, ok := err.(FormErrors) if !ok || fe["rule[0]."+name] == "" { t.Errorf("%s: err = %v", name, err) @@ -76,10 +77,10 @@ func TestFirewallRulesAreChecked(t *testing.T) { } r := good r.Protocol = "any" - if err := mustParse(t).applyFirewall([]FirewallRule{r}); err == nil { + if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}); err == nil { t.Error("ports without tcp/udp accepted") } - if err := mustParse(t).applyFirewall([]FirewallRule{good, good}); err == nil { + if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{good, good}); err == nil { t.Error("the same rule twice accepted") } } @@ -106,3 +107,125 @@ func TestFirewallPage(t *testing.T) { t.Fatalf("not saved: %v", s) } } + +func TestFirewallAddressesAndSourcePorts(t *testing.T) { + s := mustParse(t) + err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp", + Sources: "198.51.100.0/24, 2001:db8::1", SourcePorts: "53 1024-2048", Destination: "192.168.10.5", Ports: "5353"}}) + if err != nil { + t.Fatal(err) + } + rows, _ := s.firewallRules() + want := FirewallRule{From: "vpn", To: "main", Protocol: "udp", Sources: "198.51.100.0/24, 2001:db8::1", + SourcePorts: "53, 1024-2048", Destination: "192.168.10.5", Ports: "5353"} + if len(rows) != 1 || rows[0] != want { + t.Fatalf("rows = %+v", rows) + } + for name, mod := range map[string]func(*FirewallRule){ + "sources": func(r *FirewallRule) { r.Sources = "the internet" }, + "sourcePorts": func(r *FirewallRule) { r.SourcePorts = "70000" }, + "ports": func(r *FirewallRule) { r.Protocol = "icmp" }, + } { + r := want + mod(&r) + if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" { + t.Errorf("%s not refused: %v", name, fe) + } + } +} + +func TestZonesAreAssignedAddedAndRemoved(t *testing.T) { + s := mustParse(t) + // A new zone, empty at first. + if err := s.applyFirewall(ZoneForm{New: "guests, iot"}, nil); err != nil { + t.Fatal(err) + } + if got := strings.Join(s.zones(), ","); got != "guests,iot,main,vpn" { + t.Fatalf("zones = %s", got) + } + // Two interfaces into one zone; the tunnel's old zone disappears, the + // LAN's keeps its name by default. + members := s.zoneMembers() + if len(members) != 2 || members[0].Kind != "lan" || members[1].Device != "wg-vpn" { + t.Fatalf("members = %+v", members) + } + members[1].Zone = "guests" + if err := s.applyFirewall(ZoneForm{Members: members}, nil); err != nil { + t.Fatal(err) + } + if str(s, "router", "wireguard", "vpn", "zone") != "guests" || str(s, "router", "lans", "main", "zone") != "" { + t.Errorf("zones not assigned: %s", mustMarshal(t, s)) + } + if got := strings.Join(s.declaredZones(), ","); got != "iot" { + t.Errorf("declared = %s (guests has a member now)", got) + } + // Back to the default name drops the key; an empty zone can be removed. + members[1].Zone = "vpn" + if err := s.applyFirewall(ZoneForm{Members: members, Remove: []string{"iot"}}, nil); err != nil { + t.Fatal(err) + } + if _, ok := s["router"].(map[string]any)["wireguard"].(map[string]any)["vpn"].(map[string]any)["zone"]; ok { + t.Error("the default zone is written out") + } + if len(s.declaredZones()) != 0 { + t.Errorf("declared = %v", s.declaredZones()) + } + if _, ok := s["router"].(map[string]any)["firewall"]; ok { + t.Error("an empty firewall section is left behind") + } +} + +func TestZoneChangesAreChecked(t *testing.T) { + for name, zf := range map[string]func(*Settings) ZoneForm{ + "newzone": func(*Settings) ZoneForm { return ZoneForm{New: "Not Ok"} }, + "newzone ": func(*Settings) ZoneForm { return ZoneForm{New: "wan"} }, + "member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} }, + } { + s := mustParse(t) + fe, ok := s.applyFirewall(zf(&s), nil).(FormErrors) + if !ok || fe[strings.TrimSpace(name)] == "" { + t.Errorf("%s accepted: %v", name, fe) + } + } + // A zone with members stays; a rule left without its zone is refused. + s := mustParse(t) + if err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil { + t.Fatal(err) + } + m := s.zoneMembers() + m[1].Zone = "other" + fe, ok := s.applyFirewall(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors) + if !ok || fe["rule[0].to"] == "" { + t.Errorf("rule to a vanished zone accepted: %v", fe) + } + fe, ok = mustParse(t).applyFirewall(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors) + if ok && len(fe) > 0 { + t.Errorf("removing a zone that is not declared is not an error: %v", fe) + } +} + +func TestDefaultRangeIs15Minutes(t *testing.T) { + if findRange("").Name != "15m" || findRange("1h").Name != "1h" || findRange("bogus").Name != "15m" { + t.Fatal("ranges", ranges) + } + a, _ := newTestApp(t) + now := time.Now() + a.metrics.store.Add("sys:cpu", now, 50) + c15 := a.chart("t", "%", findRange("15m"), now, []seriesSpec{{"sys:cpu", "cpu", 1}}) + c1h := a.chart("t", "%", findRange("1h"), now, []seriesSpec{{"sys:cpu", "cpu", 1}}) + if c15.Empty || c1h.Empty || len(c15.Cols) != chartCols { + t.Fatalf("charts: %+v", c15) + } + // The ends of the axis are 15 and 60 minutes apart. + span := func(c Chart) time.Duration { + a, _ := time.ParseInLocation("15:04", c.XTicks[0].Label, time.Local) + b, _ := time.ParseInLocation("15:04", c.XTicks[4].Label, time.Local) + return (b.Sub(a) + 24*time.Hour) % (24 * time.Hour) + } + if got := span(c15); got < 14*time.Minute || got > 16*time.Minute { + t.Errorf("15m axis spans %v", got) + } + if got := span(c1h); got < 59*time.Minute || got > 61*time.Minute { + t.Errorf("1h axis spans %v", got) + } +} diff --git a/pkgs/router-webui/metrics.go b/pkgs/router-webui/metrics.go index 8535eb3..57cfade 100644 --- a/pkgs/router-webui/metrics.go +++ b/pkgs/router-webui/metrics.go @@ -25,24 +25,37 @@ var tiers = []struct { {3600, 720}, } -// Ranges the dashboard offers, and the tier each one reads. -var ranges = []struct { - Name string - Tier int -}{ - {"1h", 0}, - {"24h", 1}, - {"7d", 2}, - {"30d", 3}, +// Ranges the dashboard offers: the tier each one reads and how many of its +// newest slots it shows (0: all of them). The first one is the default. +var ranges = []timeRange{ + {"15m", 0, 180}, + {"1h", 0, 0}, + {"24h", 1, 0}, + {"7d", 2, 0}, + {"30d", 3, 0}, } -func rangeTier(name string) (int, string) { +type timeRange struct { + Name string + Tier int + Slots int +} + +// slots is how many slots of its tier the range shows. +func (r timeRange) slots() int { + if r.Slots > 0 { + return r.Slots + } + return tiers[r.Tier].Slots +} + +func findRange(name string) timeRange { for _, r := range ranges { if r.Name == name { - return r.Tier, r.Name + return r } } - return 0, ranges[0].Name + return ranges[0] } type Tier struct { diff --git a/pkgs/router-webui/sensors_test.go b/pkgs/router-webui/sensors_test.go new file mode 100644 index 0000000..73d073a --- /dev/null +++ b/pkgs/router-webui/sensors_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadSensors(t *testing.T) { + root := t.TempDir() + write := func(path, content string) { + t.Helper() + p := filepath.Join(root, path) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(content+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + write("thermal/thermal_zone0/type", "acpitz") + write("thermal/thermal_zone0/temp", "47000") + write("thermal/thermal_zone1/type", "x86_pkg_temp") + write("thermal/thermal_zone1/temp", "61000") + write("thermal/thermal_zone2/type", "broken") + write("thermal/thermal_zone2/temp", "0") + write("hwmon/hwmon3/name", "coretemp") + write("hwmon/hwmon3/temp1_input", "62000") + write("hwmon/hwmon3/temp1_label", "Package id 0") + write("hwmon/hwmon3/temp2_input", "58500") + write("hwmon/hwmon3/temp2_label", "Core 0") + write("hwmon/hwmon4/name", "nvme") + write("hwmon/hwmon4/temp1_input", "39000") + write("hwmon/hwmon5/name", "nvme") + write("hwmon/hwmon5/temp1_input", "41000") + + got := readSensors(filepath.Join(root, "thermal"), filepath.Join(root, "hwmon")) + want := map[string]float64{ + "acpitz": 47, "x86_pkg_temp": 61, "coretemp Package id 0": 62, "coretemp Core 0": 58.5, + "nvme temp1": 39, "nvme temp1 2": 41, + } + if len(got) != len(want) { + t.Fatalf("sensors = %+v", got) + } + for _, s := range got { + if v, ok := want[s.Label]; !ok || v != s.Temp || s.Key != s.Label { + t.Errorf("unexpected %+v", s) + } + } + if h := hottest(got); h != 62 { + t.Errorf("hottest = %v", h) + } + if got := readSensors(filepath.Join(root, "none"), filepath.Join(root, "none")); len(got) != 0 || hottest(got) == hottest(got) { + t.Errorf("no sensors: %+v (hottest should be NaN)", got) + } +} diff --git a/pkgs/router-webui/static/dashboard.js b/pkgs/router-webui/static/dashboard.js index e8d6cbc..9c5e2b0 100644 --- a/pkgs/router-webui/static/dashboard.js +++ b/pkgs/router-webui/static/dashboard.js @@ -33,7 +33,7 @@ if (!a) return; ev.preventDefault(); range = a.dataset.range; - interval = range === "1h" ? 5 : 60; + interval = range === "15m" || range === "1h" ? 5 : 60; history.replaceState(null, "", "/?range=" + encodeURIComponent(range)); refresh(); }); diff --git a/pkgs/router-webui/static/style.css b/pkgs/router-webui/static/style.css index 27c9d02..e6a90b0 100644 --- a/pkgs/router-webui/static/style.css +++ b/pkgs/router-webui/static/style.css @@ -38,8 +38,8 @@ code { font-size: .9em; overflow-wrap: anywhere; } .muted { color: var(--muted); } .bad { color: var(--bad); } .ok { color: var(--good); } -:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; } -@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; } } +:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; --s5: #0b7285; --s6: #862e9c; --s7: #5c940d; --s8: #a61e4d; --s9: #5f3dc4; } +@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc; } } .ranges { display: flex; gap: .25rem; margin: 0 0 1rem; } .ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; } .ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); } @@ -68,6 +68,7 @@ code { font-size: .9em; overflow-wrap: anywhere; } .chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); } .s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); } .s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); } +.s5 { color: var(--s5); stroke: var(--s5); } .s6 { color: var(--s6); stroke: var(--s6); } .s7 { color: var(--s7); stroke: var(--s7); } .s8 { color: var(--s8); stroke: var(--s8); } .s9 { color: var(--s9); stroke: var(--s9); } /* WireGuard */ .qr svg { width: 14rem; height: 14rem; background: #fff; } diff --git a/pkgs/router-webui/templates/firewall.html b/pkgs/router-webui/templates/firewall.html index f2d0bde..0a5dc59 100644 --- a/pkgs/router-webui/templates/firewall.html +++ b/pkgs/router-webui/templates/firewall.html @@ -1,35 +1,69 @@ {{define "content"}} {{with .Data}} {{$e := .Errors}} -
Traffic between zones is denied unless a rule allows it. A rule works in one direction only - (replies are always allowed), so letting two networks talk to each other takes two rules. - Traffic inside a zone is always allowed. A zone is a LAN or a WireGuard tunnel (named on the WireGuard page).
+Every LAN and tunnel belongs to a zone. Traffic inside a zone is allowed; between zones it is denied + unless a rule allows it. A rule works in one direction (replies are always allowed), so letting two zones talk + to each other takes two rules.
diff --git a/tests/assertions.nix b/tests/assertions.nix index 1b97fa2..dedde3a 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -310,6 +310,56 @@ let }; expect = [ "ports are only valid with protocol tcp or udp" ]; }; + ruleSourcePortsWithoutProtocol = { + router = { + lans = { + main = lan { }; + iot = lan2 { }; + }; + firewall.rules = [ + { + from = "iot"; + to = "main"; + sourcePorts = [ 53 ]; + } + ]; + }; + expect = [ "sourcePorts are only valid with protocol tcp or udp" ]; + }; + ruleSourcePortOutOfRange = { + router = { + lans = { + main = lan { }; + iot = lan2 { }; + }; + firewall.rules = [ + { + from = "iot"; + to = "main"; + protocol = "udp"; + sourcePorts = [ "2000-1000" ]; + } + ]; + }; + expect = [ "out of range" ]; + }; + ruleToDeclaredEmptyZone = { + router = { + lans.main = lan { }; + firewall = { + zones = [ "guests" ]; + rules = [ + { + from = "main"; + to = "guests"; + protocol = "tcp"; + sourcePorts = [ 1024 ]; + } + ]; + }; + }; + expect = [ ]; + }; ruleDestinationInvalid = { router = { lans = { diff --git a/tests/vlans.nix b/tests/vlans.nix index 84c8f12..50ab8c9 100644 --- a/tests/vlans.nix +++ b/tests/vlans.nix @@ -6,6 +6,8 @@ # # rules: trusted -> iot (anything) # iot -> trusted, tcp 8080, only to 192.168.10.100 +# iot -> trusted, tcp 9090, only from source ports 40000-40010 +# "lab" (no interface yet) -> trusted: matches nothing # # upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams { self, pkgs }: @@ -148,7 +150,20 @@ pkgs.testers.runNixOSTest { ports = [ 8080 ]; destination = "192.168.10.100"; } + { + from = "iot"; + to = "trusted"; + protocol = "tcp"; + ports = [ 9090 ]; + sourcePorts = [ "40000-40010" ]; + destination = "192.168.10.100"; + } + { + from = "lab"; + to = "trusted"; + } ]; + firewall.zones = [ "lab" ]; dns.forwarders = [ "203.0.113.1" ]; }; @@ -247,5 +262,15 @@ pkgs.testers.runNixOSTest { host.succeed("nc -z -w 2 192.168.10.100 8080") host.fail("nc -z -w 2 192.168.10.100 9090") host.fail("nc -z -w 2 192.168.10.150 8080") + + with subtest("a rule can match the source port too"): + for host in (iot, cams): + host.succeed("nc -z -w 2 -p 40005 192.168.10.100 9090") + host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090") + host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090") + + with subtest("a zone without interfaces does not break the ruleset"): + router.succeed("nft list ruleset | grep -q 'sport 40000-40010'") + router.succeed("systemctl is-active nftables.service") ''; }