Allow rules between zones (LANs and tunnels) are edited on their own page: from, to, protocol, ports, destination. Port forwards and rules with more options are listed and kept as they are. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
271 lines
7.2 KiB
Go
271 lines
7.2 KiB
Go
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.")
|
|
}
|