Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 028c2ef78c Release 0.2.0-rc.4+git76-e9a24b1
release / release (push) Successful in 3m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:00 +00:00
abrown38andClaude Opus 5.5 e9a24b14e3 Refuse link-local addresses in the antispoof lists
fe80::/10 in the IPv6 antispoof list (or your prefixes) dropped the
ISP's router adverts and DHCPv6 replies in the early chain, before the
ICMPv6 and DHCPv6 client built-ins could accept them: the router lost
its IPv6 default route. Validation now refuses it
(antispoof_link_local), the settings page says so, and the importer
leaves it out with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:00 +00:00
11 changed files with 57 additions and 5 deletions

No files matched your search

+7
View File
@@ -172,6 +172,13 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### Fixed
- Link-local addresses (`fe80::/10`) are refused in the IPv6 antispoof
list and in your prefixes (`antispoof_link_local`). Dropping them on
external zones dropped the ISP's router adverts and DHCPv6 replies
before the built-ins could accept them: the IPv6 default route expired
and, later, the delegated prefix would have. A configuration saved
with one keeps being applied, but the next change is refused until it
is taken out. `hc import nft` leaves them out, with a note.
- An IPv4-mapped prefix (`::ffff:0:0/96`) is refused with an error that - An IPv4-mapped prefix (`::ffff:0:0/96`) is refused with an error that
says so; it was refused as "has host bits set; the prefix is invalid says so; it was refused as "has host bits set; the prefix is invalid
Prefix". Prefix".
+1 -1
View File
@@ -128,7 +128,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.2.0 is in testing (0.2.0-rc.4+git74-5348765): zones, address sets and Housecarl 0.2.0 is in testing (0.2.0-rc.4+git76-e9a24b1): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.4+git74-5348765 0.2.0-rc.4+git76-e9a24b1
+6 -1
View File
@@ -347,7 +347,12 @@ UI), and `hc ruleset` shows them:
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated **Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
IPv6 prefix and public addresses: nothing from outside should come from IPv6 prefix and public addresses: nothing from outside should come from
them. The antispoof lists can be changed with `--antispoof-v4` and them. The antispoof lists can be changed with `--antispoof-v4` and
`--antispoof-v6`. `--antispoof-v6`. Link-local addresses (`fe80::/10`) are refused in the
IPv6 list and in your prefixes (`antispoof_link_local`): your ISP's
router sends its router adverts and DHCPv6 replies from them, and
antispoofing runs in the early chain, before the ICMPv6 and DHCPv6
client built-ins could accept them, so the router would lose its IPv6
default route and, later, its delegated prefix.
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
TCP and UDP connections forwarded between those devices skip the rules, TCP and UDP connections forwarded between those devices skip the rules,
+1
View File
@@ -31,6 +31,7 @@ are unique per kind, ignoring case.
|---|---|---| |---|---|---|
| `policy` | error | a policy that isn't `accept` or `drop` | | `policy` | error | a policy that isn't `accept` or `drop` |
| `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) | | `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) |
| `antispoof_link_local` | error | an own prefix or IPv6 antispoof entry holds link-local addresses (`fe80::/10`): the ISP's router adverts and DHCPv6 replies come from them, so dropping them on external zones loses the IPv6 default route and, later, the delegated prefix. A configuration saved before this check keeps being applied; the next change is refused until the entry is taken out |
| `flowtable_device` | error | a flowtable device with a wildcard: name each device | | `flowtable_device` | error | a flowtable device with a wildcard: name each device |
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does | | `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
| `probation_range` | error | the probation isn't 10 to 3600 seconds | | `probation_range` | error | the probation isn't 10 to 3600 seconds |
+9 -1
View File
@@ -1502,7 +1502,15 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
} }
set := &im.st.Settings set := &im.st.Settings
if p.family == "ipv6" { if p.family == "ipv6" {
set.OwnPrefixes = append(set.OwnPrefixes, p.src...) for _, s := range p.src {
// Dropping link-local on an external zone loses the ISP's
// router adverts and DHCPv6 replies.
if a, err := model.ParseAddress(s); err == nil && a.Overlaps(model.LinkLocal6) {
im.note("antispoofing: %s left out: it holds link-local addresses, which the ISP's router adverts and DHCPv6 replies come from", s)
continue
}
set.OwnPrefixes = append(set.OwnPrefixes, s)
}
im.st.Settings.AntispoofV6 = nil im.st.Settings.AntispoofV6 = nil
} else { } else {
set.AntispoofV4 = p.src set.AntispoofV4 = p.src
+23
View File
@@ -321,3 +321,26 @@ func TestImportMeters(t *testing.T) {
} }
} }
} }
// TestImportAntispoofLinkLocal: link-local in an IPv6 antispoof drop is
// left out (with a note), since it would drop the ISP's router adverts.
func TestImportAntispoofLinkLocal(t *testing.T) {
js, _ := os.ReadFile("testdata/router.json")
txt, _ := os.ReadFile("testdata/router.txt")
defs, _ := os.ReadFile("testdata/router.nft")
own := `{"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}`
if !strings.Contains(string(js), own) {
t.Fatal("router.json has no own-prefix drop")
}
js = []byte(strings.Replace(string(js), own, `{"set": [`+own+`, {"prefix": {"addr": "fe80::", "len": 10}}]}`, 1))
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
if err != nil {
t.Fatal(err)
}
if p := res.State.Settings.OwnPrefixes; len(p) != 1 || p[0] != "2a02:8013:7f3d::/48" {
t.Errorf("own prefixes %v", p)
}
if !strings.Contains(strings.Join(res.Notes, "\n"), "fe80::/10 left out") {
t.Errorf("no note: %q", res.Notes)
}
}
+3
View File
@@ -6,6 +6,9 @@ import (
"strings" "strings"
) )
// LinkLocal6 is IPv6's link-local prefix.
var LinkLocal6 = netip.MustParsePrefix("fe80::/10")
// Address is a parsed address entry: one address, a prefix or a range. // Address is a parsed address entry: one address, a prefix or a range.
type Address struct { type Address struct {
Prefix netip.Prefix // a single address is a /32 or /128 Prefix netip.Prefix // a single address is a /32 or /128
+2
View File
@@ -174,6 +174,8 @@ func (c *checker) settings() {
} }
if l.v6 != 2 && a.IsV6() != (l.v6 == 1) { if l.v6 != 2 && a.IsV6() != (l.v6 == 1) {
c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e) c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e)
} else if a.Overlaps(model.LinkLocal6) {
c.add(Error, "antispoof_link_local", "settings", "%s: %s holds link-local addresses (fe80::/10), which your ISP's router sends its router adverts and DHCPv6 replies from: dropping them on external zones loses the IPv6 default route, and later the delegated prefix. Link-local addresses never come from the internet anyway; take %s out", l.name, e, e)
} }
} }
} }
+3
View File
@@ -68,6 +68,9 @@ func TestCodes(t *testing.T) {
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }}, {"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }}, {"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }}, {"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"fc00::/7", "fe80::/10"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.OwnPrefixes = []string{"fe80::/64"} }},
{"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"::/0"} }},
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }}, {"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }}, {"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }}, {"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
+1 -1
View File
@@ -54,7 +54,7 @@ export async function render(el) {
field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))), field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))),
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."), field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
field("Antispoof IPv4", v4), field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6), field("Antispoof IPv6", v6, "Never link-local (fe80::/10): your ISP's router adverts and DHCPv6 replies come from it."),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."), field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."), field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual, h("label"), manual,