Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
497 lines
15 KiB
Go
497 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// FirewallRule is an allow rule between two zones, as the page edits it:
|
|
// 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 // 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
|
|
NATOnly bool // only connections that a port forward translated
|
|
Log bool // log new connections
|
|
RateLimit string // per source address, like 10/minute
|
|
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", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
|
|
|
|
// 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 []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 slices.Compact(out)
|
|
}
|
|
|
|
func ruleSimple(r map[string]any) bool {
|
|
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 d := strings.Join(stringList(r, "sources"), ", "); d != "" {
|
|
desc += " from " + d
|
|
}
|
|
if p := str(r, "protocol"); p != "" && p != "any" {
|
|
desc += " " + p
|
|
}
|
|
if p := portsText(r["ports"]); p != "" {
|
|
desc += " " + p
|
|
}
|
|
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"]), SourcePorts: portsText(r["sourcePorts"]),
|
|
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
|
|
NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
|
|
}
|
|
return simple, other
|
|
}
|
|
|
|
// 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" || m.Zone == "local":
|
|
errs[fmt.Sprintf("member[%d]", i)] = m.Zone + " is a reserved zone (the uplink, the router) 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" || z == "local":
|
|
errs["newzone"] = z + ": letters, digits and dashes, starting with a letter (wan and local are 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, nats []NATRule) 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.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.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
|
|
continue
|
|
}
|
|
if !slices.Contains(zones, r.From) && r.From != "wan" {
|
|
errs[field("from")] = "choose a zone"
|
|
}
|
|
if !slices.Contains(zones, r.To) && r.To != "local" {
|
|
errs[field("to")] = "choose a zone"
|
|
}
|
|
if r.To == "local" && r.NATOnly {
|
|
errs[field("to")] = "a port forward leads to a host in a zone, not to the router"
|
|
}
|
|
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"
|
|
}
|
|
ports, msg := portList(r.Ports)
|
|
if msg != "" {
|
|
errs[field("ports")] = msg
|
|
}
|
|
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"
|
|
}
|
|
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 r.From == "wan" {
|
|
// What the module requires, so that a typo cannot open a whole zone.
|
|
if r.Destination == "" && r.To != "local" {
|
|
errs[field("destination")] = "rules from the internet must name the host or network inside"
|
|
}
|
|
if r.Protocol == "any" {
|
|
errs[field("protocol")] = "rules from the internet must name a protocol"
|
|
} else if r.Protocol != "icmp" && len(ports) == 0 {
|
|
errs[field("ports")] = "rules from the internet must list the ports they open"
|
|
}
|
|
}
|
|
r.RateLimit = strings.TrimSpace(r.RateLimit)
|
|
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
|
|
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
|
|
}
|
|
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.NATOnly {
|
|
rule["natOnly"] = true
|
|
}
|
|
if r.Log {
|
|
rule["log"] = true
|
|
}
|
|
if r.RateLimit != "" {
|
|
rule["rateLimit"] = r.RateLimit
|
|
}
|
|
if r.Protocol != "any" {
|
|
rule["protocol"] = r.Protocol
|
|
}
|
|
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)
|
|
}
|
|
natRows, allows := s.natSettings(nats, zones, errs)
|
|
for _, a := range allows {
|
|
if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
|
|
next = append(next, a)
|
|
}
|
|
}
|
|
router := obj(s, "router")
|
|
fw := obj(router, "firewall")
|
|
old, _ := fw["rules"].([]any)
|
|
for _, v := range old {
|
|
r, _ := v.(map[string]any)
|
|
if r == nil || ruleSimple(r) {
|
|
continue
|
|
}
|
|
// Rules with options not edited here must keep their zones.
|
|
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")
|
|
} else {
|
|
fw["rules"] = next
|
|
}
|
|
if len(natRows) == 0 {
|
|
delete(fw, "nat")
|
|
} else {
|
|
fw["nat"] = natRows
|
|
}
|
|
if len(fw) == 0 {
|
|
delete(router, "firewall")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type firewallData struct {
|
|
Rows []FirewallRule
|
|
NATs []NATRule
|
|
FromZones []string // the zones and wan
|
|
ToZones []string // the zones and local (the router itself)
|
|
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, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
|
|
_, other := s.firewallRules()
|
|
d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
|
|
d.FromZones = append([]string{"wan"}, d.Zones...)
|
|
d.ToZones = append(slices.Clone(d.Zones), "local")
|
|
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) {
|
|
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, _ := s.firewallRules()
|
|
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
|
|
p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
|
|
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
|
|
}
|
|
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"))
|
|
nn, _ := strconv.Atoi(r.PostFormValue("nats"))
|
|
var nats []NATRule
|
|
for i := range min(nn, 512) {
|
|
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
|
|
nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
|
|
SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
|
|
ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
|
|
}
|
|
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"), Sources: v("sources"), SourcePorts: v("sourceports"),
|
|
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
|
|
NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
|
|
}
|
|
if err := s.applyFirewall(zf, rows, nats); err != nil {
|
|
var fe FormErrors
|
|
errors.As(err, &fe)
|
|
// Show what was entered; the zone lists are those before this attempt.
|
|
s, _ = loadSettings(a.sys.settingsPath())
|
|
p.Error = "Please correct the marked fields."
|
|
d := s.firewallData(rows, nats, zf.Members, fe)
|
|
d.New = zf.New
|
|
p.Data = d
|
|
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.")
|
|
}
|