package main import ( "errors" "fmt" "net/http" "net/netip" "regexp" "slices" "strconv" "strings" ) // 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. type FirewallRule struct { From string To string Protocol string Ports string Destination string Remove bool } var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`) // simpleRuleKeys are the settings keys the page edits. var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "destination"} // 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 { 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) } } } slices.Sort(out) return out } func ruleSimple(r map[string]any) bool { if str(r, "from") == "wan" { return false } for k := range r { if !slices.Contains(simpleRuleKeys, k) { return false } } return true } func portsText(v any) string { var out []string items, _ := v.([]any) for _, i := range items { switch p := i.(type) { case string: out = append(out, p) case float64: out = append(out, strconv.FormatFloat(p, 'f', -1, 64)) case int: out = append(out, strconv.Itoa(p)) } } return strings.Join(out, ", ") } // firewallRules splits the rules into those the page edits and a description // of the others, one line each. func (s Settings) firewallRules() (simple []FirewallRule, other []string) { router, _ := s["router"].(map[string]any) fw, _ := router["firewall"].(map[string]any) rules, _ := fw["rules"].([]any) for _, v := range rules { r, _ := v.(map[string]any) if r == nil { continue } if !ruleSimple(r) { desc := str(r, "from") + " → " + str(r, "to") if d := str(r, "destination"); d != "" { desc += " " + d } if p := str(r, "protocol"); p != "" && p != "any" { desc += " " + p } if p := portsText(r["ports"]); p != "" { desc += " " + p } if dnat, _ := r["dnat"].(bool); dnat { desc += " (port forward)" } other = append(other, desc) continue } proto := str(r, "protocol") if proto == "" { proto = "any" } simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto, Ports: portsText(r["ports"]), 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 { errs := FormErrors{} 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) if r.Protocol == "" { r.Protocol = "any" } if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.Destination == "") { continue } if !slices.Contains(zones, r.From) { errs[field("from")] = "choose a zone" } if !slices.Contains(zones, r.To) { errs[field("to")] = "choose a zone" } if r.From == r.To && r.From != "" { errs[field("to")] = "traffic inside a zone is already allowed" } 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) } } if len(ports) > 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" } } } if seen[r] { errs[field("from")] = "the same rule twice" } seen[r] = true rule := map[string]any{"from": r.From, "to": r.To} if r.Protocol != "any" { rule["protocol"] = r.Protocol } if len(ports) > 0 { rule["ports"] = ports } 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) } } if len(next) == 0 { delete(fw, "rules") if len(fw) == 0 { delete(router, "firewall") } return nil } fw["rules"] = next return nil } type firewallData struct { Rows []FirewallRule Other []string Zones []string Errors FormErrors } func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "firewall", p) return } rows, other := 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()} a.render(w, "firewall", p) } func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Firewall", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "firewall", p) return } 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"}) } if err := s.applyFirewall(rows); err != nil { var fe FormErrors errors.As(err, &fe) _, other := s.firewallRules() p.Error = "Please correct the marked fields." p.Data = firewallData{Rows: rows, Other: other, Zones: s.zones(), Errors: fe} w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "firewall", p) return } if err := s.save(a.sys.settingsPath()); err != nil { p.Error = "Saving: " + err.Error() a.render(w, "firewall", p) return } back(w, r, "Saved. Apply to make the changes take effect.") }