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
- 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
says so; it was refused as "has host bits set; the prefix is invalid
Prefix".
+1 -1
View File
@@ -128,7 +128,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## 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
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
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
IPv6 prefix and public addresses: nothing from outside should come from
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
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` |
| `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_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 |
+9 -1
View File
@@ -1502,7 +1502,15 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
}
set := &im.st.Settings
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
} else {
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"
)
// 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.
type Address struct {
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) {
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" }},
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
{"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*"} }},
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
{"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("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
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("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual,