Add a firewall page to the web UI
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
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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.")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
|
||||
t.Fatalf("zones = %v", got)
|
||||
}
|
||||
// A port forward the page does not edit must survive.
|
||||
obj(obj(s, "router"), "firewall")["rules"] = []any{
|
||||
map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp", "ports": []any{float64(443)}},
|
||||
}
|
||||
rows, other := s.firewallRules()
|
||||
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{
|
||||
{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
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, other = s.firewallRules()
|
||||
if len(rows) != 2 || len(other) != 1 {
|
||||
t.Fatalf("rows=%+v other=%v", rows, other)
|
||||
}
|
||||
if rows[1].Ports != "22, 443, 5000-5100" || rows[1].Protocol != "tcp" || rows[0].Protocol != "any" {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
out := string(mustMarshal(t, s))
|
||||
for _, want := range []string{`"dnat": true`, `"5000-5100"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s missing:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
rows[0].Remove, rows[1].Remove = true, true
|
||||
if err := s.applyFirewall(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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
|
||||
t.Error("the port forward was removed with the allow rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallRulesAreChecked(t *testing.T) {
|
||||
good := FirewallRule{From: "main", To: "vpn", Protocol: "tcp", Ports: "80"}
|
||||
for name, mod := range map[string]func(*FirewallRule){
|
||||
"from": func(r *FirewallRule) { r.From = "nowhere" },
|
||||
"to": func(r *FirewallRule) { r.To = "main" },
|
||||
"protocol": func(r *FirewallRule) { r.Protocol = "gre" },
|
||||
"ports": func(r *FirewallRule) { r.Ports = "80-70" },
|
||||
"destination": func(r *FirewallRule) { r.Destination = "the nas" },
|
||||
} {
|
||||
r := good
|
||||
mod(&r)
|
||||
err := mustParse(t).applyFirewall([]FirewallRule{r})
|
||||
fe, ok := err.(FormErrors)
|
||||
if !ok || fe["rule[0]."+name] == "" {
|
||||
t.Errorf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
r := good
|
||||
r.Protocol = "any"
|
||||
if err := mustParse(t).applyFirewall([]FirewallRule{r}); err == nil {
|
||||
t.Error("ports without tcp/udp accepted")
|
||||
}
|
||||
if err := mustParse(t).applyFirewall([]FirewallRule{good, good}); err == nil {
|
||||
t.Error("the same rule twice accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewallPage(t *testing.T) {
|
||||
a, sys := newTestApp(t)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/firewall?from=main&to=vpn", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `value="vpn"`) {
|
||||
t.Errorf("zones missing:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "rows": {"1"}, "rule.0.from": {"main"}, "rule.0.to": {"nowhere"}, "rule.0.protocol": {"any"}}
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("unknown zone: %d", rec.Code)
|
||||
}
|
||||
form.Set("rule.0.to", "vpn")
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
s, _ := loadSettings(sys.settingsPath())
|
||||
if rows, _ := s.firewallRules(); len(rows) != 1 || rows[0].From != "main" || rows[0].To != "vpn" {
|
||||
t.Fatalf("not saved: %v", s)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "advanced", "update"} {
|
||||
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "advanced", "update"} {
|
||||
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||
if err != nil {
|
||||
@@ -292,6 +292,8 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("POST /wireguard/delete", a.auth(a.wireguardDelete))
|
||||
mux.HandleFunc("POST /wireguard/client", a.auth(a.wireguardClient))
|
||||
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
|
||||
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
|
||||
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
|
||||
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
|
||||
mux.HandleFunc("POST /dhcp", a.auth(a.dhcp))
|
||||
mux.HandleFunc("GET /dashboard", a.auth(a.dashboardFragment))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
{{$e := .Errors}}
|
||||
<p class="hint">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).</p>
|
||||
<form method="post" action="/firewall">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<fieldset>
|
||||
<legend>Allow rules</legend>
|
||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||
<table>
|
||||
<tr><th>From</th><th>To</th><th>Protocol</th><th>Ports</th><th>Only to address</th><th>Remove</th></tr>
|
||||
{{range $i, $r := .Rows}}
|
||||
<tr>
|
||||
<td><select name="rule.{{$i}}.from"><option value=""></option>
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><select name="rule.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><select name="rule.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="rule.{{$i}}.ports" value="{{$r.Ports}}" size="14" placeholder="all"></td>
|
||||
<td><input name="rule.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="whole zone"></td>
|
||||
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
|
||||
</tr>
|
||||
{{range $f := list "from" "to" "protocol" "ports" "destination"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="6" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="hint">Fill in the last row to add a rule. Ports apply to tcp and udp: <code>22, 443, 5000-5100</code>.</p>
|
||||
</fieldset>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
{{if .Other}}
|
||||
<section>
|
||||
<h2>Other rules</h2>
|
||||
<p class="muted">Port forwards and rules with more options are kept as they are; change them in <code>settings.json</code>.</p>
|
||||
<ul>{{range .Other}}<li>{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/dhcp">DHCP</a>
|
||||
<a href="/dns">DNS</a>
|
||||
<a href="/wireguard">WireGuard</a>
|
||||
<a href="/firewall">Firewall</a>
|
||||
<a href="/update">Update</a>
|
||||
<a href="/advanced">Advanced</a>
|
||||
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
|
||||
|
||||
Reference in New Issue
Block a user