Zone-based firewall page, per-sensor temperatures, 15 minute default

The firewall page now manages zones (assign each LAN or tunnel, add and
remove zones) and rules with zone, address and port on both sides plus the
protocol. router.firewall.zones declares zones without members and rules
take sourcePorts. The temperature chart draws every thermal zone and hwmon
sensor; the overview defaults to 15 minutes, 1h stays an option.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-30 09:26:25 +01:00
co-authored by Claude Sonnet 5.5
parent e2986f1418
commit eda7080d94
14 changed files with 713 additions and 149 deletions
+253 -78
View File
@@ -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