Compare commits
4
Commits
57ab9881db
...
028c2ef78c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
028c2ef78c | ||
|
|
e9a24b14e3 | ||
|
|
869ff6009b | ||
|
|
5348765d90 |
No files matched your search
@@ -124,6 +124,11 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Changed
|
||||
|
||||
- Moving a rule several places is one change. The Rules page has a
|
||||
**Move…** button that puts a rule before any other of its chain (or at
|
||||
the end) at once, and in a changeset a move right after a move of the
|
||||
same rule takes its place, so ↑ three times stages one operation (and
|
||||
moving it back where it was takes the operation out).
|
||||
- Statistics' "Busiest rules" (the Statistics page, `hc stats`,
|
||||
`GET /stats/summary`) lists only rules and NAT objects. Built-ins and
|
||||
policies have their own list ("Built-ins and policies", `builtins` in
|
||||
@@ -167,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".
|
||||
|
||||
@@ -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+git72-ca5ac97): 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
|
||||
|
||||
+4
-1
@@ -56,7 +56,10 @@ As in Jarl, a change can be made at once or staged in a **changeset**,
|
||||
to be reviewed and applied together with others:
|
||||
|
||||
* Each change is an **operation** (create, update, delete or move an
|
||||
object, or change the settings), kept as the API received it.
|
||||
object, or change the settings), kept as the API received it. A move
|
||||
staged right after a move of the same object takes its place: moving a
|
||||
rule up three steps is one operation, and moving it back where it was
|
||||
takes it out.
|
||||
* Staged in a changeset, an operation is checked as it is added: the
|
||||
changeset with it must still be valid, and nftables must accept the
|
||||
ruleset it makes. Nothing is applied.
|
||||
|
||||
+11
-1
@@ -130,6 +130,11 @@ drops or rejects decides. What no rule decides gets the chain's
|
||||
**policy** (Settings): by default input and forward drop, output
|
||||
accepts.
|
||||
|
||||
On the Rules page, ↑ and ↓ move a rule one place; **Move…** puts it
|
||||
before any other rule of its chain, or at the end, in one change
|
||||
(`hc rule move ID --before ID`). In a changeset, moves of the same rule
|
||||
one after another are kept as one operation.
|
||||
|
||||
```sh
|
||||
hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||
hc rule add input --in wg --service dns
|
||||
@@ -342,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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -89,6 +89,17 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
if err := replay(before, cs.Ops); err != nil {
|
||||
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
|
||||
}
|
||||
// A move right after a move of the same object takes its place:
|
||||
// moving a rule up three steps is one operation.
|
||||
kept := cs.Ops
|
||||
if n := len(kept); n > 0 && foldMove(before, &kept[n-1], &op) {
|
||||
kept = kept[:n-1]
|
||||
before = s.eng.State()
|
||||
if err := replay(before, kept); err != nil {
|
||||
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
|
||||
}
|
||||
}
|
||||
folded := len(kept) < len(cs.Ops)
|
||||
after := before.Clone()
|
||||
objID, err := applyOp(after, &op)
|
||||
if err != nil {
|
||||
@@ -98,6 +109,13 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
after.Sort()
|
||||
changes := engine.Diff(before, after)
|
||||
if len(changes) == 0 {
|
||||
// Moved back where it was: the earlier move goes too.
|
||||
if folded && !queryBool(r, "dry_run") {
|
||||
cs.Ops = kept
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
|
||||
return nil
|
||||
}
|
||||
@@ -108,7 +126,7 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
op.Summary = engine.UndoDescription(&h)
|
||||
}
|
||||
op.AddedAt = s.nowFn()
|
||||
for _, o := range cs.Ops {
|
||||
for _, o := range kept {
|
||||
if o.Seq >= op.Seq {
|
||||
op.Seq = o.Seq + 1
|
||||
}
|
||||
@@ -116,7 +134,7 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
if op.Seq == 0 {
|
||||
op.Seq = 1
|
||||
}
|
||||
ops := append(append([]model.Op{}, cs.Ops...), op)
|
||||
ops := append(append([]model.Op{}, kept...), op)
|
||||
dry := queryBool(r, "dry_run")
|
||||
// The whole changeset, checked as it would be applied.
|
||||
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||
@@ -145,6 +163,31 @@ func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal
|
||||
return nil
|
||||
}
|
||||
|
||||
// foldMove reports whether op, a move, can take the place of prev, the
|
||||
// changeset's last operation: a move of the same object. A move puts
|
||||
// the object relative to the others, which an earlier move of it
|
||||
// doesn't change, so only the chain it moved to has to be carried over.
|
||||
// st is the changeset so far.
|
||||
func foldMove(st *model.State, prev, op *model.Op) bool {
|
||||
if prev.Op != model.OpMove || op.Op != model.OpMove || prev.Kind != op.Kind {
|
||||
return false
|
||||
}
|
||||
a, err1 := findObject(st, prev.Kind, prev.Ref)
|
||||
b, err2 := findObject(st, op.Kind, op.Ref)
|
||||
if err1 != nil || err2 != nil || a.GetID() != b.GetID() {
|
||||
return false
|
||||
}
|
||||
var was, now api.MoveInput
|
||||
if json.Unmarshal(prev.Data, &was) != nil || json.Unmarshal(op.Data, &now) != nil {
|
||||
return false
|
||||
}
|
||||
if now.Chain == "" && was.Chain != "" {
|
||||
now.Chain = was.Chain
|
||||
op.Data, _ = json.Marshal(now)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1336,6 +1336,53 @@ func TestCreatePlaced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStagedMovesFold: moving a rule several steps in a changeset stays
|
||||
// one operation, and moving it back where it was takes it out.
|
||||
func TestStagedMovesFold(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var ids []int64
|
||||
for _, svc := range []string{"ssh", "dns", "http", "https"} {
|
||||
var mr api.MutationResponse
|
||||
var r model.Rule
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{svc}}}, &mr)
|
||||
json.Unmarshal(mr.Object, &r)
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
var cs model.Changeset
|
||||
e.call(e.token, "POST", "/changesets", nil, &cs)
|
||||
q := "?changeset=" + itoa(cs.ID)
|
||||
var mr api.MutationResponse
|
||||
for _, before := range []int64{ids[2], ids[1], ids[0]} { // https up three steps
|
||||
if code := e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{Before: before}, &mr); code != 200 || !mr.Staged {
|
||||
t.Fatalf("move before %d: %d %+v", before, code, mr)
|
||||
}
|
||||
}
|
||||
if n := len(mr.Changeset.Ops); n != 1 {
|
||||
t.Fatalf("%d operations, want 1: %+v", n, mr.Changeset.Ops)
|
||||
}
|
||||
// Another operation in between: the next move is its own.
|
||||
e.call(e.token, "PATCH", "/rules/"+itoa(ids[0])+q, map[string]any{"comment": "x"}, nil)
|
||||
e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{After: ids[0]}, &mr)
|
||||
if n := len(mr.Changeset.Ops); n != 3 {
|
||||
t.Errorf("%d operations after a patch and a move, want 3", n)
|
||||
}
|
||||
// Back to the start: the same place as before that move, so it goes.
|
||||
e.call(e.token, "POST", "/rules/"+itoa(ids[3])+"/move"+q, api.MoveInput{To: "start"}, &mr)
|
||||
if n := len(mr.Changeset.Ops); n != 2 {
|
||||
t.Errorf("%d operations after moving back, want 2", n)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, nil); code != 200 {
|
||||
t.Fatalf("apply: %d", code)
|
||||
}
|
||||
var out []string
|
||||
for _, r := range e.eng.State().Rules {
|
||||
out = append(out, strings.Join(r.Services, ""))
|
||||
}
|
||||
if o := strings.Join(out, ","); o != "https,ssh,dns,http" {
|
||||
t.Errorf("order: %s", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenForUser: an administrator makes a token for another user (the
|
||||
// web UI's New token on a user's row); that user can't make one for
|
||||
// someone else.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }},
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function render(el, params, query) {
|
||||
return [
|
||||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
|
||||
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
|
||||
h("button", { class: "small reveal", title: "Move to another place, in one change", disabled: rs.length < 2, onclick: () => moveDialog(r, rs, (x) => live(rules).indexOf(x) + 1) }, "Move…"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||
@@ -245,6 +246,28 @@ async function move(r, where) {
|
||||
try { await change("POST", "/rules/" + r.id + "/move", where, { quiet: true }); rerender(); } catch (e) { modal("Not moved", errorBox(e)); }
|
||||
}
|
||||
|
||||
// moveDialog puts a rule anywhere among rs (the rules it can move
|
||||
// among) in one move: one change, or one staged operation.
|
||||
async function moveDialog(r, rs, order) {
|
||||
const others = rs.filter((x) => x !== r);
|
||||
const label = (x) => order(x) + ". " + (x.comment || (x.raw ? x.raw : describe(x))) + " (#" + x.id + ")";
|
||||
const next = rs[rs.indexOf(r) + 1];
|
||||
const where = select([...others.map((x) => [String(x.id), "before " + label(x)]), ["end", "after " + label(others[others.length - 1])]],
|
||||
next ? String(next.id) : "end");
|
||||
const err = h("div");
|
||||
await modal("Move " + (r.comment || "rule #" + r.id), h("div", null, h("div", { class: "form-grid" }, field("Put it", where)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Move", kind: "primary", onclick: async () => {
|
||||
const v = where.value;
|
||||
if (v === (next ? String(next.id) : "end")) return true; // where it is
|
||||
try {
|
||||
await change("POST", "/rules/" + r.id + "/move", v === "end" ? { after: others[others.length - 1].id } : { before: Number(v) }, { quiet: true });
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function toggle(r) {
|
||||
try { await change("PATCH", "/rules/" + r.id, { disabled: !r.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user