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:
@@ -142,7 +142,7 @@ let
|
|||||||
);
|
);
|
||||||
|
|
||||||
wgs = lib.attrValues cfg.wireguard;
|
wgs = lib.attrValues cfg.wireguard;
|
||||||
zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs);
|
zoneNames = lib.unique (map (l: l.zone) lans ++ map (w: w.zone) wgs ++ cfg.firewall.zones);
|
||||||
|
|
||||||
isV6 = lib.hasInfix ":";
|
isV6 = lib.hasInfix ":";
|
||||||
v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null;
|
v4Cidr = s: lib.hasInfix "/" s && ipv4.parseNet s != null;
|
||||||
@@ -227,7 +227,11 @@ let
|
|||||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = lib.all portOk r.ports;
|
assertion = r.sourcePorts == [ ] || ((r.protocol == "tcp" || r.protocol == "udp") && !r.dnat);
|
||||||
|
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all portOk (r.ports ++ r.sourcePorts);
|
||||||
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,15 +62,17 @@ let
|
|||||||
|
|
||||||
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
|
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
|
||||||
|
|
||||||
# Layer 4 match; `ports` may be ints or "a-b" ranges.
|
# Layer 4 match; `ports` and `sports` may be ints or "a-b" ranges.
|
||||||
l4 =
|
l4 =
|
||||||
proto: ports:
|
proto: ports: sports:
|
||||||
if proto == "any" then
|
if proto == "any" then
|
||||||
""
|
""
|
||||||
else if proto == "icmp" then
|
else if proto == "icmp" then
|
||||||
"meta l4proto { icmp, ipv6-icmp } "
|
"meta l4proto { icmp, ipv6-icmp } "
|
||||||
else
|
else
|
||||||
"${proto} dport ${if ports == [ ] then "1-65535" else setOf ports} ";
|
"${lib.optionalString (sports != [ ]) "${proto} sport ${setOf sports} "}${proto} dport ${
|
||||||
|
if ports == [ ] then "1-65535" else setOf ports
|
||||||
|
} ";
|
||||||
|
|
||||||
# Destination match, by family of the address.
|
# Destination match, by family of the address.
|
||||||
daddrOf =
|
daddrOf =
|
||||||
@@ -121,7 +123,7 @@ let
|
|||||||
toDevs = zoneDevices r.to;
|
toDevs = zoneDevices r.to;
|
||||||
# After DNAT the packet carries the translated port.
|
# After DNAT the packet carries the translated port.
|
||||||
fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports;
|
fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports;
|
||||||
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts}${lib.optionalString r.dnat "ct status dnat "}";
|
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts r.sourcePorts}${lib.optionalString r.dnat "ct status dnat "}";
|
||||||
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
|
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
|
||||||
limitLines =
|
limitLines =
|
||||||
v:
|
v:
|
||||||
@@ -150,19 +152,22 @@ let
|
|||||||
}";
|
}";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
forward = lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r);
|
# A zone nothing is assigned to yet has no devices; the rule waits for one.
|
||||||
|
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
|
||||||
|
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
|
||||||
|
);
|
||||||
prerouting =
|
prerouting =
|
||||||
lib.optionals r.dnat (
|
lib.optionals r.dnat (
|
||||||
map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports}${dnatTarget} # ${label}") (
|
map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports [ ]}${dnatTarget} # ${label}") (
|
||||||
variantsOf r
|
variantsOf r
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# NAT loopback: internal clients addressing the router's public address.
|
# NAT loopback: internal clients addressing the router's public address.
|
||||||
++
|
++
|
||||||
lib.optional (r.dnat && r.hairpin)
|
lib.optional (r.dnat && r.hairpin)
|
||||||
"iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports}${dnatTarget} # hairpin ${label}";
|
"iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports [ ]}${dnatTarget} # hairpin ${label}";
|
||||||
hairpinForward = lib.optional (r.dnat && r.hairpin) (
|
hairpinForward = lib.optional (r.dnat && r.hairpin && toDevs != [ ]) (
|
||||||
"iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts}ct status dnat accept # hairpin ${label}"
|
"iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts [ ]}ct status dnat accept # hairpin ${label}"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,17 @@ in
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
firewall.zones = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "guests" ];
|
||||||
|
description = ''
|
||||||
|
Zones to define besides those of the LANs and tunnels (`zone` there
|
||||||
|
assigns them), so rules can name them before anything is assigned. A
|
||||||
|
zone without members matches nothing.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
firewall.rules = mkOption {
|
firewall.rules = mkOption {
|
||||||
default = [ ];
|
default = [ ];
|
||||||
description = ''
|
description = ''
|
||||||
@@ -426,6 +437,12 @@ in
|
|||||||
public address.
|
public address.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
sourcePorts = mkOption {
|
||||||
|
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||||
|
default = [ ];
|
||||||
|
example = [ 53 ];
|
||||||
|
description = "Source ports or ranges (tcp/udp only); empty means any. Not for port forwards.";
|
||||||
|
};
|
||||||
destination = mkOption {
|
destination = mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.nullOr types.str;
|
||||||
default = null;
|
default = null;
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ type SystemStatus struct {
|
|||||||
CPU float64 // percent; NaN until the second sample
|
CPU float64 // percent; NaN until the second sample
|
||||||
MemUsed uint64
|
MemUsed uint64
|
||||||
MemTotal uint64
|
MemTotal uint64
|
||||||
Temperature float64 // °C, NaN if no sensor
|
Temperature float64 // °C, the hottest sensor; NaN if there is none
|
||||||
|
Sensors []Sensor
|
||||||
DiskUsed uint64
|
DiskUsed uint64
|
||||||
DiskTotal uint64
|
DiskTotal uint64
|
||||||
}
|
}
|
||||||
@@ -166,6 +167,9 @@ func (c *Collector) sample(now time.Time) {
|
|||||||
c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal))
|
c.store.Add("sys:mem", now, 100*float64(sys.MemUsed)/float64(sys.MemTotal))
|
||||||
}
|
}
|
||||||
c.store.Add("sys:temp", now, sys.Temperature)
|
c.store.Add("sys:temp", now, sys.Temperature)
|
||||||
|
for _, t := range sys.Sensors {
|
||||||
|
c.store.Add("temp:"+t.Key, now, t.Temp)
|
||||||
|
}
|
||||||
|
|
||||||
up := uplinkStatus(f.WAN)
|
up := uplinkStatus(f.WAN)
|
||||||
<-done
|
<-done
|
||||||
@@ -241,7 +245,8 @@ func cpuTimes() (busy, total uint64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func systemStatus() SystemStatus {
|
func systemStatus() SystemStatus {
|
||||||
s := SystemStatus{Temperature: temperature()}
|
s := SystemStatus{Sensors: sensors()}
|
||||||
|
s.Temperature = hottest(s.Sensors)
|
||||||
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
if data, err := os.ReadFile("/proc/uptime"); err == nil {
|
||||||
if f := strings.Fields(string(data)); len(f) > 0 {
|
if f := strings.Fields(string(data)); len(f) > 0 {
|
||||||
secs, _ := strconv.ParseFloat(f[0], 64)
|
secs, _ := strconv.ParseFloat(f[0], 64)
|
||||||
@@ -277,23 +282,73 @@ func systemStatus() SystemStatus {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// temperature is the hottest thermal zone, or hwmon sensor if there is none.
|
// Sensor is one temperature the machine reports.
|
||||||
func temperature() float64 {
|
type Sensor struct {
|
||||||
hottest := math.NaN()
|
Key string // stable name of its series
|
||||||
for _, pattern := range []string{"/sys/class/thermal/thermal_zone*/temp", "/sys/class/hwmon/hwmon*/temp*_input"} {
|
Label string
|
||||||
files, _ := filepath.Glob(pattern)
|
Temp float64 // °C
|
||||||
for _, f := range files {
|
}
|
||||||
if v, err := readUint(f); err == nil && v > 0 && v < 150000 {
|
|
||||||
if t := float64(v) / 1000; math.IsNaN(hottest) || t > hottest {
|
// sensors lists the thermal zones and hwmon temperature inputs, sorted by
|
||||||
hottest = t
|
// label. Two sensors with the same name get a number.
|
||||||
}
|
func sensors() []Sensor {
|
||||||
}
|
return readSensors("/sys/class/thermal", "/sys/class/hwmon")
|
||||||
}
|
}
|
||||||
if !math.IsNaN(hottest) {
|
|
||||||
break
|
func readSensors(thermalDir, hwmonDir string) []Sensor {
|
||||||
|
var out []Sensor
|
||||||
|
add := func(label, file string) {
|
||||||
|
if v, err := readUint(file); err == nil && v > 0 && v < 150000 {
|
||||||
|
out = append(out, Sensor{Label: label, Temp: float64(v) / 1000})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hottest
|
text := func(file string) string {
|
||||||
|
data, _ := os.ReadFile(file)
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
zones, _ := filepath.Glob(filepath.Join(thermalDir, "thermal_zone*"))
|
||||||
|
for _, z := range zones {
|
||||||
|
label := text(filepath.Join(z, "type"))
|
||||||
|
if label == "" {
|
||||||
|
label = filepath.Base(z)
|
||||||
|
}
|
||||||
|
add(label, filepath.Join(z, "temp"))
|
||||||
|
}
|
||||||
|
inputs, _ := filepath.Glob(filepath.Join(hwmonDir, "hwmon*", "temp*_input"))
|
||||||
|
for _, in := range inputs {
|
||||||
|
dir, base := filepath.Split(in)
|
||||||
|
chip := text(filepath.Join(dir, "name"))
|
||||||
|
if chip == "" {
|
||||||
|
chip = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(base, "_input")
|
||||||
|
sub := text(filepath.Join(dir, name+"_label"))
|
||||||
|
if sub == "" {
|
||||||
|
sub = name
|
||||||
|
}
|
||||||
|
add(chip+" "+sub, in)
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(out, func(a, b Sensor) int { return strings.Compare(a.Label, b.Label) })
|
||||||
|
count := map[string]int{}
|
||||||
|
for i := range out {
|
||||||
|
base := out[i].Label
|
||||||
|
count[base]++
|
||||||
|
if n := count[base]; n > 1 {
|
||||||
|
out[i].Label = fmt.Sprintf("%s %d", base, n)
|
||||||
|
}
|
||||||
|
out[i].Key = out[i].Label
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hottest(sensors []Sensor) float64 {
|
||||||
|
h := math.NaN()
|
||||||
|
for _, t := range sensors {
|
||||||
|
if math.IsNaN(h) || t.Temp > h {
|
||||||
|
h = t.Temp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// uplinkDevice is where the router's IPv4 internet traffic leaves.
|
// uplinkDevice is where the router's IPv4 internet traffic leaves.
|
||||||
|
|||||||
@@ -120,14 +120,18 @@ type seriesSpec struct {
|
|||||||
Scale float64 // e.g. 8 for bytes/s -> bit/s
|
Scale float64 // e.g. 8 for bytes/s -> bit/s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesSpec) Chart {
|
func (a *app) chart(title, unit string, rg timeRange, now time.Time, specs []seriesSpec) Chart {
|
||||||
c := Chart{Title: title, Unit: unit}
|
c := Chart{Title: title, Unit: unit}
|
||||||
var start time.Time
|
var start time.Time
|
||||||
var step time.Duration
|
var step time.Duration
|
||||||
cols := make([][]float64, len(specs))
|
cols := make([][]float64, len(specs))
|
||||||
ymax := 0.0
|
ymax := 0.0
|
||||||
for i, sp := range specs {
|
for i, sp := range specs {
|
||||||
st, stp, vals := a.metrics.store.Read(sp.Name, tier, now)
|
st, stp, vals := a.metrics.store.Read(sp.Name, rg.Tier, now)
|
||||||
|
if keep := rg.slots(); keep < len(vals) {
|
||||||
|
st = st.Add(stp * time.Duration(len(vals)-keep))
|
||||||
|
vals = vals[len(vals)-keep:]
|
||||||
|
}
|
||||||
start, step = st, stp
|
start, step = st, stp
|
||||||
cols[i] = downsample(vals, chartCols, sp.Scale)
|
cols[i] = downsample(vals, chartCols, sp.Scale)
|
||||||
for _, v := range cols[i] {
|
for _, v := range cols[i] {
|
||||||
@@ -137,7 +141,7 @@ func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
n := chartCols
|
n := chartCols
|
||||||
span := step * time.Duration(tiers[tier].Slots)
|
span := step * time.Duration(rg.slots())
|
||||||
colDur := span / time.Duration(n)
|
colDur := span / time.Duration(n)
|
||||||
if unit == "%" {
|
if unit == "%" {
|
||||||
ymax = math.Max(ymax, 1)
|
ymax = math.Max(ymax, 1)
|
||||||
@@ -148,7 +152,7 @@ func (a *app) chart(title, unit string, tier int, now time.Time, specs []seriesS
|
|||||||
ymax = niceCeil(ymax)
|
ymax = niceCeil(ymax)
|
||||||
c.Empty = true
|
c.Empty = true
|
||||||
for i, sp := range specs {
|
for i, sp := range specs {
|
||||||
line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i), Now: "–"}
|
line := ChartLine{Label: sp.Label, Class: fmt.Sprintf("s%d", i%10), Now: "–"}
|
||||||
var path strings.Builder
|
var path strings.Builder
|
||||||
pen := false
|
pen := false
|
||||||
for j, v := range cols[i] {
|
for j, v := range cols[i] {
|
||||||
@@ -316,18 +320,18 @@ type dashboard struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) dashboardData(rangeName string) dashboard {
|
func (a *app) dashboardData(rangeName string) dashboard {
|
||||||
tier, rangeName := rangeTier(rangeName)
|
rg := findRange(rangeName)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s, _ := loadSettings(a.sys.settingsPath())
|
s, _ := loadSettings(a.sys.settingsPath())
|
||||||
f := s.form()
|
f := s.form()
|
||||||
d := dashboard{Range: rangeName, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()}
|
d := dashboard{Range: rg.Name, Interval: 5, Snap: a.metrics.Snapshot(), Prefs: a.sys.loadPrefs()}
|
||||||
// Newest first, the last few.
|
// Newest first, the last few.
|
||||||
ev := d.Snap.Uplink.Events
|
ev := d.Snap.Uplink.Events
|
||||||
d.Events = make([]Event, 0, 10)
|
d.Events = make([]Event, 0, 10)
|
||||||
for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- {
|
for i := len(ev) - 1; i >= 0 && len(d.Events) < 10; i-- {
|
||||||
d.Events = append(d.Events, ev[i])
|
d.Events = append(d.Events, ev[i])
|
||||||
}
|
}
|
||||||
if tier > 0 {
|
if rg.Tier > 0 {
|
||||||
d.Interval = 60
|
d.Interval = 60
|
||||||
}
|
}
|
||||||
for _, r := range ranges {
|
for _, r := range ranges {
|
||||||
@@ -336,7 +340,7 @@ func (a *app) dashboardData(rangeName string) dashboard {
|
|||||||
d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now)
|
d.Leases = readLeases(a.metrics.leasesPath, f.LANs, now)
|
||||||
|
|
||||||
traffic := func(title, dev string) Chart {
|
traffic := func(title, dev string) Chart {
|
||||||
return a.chart(title, "bit/s", tier, now, []seriesSpec{
|
return a.chart(title, "bit/s", rg, now, []seriesSpec{
|
||||||
{"if:" + dev + ":rx", "down", 8},
|
{"if:" + dev + ":rx", "down", 8},
|
||||||
{"if:" + dev + ":tx", "up", 8},
|
{"if:" + dev + ":tx", "up", 8},
|
||||||
})
|
})
|
||||||
@@ -359,12 +363,16 @@ func (a *app) dashboardData(rangeName string) dashboard {
|
|||||||
loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1})
|
loss = append(loss, seriesSpec{"ping:" + t + ":loss", t, 1})
|
||||||
}
|
}
|
||||||
if len(rtt) > 0 {
|
if len(rtt) > 0 {
|
||||||
d.Charts = append(d.Charts, a.chart("Latency", "ms", tier, now, rtt), a.chart("Packet loss", "%", tier, now, loss))
|
d.Charts = append(d.Charts, a.chart("Latency", "ms", rg, now, rtt), a.chart("Packet loss", "%", rg, now, loss))
|
||||||
}
|
}
|
||||||
d.Charts = append(d.Charts,
|
d.Charts = append(d.Charts,
|
||||||
a.chart("CPU and memory", "%", tier, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
|
a.chart("CPU and memory", "%", rg, now, []seriesSpec{{"sys:cpu", "CPU", 1}, {"sys:mem", "memory", 1}}))
|
||||||
if temp := a.chart("Temperature", "°C", tier, now, []seriesSpec{{"sys:temp", "hottest sensor", 1}}); !temp.Empty || !math.IsNaN(d.Snap.System.Temperature) {
|
var temps []seriesSpec
|
||||||
d.Charts = append(d.Charts, temp)
|
for _, t := range d.Snap.System.Sensors {
|
||||||
|
temps = append(temps, seriesSpec{"temp:" + t.Key, t.Label, 1})
|
||||||
|
}
|
||||||
|
if len(temps) > 0 {
|
||||||
|
d.Charts = append(d.Charts, a.chart("Temperature", "°C", rg, now, temps))
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|||||||
+253
-78
@@ -12,42 +12,76 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// FirewallRule is an allow rule between two zones, as the page edits it:
|
// FirewallRule is an allow rule between two zones, as the page edits it:
|
||||||
// from, to, protocol, ports and destination. Rules with more (port forwards,
|
// zones, addresses and ports on both sides, and the protocol. Rules with more
|
||||||
// sources, rate limits, logging) are kept as they are and only listed.
|
// (port forwards, rate limits, logging) are kept as they are and only listed.
|
||||||
type FirewallRule struct {
|
type FirewallRule struct {
|
||||||
From string
|
From string // zone
|
||||||
To string
|
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
|
Protocol string
|
||||||
Ports string
|
|
||||||
Destination string
|
|
||||||
Remove bool
|
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]+)?$`)
|
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
|
||||||
|
|
||||||
// simpleRuleKeys are the settings keys the page edits.
|
// 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
|
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
|
||||||
// named after its LAN or tunnel unless it says otherwise.
|
// named after itself unless it says otherwise.
|
||||||
func (s Settings) zones() []string {
|
func (s Settings) zoneMembers() []ZoneMember {
|
||||||
router, _ := s["router"].(map[string]any)
|
router, _ := s["router"].(map[string]any)
|
||||||
var out []string
|
var out []ZoneMember
|
||||||
for _, kind := range []string{"lans", "wireguard"} {
|
lans := s.form().LANs
|
||||||
m, _ := router[kind].(map[string]any)
|
for _, l := range lans {
|
||||||
for name, v := range m {
|
out = append(out, ZoneMember{Kind: "lan", Name: l.Name, Device: l.Interface, Zone: orName(str(router, "lans", l.Name, "zone"), l.Name)})
|
||||||
member, _ := v.(map[string]any)
|
}
|
||||||
zone := str(member, "zone")
|
wgs, _ := router["wireguard"].(map[string]any)
|
||||||
if zone == "" {
|
names := make([]string, 0, len(wgs))
|
||||||
zone = name
|
for n := range wgs {
|
||||||
}
|
names = append(names, n)
|
||||||
if !slices.Contains(out, zone) {
|
}
|
||||||
out = append(out, zone)
|
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)
|
slices.Sort(out)
|
||||||
return out
|
return slices.Compact(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruleSimple(r map[string]any) bool {
|
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 != "" {
|
if d := str(r, "destination"); d != "" {
|
||||||
desc += " " + d
|
desc += " " + d
|
||||||
}
|
}
|
||||||
|
if d := strings.Join(stringList(r, "sources"), ", "); d != "" {
|
||||||
|
desc += " from " + d
|
||||||
|
}
|
||||||
if p := str(r, "protocol"); p != "" && p != "any" {
|
if p := str(r, "protocol"); p != "" && p != "any" {
|
||||||
desc += " " + p
|
desc += " " + p
|
||||||
}
|
}
|
||||||
@@ -111,26 +148,133 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
|||||||
proto = "any"
|
proto = "any"
|
||||||
}
|
}
|
||||||
simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto,
|
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
|
return simple, other
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyFirewall replaces the rules the page edits with rows (an untouched
|
// ZoneForm is what the page changes about the zones: the zone of each member,
|
||||||
// "add" row is skipped); the other rules stay as they are.
|
// zones to add (separated by commas or spaces) and empty zones to drop.
|
||||||
func (s Settings) applyFirewall(rows []FirewallRule) error {
|
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{}
|
errs := FormErrors{}
|
||||||
|
s.applyZones(zf, errs)
|
||||||
zones := s.zones()
|
zones := s.zones()
|
||||||
var next []any
|
var next []any
|
||||||
seen := map[FirewallRule]bool{}
|
seen := map[FirewallRule]bool{}
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
field := func(n string) string { return fmt.Sprintf("rule[%d].%s", i, n) }
|
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.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 == "" {
|
if r.Protocol == "" {
|
||||||
r.Protocol = "any"
|
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
|
continue
|
||||||
}
|
}
|
||||||
if !slices.Contains(zones, r.From) {
|
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) {
|
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
|
||||||
errs[field("protocol")] = "any, tcp, udp or icmp"
|
errs[field("protocol")] = "any, tcp, udp or icmp"
|
||||||
}
|
}
|
||||||
var ports []any
|
ports, msg := portList(r.Ports)
|
||||||
for _, p := range splitList(r.Ports) {
|
if msg != "" {
|
||||||
if !portRangeRe.MatchString(p) {
|
errs[field("ports")] = msg
|
||||||
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" {
|
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"
|
errs[field("ports")] = "ports need protocol tcp or udp"
|
||||||
}
|
}
|
||||||
if r.Destination != "" {
|
var sources []any
|
||||||
if _, err := netip.ParsePrefix(r.Destination); err != nil {
|
for _, a := range splitList(r.Sources) {
|
||||||
if _, err := netip.ParseAddr(r.Destination); err != nil {
|
if !validAddress(a) {
|
||||||
errs[field("destination")] = "an address or CIDR, or empty for the whole zone"
|
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] {
|
if seen[r] {
|
||||||
errs[field("from")] = "the same rule twice"
|
errs[field("from")] = "the same rule twice"
|
||||||
@@ -188,38 +322,70 @@ func (s Settings) applyFirewall(rows []FirewallRule) error {
|
|||||||
if len(ports) > 0 {
|
if len(ports) > 0 {
|
||||||
rule["ports"] = ports
|
rule["ports"] = ports
|
||||||
}
|
}
|
||||||
|
if len(sports) > 0 {
|
||||||
|
rule["sourcePorts"] = sports
|
||||||
|
}
|
||||||
|
if len(sources) > 0 {
|
||||||
|
rule["sources"] = sources
|
||||||
|
}
|
||||||
if r.Destination != "" {
|
if r.Destination != "" {
|
||||||
rule["destination"] = r.Destination
|
rule["destination"] = r.Destination
|
||||||
}
|
}
|
||||||
next = append(next, rule)
|
next = append(next, rule)
|
||||||
}
|
}
|
||||||
if len(errs) > 0 {
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
router := obj(s, "router")
|
router := obj(s, "router")
|
||||||
fw := obj(router, "firewall")
|
fw := obj(router, "firewall")
|
||||||
old, _ := fw["rules"].([]any)
|
old, _ := fw["rules"].([]any)
|
||||||
for _, v := range old {
|
for _, v := range old {
|
||||||
if r, _ := v.(map[string]any); r != nil && !ruleSimple(r) {
|
r, _ := v.(map[string]any)
|
||||||
next = append(next, v)
|
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 {
|
if len(next) == 0 {
|
||||||
delete(fw, "rules")
|
delete(fw, "rules")
|
||||||
if len(fw) == 0 {
|
} else {
|
||||||
delete(router, "firewall")
|
fw["rules"] = next
|
||||||
}
|
}
|
||||||
return nil
|
if len(fw) == 0 {
|
||||||
|
delete(router, "firewall")
|
||||||
}
|
}
|
||||||
fw["rules"] = next
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type firewallData struct {
|
type firewallData struct {
|
||||||
Rows []FirewallRule
|
Rows []FirewallRule
|
||||||
Other []string
|
Other []string
|
||||||
Zones []string
|
Zones []string
|
||||||
Errors FormErrors
|
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) {
|
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)
|
a.render(w, "firewall", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, other := s.firewallRules()
|
rows, _ := s.firewallRules()
|
||||||
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
|
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)
|
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)
|
a.render(w, "firewall", p)
|
||||||
return
|
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"))
|
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||||
var rows []FirewallRule
|
var rows []FirewallRule
|
||||||
for i := range min(n, 512) {
|
for i := range min(n, 512) {
|
||||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) }
|
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"),
|
rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"),
|
||||||
Ports: v("ports"), Destination: v("destination"), Remove: v("remove") == "on"})
|
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
|
var fe FormErrors
|
||||||
errors.As(err, &fe)
|
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.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)
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
a.render(w, "firewall", p)
|
a.render(w, "firewall", p)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFirewallRulesRoundTrip(t *testing.T) {
|
func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||||
@@ -21,7 +22,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
|
|||||||
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") {
|
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "port forward") {
|
||||||
t.Fatalf("rows=%+v other=%v", rows, other)
|
t.Fatalf("rows=%+v other=%v", rows, other)
|
||||||
}
|
}
|
||||||
err := s.applyFirewall([]FirewallRule{
|
err := s.applyFirewall(ZoneForm{}, []FirewallRule{
|
||||||
{From: "main", To: "vpn", Protocol: "any"},
|
{From: "main", To: "vpn", Protocol: "any"},
|
||||||
{From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"},
|
{From: "vpn", To: "main", Protocol: "tcp", Ports: "22, 443 5000-5100", Destination: "192.168.10.0/24"},
|
||||||
{Protocol: "any"}, // the empty add row
|
{Protocol: "any"}, // the empty add row
|
||||||
@@ -43,13 +44,13 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows[0].Remove, rows[1].Remove = true, true
|
rows[0].Remove, rows[1].Remove = true, true
|
||||||
if err := s.applyFirewall(rows); err != nil {
|
if err := s.applyFirewall(ZoneForm{}, rows); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 {
|
if rows, other = s.firewallRules(); len(rows) != 0 || len(other) != 1 {
|
||||||
t.Errorf("rows=%+v other=%v", rows, other)
|
t.Errorf("rows=%+v other=%v", rows, other)
|
||||||
}
|
}
|
||||||
if err := s.applyFirewall(nil); err != nil {
|
if err := s.applyFirewall(ZoneForm{}, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
|
if _, ok := s["router"].(map[string]any)["firewall"].(map[string]any)["rules"]; !ok {
|
||||||
@@ -68,7 +69,7 @@ func TestFirewallRulesAreChecked(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
r := good
|
r := good
|
||||||
mod(&r)
|
mod(&r)
|
||||||
err := mustParse(t).applyFirewall([]FirewallRule{r})
|
err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r})
|
||||||
fe, ok := err.(FormErrors)
|
fe, ok := err.(FormErrors)
|
||||||
if !ok || fe["rule[0]."+name] == "" {
|
if !ok || fe["rule[0]."+name] == "" {
|
||||||
t.Errorf("%s: err = %v", name, err)
|
t.Errorf("%s: err = %v", name, err)
|
||||||
@@ -76,10 +77,10 @@ func TestFirewallRulesAreChecked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
r := good
|
r := good
|
||||||
r.Protocol = "any"
|
r.Protocol = "any"
|
||||||
if err := mustParse(t).applyFirewall([]FirewallRule{r}); err == nil {
|
if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}); err == nil {
|
||||||
t.Error("ports without tcp/udp accepted")
|
t.Error("ports without tcp/udp accepted")
|
||||||
}
|
}
|
||||||
if err := mustParse(t).applyFirewall([]FirewallRule{good, good}); err == nil {
|
if err := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{good, good}); err == nil {
|
||||||
t.Error("the same rule twice accepted")
|
t.Error("the same rule twice accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,3 +107,125 @@ func TestFirewallPage(t *testing.T) {
|
|||||||
t.Fatalf("not saved: %v", s)
|
t.Fatalf("not saved: %v", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirewallAddressesAndSourcePorts(t *testing.T) {
|
||||||
|
s := mustParse(t)
|
||||||
|
err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "vpn", To: "main", Protocol: "udp",
|
||||||
|
Sources: "198.51.100.0/24, 2001:db8::1", SourcePorts: "53 1024-2048", Destination: "192.168.10.5", Ports: "5353"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _ := s.firewallRules()
|
||||||
|
want := FirewallRule{From: "vpn", To: "main", Protocol: "udp", Sources: "198.51.100.0/24, 2001:db8::1",
|
||||||
|
SourcePorts: "53, 1024-2048", Destination: "192.168.10.5", Ports: "5353"}
|
||||||
|
if len(rows) != 1 || rows[0] != want {
|
||||||
|
t.Fatalf("rows = %+v", rows)
|
||||||
|
}
|
||||||
|
for name, mod := range map[string]func(*FirewallRule){
|
||||||
|
"sources": func(r *FirewallRule) { r.Sources = "the internet" },
|
||||||
|
"sourcePorts": func(r *FirewallRule) { r.SourcePorts = "70000" },
|
||||||
|
"ports": func(r *FirewallRule) { r.Protocol = "icmp" },
|
||||||
|
} {
|
||||||
|
r := want
|
||||||
|
mod(&r)
|
||||||
|
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, []FirewallRule{r}).(FormErrors); !ok || fe["rule[0]."+name] == "" {
|
||||||
|
t.Errorf("%s not refused: %v", name, fe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZonesAreAssignedAddedAndRemoved(t *testing.T) {
|
||||||
|
s := mustParse(t)
|
||||||
|
// A new zone, empty at first.
|
||||||
|
if err := s.applyFirewall(ZoneForm{New: "guests, iot"}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(s.zones(), ","); got != "guests,iot,main,vpn" {
|
||||||
|
t.Fatalf("zones = %s", got)
|
||||||
|
}
|
||||||
|
// Two interfaces into one zone; the tunnel's old zone disappears, the
|
||||||
|
// LAN's keeps its name by default.
|
||||||
|
members := s.zoneMembers()
|
||||||
|
if len(members) != 2 || members[0].Kind != "lan" || members[1].Device != "wg-vpn" {
|
||||||
|
t.Fatalf("members = %+v", members)
|
||||||
|
}
|
||||||
|
members[1].Zone = "guests"
|
||||||
|
if err := s.applyFirewall(ZoneForm{Members: members}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if str(s, "router", "wireguard", "vpn", "zone") != "guests" || str(s, "router", "lans", "main", "zone") != "" {
|
||||||
|
t.Errorf("zones not assigned: %s", mustMarshal(t, s))
|
||||||
|
}
|
||||||
|
if got := strings.Join(s.declaredZones(), ","); got != "iot" {
|
||||||
|
t.Errorf("declared = %s (guests has a member now)", got)
|
||||||
|
}
|
||||||
|
// Back to the default name drops the key; an empty zone can be removed.
|
||||||
|
members[1].Zone = "vpn"
|
||||||
|
if err := s.applyFirewall(ZoneForm{Members: members, Remove: []string{"iot"}}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := s["router"].(map[string]any)["wireguard"].(map[string]any)["vpn"].(map[string]any)["zone"]; ok {
|
||||||
|
t.Error("the default zone is written out")
|
||||||
|
}
|
||||||
|
if len(s.declaredZones()) != 0 {
|
||||||
|
t.Errorf("declared = %v", s.declaredZones())
|
||||||
|
}
|
||||||
|
if _, ok := s["router"].(map[string]any)["firewall"]; ok {
|
||||||
|
t.Error("an empty firewall section is left behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneChangesAreChecked(t *testing.T) {
|
||||||
|
for name, zf := range map[string]func(*Settings) ZoneForm{
|
||||||
|
"newzone": func(*Settings) ZoneForm { return ZoneForm{New: "Not Ok"} },
|
||||||
|
"newzone ": func(*Settings) ZoneForm { return ZoneForm{New: "wan"} },
|
||||||
|
"member[0]": func(s *Settings) ZoneForm { m := s.zoneMembers(); m[0].Zone = "wan"; return ZoneForm{Members: m} },
|
||||||
|
} {
|
||||||
|
s := mustParse(t)
|
||||||
|
fe, ok := s.applyFirewall(zf(&s), nil).(FormErrors)
|
||||||
|
if !ok || fe[strings.TrimSpace(name)] == "" {
|
||||||
|
t.Errorf("%s accepted: %v", name, fe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A zone with members stays; a rule left without its zone is refused.
|
||||||
|
s := mustParse(t)
|
||||||
|
if err := s.applyFirewall(ZoneForm{}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := s.zoneMembers()
|
||||||
|
m[1].Zone = "other"
|
||||||
|
fe, ok := s.applyFirewall(ZoneForm{Members: m}, []FirewallRule{{From: "main", To: "vpn", Protocol: "any"}}).(FormErrors)
|
||||||
|
if !ok || fe["rule[0].to"] == "" {
|
||||||
|
t.Errorf("rule to a vanished zone accepted: %v", fe)
|
||||||
|
}
|
||||||
|
fe, ok = mustParse(t).applyFirewall(ZoneForm{Remove: []string{"main"}, Members: mustParse(t).zoneMembers()}, nil).(FormErrors)
|
||||||
|
if ok && len(fe) > 0 {
|
||||||
|
t.Errorf("removing a zone that is not declared is not an error: %v", fe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRangeIs15Minutes(t *testing.T) {
|
||||||
|
if findRange("").Name != "15m" || findRange("1h").Name != "1h" || findRange("bogus").Name != "15m" {
|
||||||
|
t.Fatal("ranges", ranges)
|
||||||
|
}
|
||||||
|
a, _ := newTestApp(t)
|
||||||
|
now := time.Now()
|
||||||
|
a.metrics.store.Add("sys:cpu", now, 50)
|
||||||
|
c15 := a.chart("t", "%", findRange("15m"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
|
||||||
|
c1h := a.chart("t", "%", findRange("1h"), now, []seriesSpec{{"sys:cpu", "cpu", 1}})
|
||||||
|
if c15.Empty || c1h.Empty || len(c15.Cols) != chartCols {
|
||||||
|
t.Fatalf("charts: %+v", c15)
|
||||||
|
}
|
||||||
|
// The ends of the axis are 15 and 60 minutes apart.
|
||||||
|
span := func(c Chart) time.Duration {
|
||||||
|
a, _ := time.ParseInLocation("15:04", c.XTicks[0].Label, time.Local)
|
||||||
|
b, _ := time.ParseInLocation("15:04", c.XTicks[4].Label, time.Local)
|
||||||
|
return (b.Sub(a) + 24*time.Hour) % (24 * time.Hour)
|
||||||
|
}
|
||||||
|
if got := span(c15); got < 14*time.Minute || got > 16*time.Minute {
|
||||||
|
t.Errorf("15m axis spans %v", got)
|
||||||
|
}
|
||||||
|
if got := span(c1h); got < 59*time.Minute || got > 61*time.Minute {
|
||||||
|
t.Errorf("1h axis spans %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,24 +25,37 @@ var tiers = []struct {
|
|||||||
{3600, 720},
|
{3600, 720},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ranges the dashboard offers, and the tier each one reads.
|
// Ranges the dashboard offers: the tier each one reads and how many of its
|
||||||
var ranges = []struct {
|
// newest slots it shows (0: all of them). The first one is the default.
|
||||||
Name string
|
var ranges = []timeRange{
|
||||||
Tier int
|
{"15m", 0, 180},
|
||||||
}{
|
{"1h", 0, 0},
|
||||||
{"1h", 0},
|
{"24h", 1, 0},
|
||||||
{"24h", 1},
|
{"7d", 2, 0},
|
||||||
{"7d", 2},
|
{"30d", 3, 0},
|
||||||
{"30d", 3},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rangeTier(name string) (int, string) {
|
type timeRange struct {
|
||||||
|
Name string
|
||||||
|
Tier int
|
||||||
|
Slots int
|
||||||
|
}
|
||||||
|
|
||||||
|
// slots is how many slots of its tier the range shows.
|
||||||
|
func (r timeRange) slots() int {
|
||||||
|
if r.Slots > 0 {
|
||||||
|
return r.Slots
|
||||||
|
}
|
||||||
|
return tiers[r.Tier].Slots
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRange(name string) timeRange {
|
||||||
for _, r := range ranges {
|
for _, r := range ranges {
|
||||||
if r.Name == name {
|
if r.Name == name {
|
||||||
return r.Tier, r.Name
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, ranges[0].Name
|
return ranges[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tier struct {
|
type Tier struct {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadSensors(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(root, path)
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
if err := os.WriteFile(p, []byte(content+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("thermal/thermal_zone0/type", "acpitz")
|
||||||
|
write("thermal/thermal_zone0/temp", "47000")
|
||||||
|
write("thermal/thermal_zone1/type", "x86_pkg_temp")
|
||||||
|
write("thermal/thermal_zone1/temp", "61000")
|
||||||
|
write("thermal/thermal_zone2/type", "broken")
|
||||||
|
write("thermal/thermal_zone2/temp", "0")
|
||||||
|
write("hwmon/hwmon3/name", "coretemp")
|
||||||
|
write("hwmon/hwmon3/temp1_input", "62000")
|
||||||
|
write("hwmon/hwmon3/temp1_label", "Package id 0")
|
||||||
|
write("hwmon/hwmon3/temp2_input", "58500")
|
||||||
|
write("hwmon/hwmon3/temp2_label", "Core 0")
|
||||||
|
write("hwmon/hwmon4/name", "nvme")
|
||||||
|
write("hwmon/hwmon4/temp1_input", "39000")
|
||||||
|
write("hwmon/hwmon5/name", "nvme")
|
||||||
|
write("hwmon/hwmon5/temp1_input", "41000")
|
||||||
|
|
||||||
|
got := readSensors(filepath.Join(root, "thermal"), filepath.Join(root, "hwmon"))
|
||||||
|
want := map[string]float64{
|
||||||
|
"acpitz": 47, "x86_pkg_temp": 61, "coretemp Package id 0": 62, "coretemp Core 0": 58.5,
|
||||||
|
"nvme temp1": 39, "nvme temp1 2": 41,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("sensors = %+v", got)
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
if v, ok := want[s.Label]; !ok || v != s.Temp || s.Key != s.Label {
|
||||||
|
t.Errorf("unexpected %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h := hottest(got); h != 62 {
|
||||||
|
t.Errorf("hottest = %v", h)
|
||||||
|
}
|
||||||
|
if got := readSensors(filepath.Join(root, "none"), filepath.Join(root, "none")); len(got) != 0 || hottest(got) == hottest(got) {
|
||||||
|
t.Errorf("no sensors: %+v (hottest should be NaN)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
if (!a) return;
|
if (!a) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
range = a.dataset.range;
|
range = a.dataset.range;
|
||||||
interval = range === "1h" ? 5 : 60;
|
interval = range === "15m" || range === "1h" ? 5 : 60;
|
||||||
history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
|
history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ code { font-size: .9em; overflow-wrap: anywhere; }
|
|||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.bad { color: var(--bad); }
|
.bad { color: var(--bad); }
|
||||||
.ok { color: var(--good); }
|
.ok { color: var(--good); }
|
||||||
:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; }
|
:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; --s5: #0b7285; --s6: #862e9c; --s7: #5c940d; --s8: #a61e4d; --s9: #5f3dc4; }
|
||||||
@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; } }
|
@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; --s5: #66d9e8; --s6: #e599f7; --s7: #c0eb75; --s8: #faa2c1; --s9: #b197fc; } }
|
||||||
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
|
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
|
||||||
.ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; }
|
.ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; }
|
||||||
.ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
.ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
@@ -68,6 +68,7 @@ code { font-size: .9em; overflow-wrap: anywhere; }
|
|||||||
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
|
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||||
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
|
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
|
||||||
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
|
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
|
||||||
|
.s5 { color: var(--s5); stroke: var(--s5); } .s6 { color: var(--s6); stroke: var(--s6); } .s7 { color: var(--s7); stroke: var(--s7); } .s8 { color: var(--s8); stroke: var(--s8); } .s9 { color: var(--s9); stroke: var(--s9); }
|
||||||
|
|
||||||
/* WireGuard */
|
/* WireGuard */
|
||||||
.qr svg { width: 14rem; height: 14rem; background: #fff; }
|
.qr svg { width: 14rem; height: 14rem; background: #fff; }
|
||||||
|
|||||||
@@ -1,35 +1,69 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
{{$e := .Errors}}
|
{{$e := .Errors}}
|
||||||
<p class="hint">Traffic between zones is denied unless a rule allows it. A rule works in one direction only
|
<p class="hint">Every LAN and tunnel belongs to a zone. Traffic inside a zone is allowed; between zones it is denied
|
||||||
(replies are always allowed), so letting two networks talk to each other takes two rules.
|
unless a rule allows it. A rule works in one direction (replies are always allowed), so letting two zones talk
|
||||||
Traffic inside a zone is always allowed. A zone is a LAN or a WireGuard tunnel (named on the WireGuard page).</p>
|
to each other takes two rules.</p>
|
||||||
<form method="post" action="/firewall">
|
<form method="post" action="/firewall">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Allow rules</legend>
|
<legend>Zones</legend>
|
||||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
<input type="hidden" name="members" value="{{len .Members}}">
|
||||||
<table>
|
<table>
|
||||||
<tr><th>From</th><th>To</th><th>Protocol</th><th>Ports</th><th>Only to address</th><th>Remove</th></tr>
|
<tr><th>Interface</th><th>Device</th><th>Zone</th></tr>
|
||||||
|
{{range $i, $m := .Members}}
|
||||||
|
<tr>
|
||||||
|
<td>{{if eq $m.Kind "lan"}}LAN{{else}}Tunnel{{end}} <strong>{{$m.Name}}</strong>
|
||||||
|
<input type="hidden" name="member.{{$i}}.kind" value="{{$m.Kind}}">
|
||||||
|
<input type="hidden" name="member.{{$i}}.name" value="{{$m.Name}}">
|
||||||
|
<input type="hidden" name="member.{{$i}}.device" value="{{$m.Device}}"></td>
|
||||||
|
<td><code>{{$m.Device}}</code></td>
|
||||||
|
<td><select name="member.{{$i}}.zone">
|
||||||
|
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $m.Zone}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select></td>
|
||||||
|
</tr>
|
||||||
|
{{with index $e (printf "member[%d]" $i)}}<tr><td colspan="3" class="field-error">{{.}}</td></tr>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
<p class="hint">Several interfaces can share a zone. The uplink is the zone <code>wan</code>; rules from it publish services.</p>
|
||||||
|
{{range .Empty}}
|
||||||
|
<p>Zone <strong>{{.}}</strong> has no interface. <label class="inline"><input type="checkbox" name="rmzone" value="{{.}}"> remove</label>
|
||||||
|
{{with index $e (printf "zone.%s" .)}}<span class="field-error">{{.}}</span>{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
<label>New zones <input name="newzones" value="{{.New}}" size="24" placeholder="guests, iot"></label>
|
||||||
|
{{with index $e "newzone"}}<span class="field-error">{{.}}</span>{{end}}
|
||||||
|
<p class="hint">A new zone can be picked above (and in the rules) after saving.</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Rules</legend>
|
||||||
|
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th>Remove</th></tr>
|
||||||
{{range $i, $r := .Rows}}
|
{{range $i, $r := .Rows}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><select name="rule.{{$i}}.from"><option value=""></option>
|
<td><select name="rule.{{$i}}.from"><option value=""></option>
|
||||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
</select></td>
|
</select></td>
|
||||||
|
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
|
||||||
|
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
|
||||||
<td><select name="rule.{{$i}}.to"><option value=""></option>
|
<td><select name="rule.{{$i}}.to"><option value=""></option>
|
||||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
</select></td>
|
</select></td>
|
||||||
|
<td><input name="rule.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="whole zone"></td>
|
||||||
|
<td><input name="rule.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="all"></td>
|
||||||
<td><select name="rule.{{$i}}.protocol">
|
<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}}
|
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||||
</select></td>
|
</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>
|
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
|
||||||
</tr>
|
</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}}
|
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
<p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR;
|
||||||
|
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button>Save</button>
|
<button>Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -310,6 +310,56 @@ let
|
|||||||
};
|
};
|
||||||
expect = [ "ports are only valid with protocol tcp or udp" ];
|
expect = [ "ports are only valid with protocol tcp or udp" ];
|
||||||
};
|
};
|
||||||
|
ruleSourcePortsWithoutProtocol = {
|
||||||
|
router = {
|
||||||
|
lans = {
|
||||||
|
main = lan { };
|
||||||
|
iot = lan2 { };
|
||||||
|
};
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "iot";
|
||||||
|
to = "main";
|
||||||
|
sourcePorts = [ 53 ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "sourcePorts are only valid with protocol tcp or udp" ];
|
||||||
|
};
|
||||||
|
ruleSourcePortOutOfRange = {
|
||||||
|
router = {
|
||||||
|
lans = {
|
||||||
|
main = lan { };
|
||||||
|
iot = lan2 { };
|
||||||
|
};
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "iot";
|
||||||
|
to = "main";
|
||||||
|
protocol = "udp";
|
||||||
|
sourcePorts = [ "2000-1000" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "out of range" ];
|
||||||
|
};
|
||||||
|
ruleToDeclaredEmptyZone = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall = {
|
||||||
|
zones = [ "guests" ];
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
from = "main";
|
||||||
|
to = "guests";
|
||||||
|
protocol = "tcp";
|
||||||
|
sourcePorts = [ 1024 ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect = [ ];
|
||||||
|
};
|
||||||
ruleDestinationInvalid = {
|
ruleDestinationInvalid = {
|
||||||
router = {
|
router = {
|
||||||
lans = {
|
lans = {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
#
|
#
|
||||||
# rules: trusted -> iot (anything)
|
# rules: trusted -> iot (anything)
|
||||||
# iot -> trusted, tcp 8080, only to 192.168.10.100
|
# iot -> trusted, tcp 8080, only to 192.168.10.100
|
||||||
|
# iot -> trusted, tcp 9090, only from source ports 40000-40010
|
||||||
|
# "lab" (no interface yet) -> trusted: matches nothing
|
||||||
#
|
#
|
||||||
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
|
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
|
||||||
{ self, pkgs }:
|
{ self, pkgs }:
|
||||||
@@ -148,7 +150,20 @@ pkgs.testers.runNixOSTest {
|
|||||||
ports = [ 8080 ];
|
ports = [ 8080 ];
|
||||||
destination = "192.168.10.100";
|
destination = "192.168.10.100";
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
from = "iot";
|
||||||
|
to = "trusted";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 9090 ];
|
||||||
|
sourcePorts = [ "40000-40010" ];
|
||||||
|
destination = "192.168.10.100";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
from = "lab";
|
||||||
|
to = "trusted";
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
firewall.zones = [ "lab" ];
|
||||||
dns.forwarders = [ "203.0.113.1" ];
|
dns.forwarders = [ "203.0.113.1" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,5 +262,15 @@ pkgs.testers.runNixOSTest {
|
|||||||
host.succeed("nc -z -w 2 192.168.10.100 8080")
|
host.succeed("nc -z -w 2 192.168.10.100 8080")
|
||||||
host.fail("nc -z -w 2 192.168.10.100 9090")
|
host.fail("nc -z -w 2 192.168.10.100 9090")
|
||||||
host.fail("nc -z -w 2 192.168.10.150 8080")
|
host.fail("nc -z -w 2 192.168.10.150 8080")
|
||||||
|
|
||||||
|
with subtest("a rule can match the source port too"):
|
||||||
|
for host in (iot, cams):
|
||||||
|
host.succeed("nc -z -w 2 -p 40005 192.168.10.100 9090")
|
||||||
|
host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090")
|
||||||
|
host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090")
|
||||||
|
|
||||||
|
with subtest("a zone without interfaces does not break the ruleset"):
|
||||||
|
router.succeed("nft list ruleset | grep -q 'sport 40000-40010'")
|
||||||
|
router.succeed("systemctl is-active nftables.service")
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user