Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40748d3042 | ||
|
|
21ddced1ca | ||
|
|
566c07cbc7 |
No files matched your search
@@ -8,6 +8,11 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Added
|
||||
|
||||
- **Links to a rule open it.** A rule named in the matrix, the log, the
|
||||
tracer, statistics or the dashboard's busiest rules opens the Rules
|
||||
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
||||
The Rules page shows each rule's ID (`#410`, as `hc` and labels name
|
||||
it) beside its order.
|
||||
- **A clearer zone matrix.** A cell reads allow, block, or **partly**
|
||||
(some new connections let through, the rest not), with the exceptions
|
||||
counted by what they do (`+3 -1` instead of `+4`), and what they let
|
||||
@@ -17,6 +22,8 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
between its interfaces passes the firewall). The details list each
|
||||
rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
|
||||
`same_zone`, `note`, `hits`, and `short` and `hits` per exception.
|
||||
Raw rules that are a match and then accept, drop or reject (without
|
||||
interfaces) are read instead of shown as unknown.
|
||||
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
|
||||
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
||||
rejects and translates nothing. Drops and rejects are counted and accept
|
||||
|
||||
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## Status
|
||||
|
||||
Housecarl 0.1.1 is in testing (0.1.1-rc.3): zones, address sets and
|
||||
Housecarl 0.1.1 is in testing (0.1.1-rc.4): 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
|
||||
|
||||
@@ -332,6 +332,13 @@ The hits are the rule's packets since housecarld started, in all of its
|
||||
traffic: a rule without zones counts for every pair it covers. They are
|
||||
how you tell an exception that is used from one that isn't.
|
||||
|
||||
A raw rule is read when it is a match followed by `accept`, `drop` or
|
||||
`reject` (with `counter` and `log` if you like): it counts as an exception
|
||||
for every pair of its chain, shown as `raw: …`. One that matches
|
||||
interfaces (the matrix can't tell which zones), jumps, translates, sets
|
||||
something or uses a verdict map shows as **unknown**: check it yourself.
|
||||
Raw rules have hits only if they say `counter`.
|
||||
|
||||
A rule whose addresses all lie in networks directly connected to other
|
||||
zones is left out of a pair: traffic between those zones can't have them
|
||||
(`from wg_net` doesn't concern traffic coming in on the LAN). Routed
|
||||
|
||||
+137
-2
@@ -9,6 +9,7 @@ package matrix
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
@@ -185,8 +186,20 @@ func walk(st *model.State, o Options, c *api.MatrixCell, chain, from, to string)
|
||||
for _, r := range rules {
|
||||
mark := []string{fmt.Sprintf("r%d", r.ID)}
|
||||
if r.Raw != "" {
|
||||
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw,
|
||||
Short: "a raw rule", Marks: mark})
|
||||
rr := readRaw(r.Raw)
|
||||
switch {
|
||||
case !rr.ok:
|
||||
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: "unknown", What: "a raw rule: " + r.Raw,
|
||||
Short: "a raw rule", Marks: mark})
|
||||
case !rr.forNew || chain == model.ChainEarly && rr.verdict == "allowed":
|
||||
// Not for new connections; an early accept decides nothing.
|
||||
case rr.match == "":
|
||||
c.Verdict, c.DecidedBy, c.RuleID, c.Marks = rr.verdict, "rule "+r.Label(), r.ID, mark
|
||||
return true
|
||||
default:
|
||||
c.Exceptions = append(c.Exceptions, api.MatrixException{RuleID: r.ID, Chain: chain, Label: "rule " + r.Label(), Verdict: rr.verdict, What: "raw: " + rr.match,
|
||||
Short: rr.short, Marks: mark})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !zones(r, from, to) || !forNew(r) {
|
||||
@@ -321,6 +334,128 @@ func forNew(r *model.Rule) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// raw is what the matrix can read of a raw rule.
|
||||
type raw struct {
|
||||
ok bool // read: it applies to every pair of its chain
|
||||
forNew bool // it can match a new connection
|
||||
verdict string // allowed or blocked
|
||||
match string // what it matches ("" for everything)
|
||||
short string
|
||||
}
|
||||
|
||||
// rawStops are words that make a raw rule's reach or effect unclear to
|
||||
// the matrix: interfaces (which zones?), jumps, NAT, changes, verdict maps.
|
||||
var rawStops = map[string]bool{
|
||||
"iifname": true, "oifname": true, "iif": true, "oif": true, "iiftype": true, "oiftype": true,
|
||||
"jump": true, "goto": true, "return": true, "vmap": true, "queue": true, "continue": true,
|
||||
"dnat": true, "snat": true, "masquerade": true, "redirect": true, "tproxy": true,
|
||||
"set": true, "notrack": true, "dup": true, "fwd": true, "synproxy": true, "flow": true,
|
||||
}
|
||||
|
||||
// readRaw reads a raw rule that is a match and then accept, drop or
|
||||
// reject: the verdict is the last word (but a reject's "with …"); counter
|
||||
// and log only count and log.
|
||||
func readRaw(text string) raw {
|
||||
toks := fields(text)
|
||||
for i, t := range toks {
|
||||
if t == "comment" {
|
||||
toks = toks[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
verdict := -1
|
||||
for i, t := range toks {
|
||||
if strings.HasPrefix(t, `"`) {
|
||||
continue
|
||||
}
|
||||
w := strings.Trim(t, ",{}")
|
||||
if rawStops[w] {
|
||||
return raw{}
|
||||
}
|
||||
if w == "accept" || w == "drop" || w == "reject" {
|
||||
verdict = i
|
||||
}
|
||||
}
|
||||
if verdict < 0 || verdict != len(toks)-1 && !(toks[verdict] == "reject" && toks[verdict+1] == "with") {
|
||||
return raw{}
|
||||
}
|
||||
rr := raw{ok: true, forNew: true, verdict: verdictOf(toks[verdict])}
|
||||
var match []string
|
||||
for i := 0; i < verdict; i++ {
|
||||
switch toks[i] {
|
||||
case "counter":
|
||||
continue
|
||||
case "log":
|
||||
// Its options run up to the next match.
|
||||
for i+1 < verdict && map[string]bool{"prefix": true, "level": true, "group": true, "flags": true}[toks[i+1]] {
|
||||
i += 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
match = append(match, toks[i])
|
||||
}
|
||||
rr.match = strings.Join(match, " ")
|
||||
// ct state without new: not for new connections. The states are one
|
||||
// word ("established,related") or a set ("{ established, related }").
|
||||
for i := 0; i+2 < len(match); i++ {
|
||||
if match[i] != "ct" || match[i+1] != "state" {
|
||||
continue
|
||||
}
|
||||
list := match[i+2]
|
||||
if list == "{" || strings.HasPrefix(list, "{") {
|
||||
var b []string
|
||||
for j := i + 2; j < len(match); j++ {
|
||||
b = append(b, match[j])
|
||||
if strings.HasSuffix(match[j], "}") {
|
||||
break
|
||||
}
|
||||
}
|
||||
list = strings.Join(b, " ")
|
||||
}
|
||||
rr.forNew = false
|
||||
for _, st := range strings.FieldsFunc(list, func(c rune) bool { return c == ',' || c == ' ' || c == '{' || c == '}' }) {
|
||||
rr.forNew = rr.forNew || st == "new"
|
||||
}
|
||||
}
|
||||
rr.short = rr.match
|
||||
if m := icmpRe.FindStringSubmatch(rr.match); m != nil {
|
||||
rr.short = m[1]
|
||||
}
|
||||
if len(rr.short) > 40 {
|
||||
rr.short = rr.short[:39] + "…"
|
||||
}
|
||||
return rr
|
||||
}
|
||||
|
||||
// fields splits a rule into words, a quoted string being one.
|
||||
func fields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
quoted := false
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case c == '"':
|
||||
quoted = !quoted
|
||||
cur.WriteRune(c)
|
||||
case !quoted && (c == ' ' || c == '\t'):
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// icmpRe finds an ICMP type match in a raw rule: shown as just icmp or
|
||||
// icmpv6.
|
||||
var icmpRe = regexp.MustCompile(`\b(icmpv6|icmp) type\b`)
|
||||
|
||||
// short sums up an exception for the overview: its services (ICMP types
|
||||
// as just icmp or icmpv6), with the addresses it is limited to, which
|
||||
// matter as much ("tcp/22 from trusted"); times and rates are left to
|
||||
|
||||
@@ -158,3 +158,42 @@ func TestSummary(t *testing.T) {
|
||||
t.Errorf("a drop policy's mark: %v", c.Marks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRaw: raw rules that are a match and a verdict are read; others stay
|
||||
// unknown.
|
||||
func TestRaw(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
raw, verdict, short string
|
||||
ok, forNew bool
|
||||
}{
|
||||
{"icmpv6 type { nd-router-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept", "allowed", "icmpv6", true, true},
|
||||
{`tcp dport 22 counter log prefix "ssh: " drop comment "x"`, "blocked", "tcp dport 22", true, true},
|
||||
{"ip saddr 10.0.0.1 reject with icmpx type admin-prohibited", "blocked", "ip saddr 10.0.0.1", true, true},
|
||||
{"ct state established,related accept", "allowed", "ct state established,related", true, false},
|
||||
{"ct state { new, untracked } tcp dport 80 accept", "allowed", "ct state { new, untracked } tcp dport 80", true, true},
|
||||
{"counter accept", "allowed", "", true, true},
|
||||
{`iifname "wan" tcp dport 22 accept`, "", "", false, false},
|
||||
{"tcp dport vmap { 22 : accept }", "", "", false, false},
|
||||
{"meta mark set 1 accept", "", "", false, false},
|
||||
{"tcp dport 22 counter", "", "", false, false},
|
||||
} {
|
||||
rr := readRaw(c.raw)
|
||||
if rr.ok != c.ok || c.ok && (rr.verdict != c.verdict || rr.short != c.short || rr.forNew != c.forNew) {
|
||||
t.Errorf("%q: %+v", c.raw, rr)
|
||||
}
|
||||
}
|
||||
// In a pair: an exception that lets ND in, not "unknown".
|
||||
st := state()
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: 90, Chain: "input", Position: 0.5, Raw: "icmpv6 type { nd-router-solicit } ip6 hoplimit 255 accept"})
|
||||
st.Sort()
|
||||
e := find(Build(st), "wan", Firewall).Exceptions
|
||||
found := false
|
||||
for _, x := range e {
|
||||
if x.RuleID == 90 {
|
||||
found = x.Verdict == "allowed" && x.Short == "icmpv6" && strings.HasPrefix(x.What, "raw: icmpv6 type")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the raw ND rule: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function render(el) {
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
|
||||
table([
|
||||
{ label: "Chain", render: (r) => r.chain },
|
||||
{ label: "Rule", render: (r) => r.raw || describe(r) },
|
||||
{ label: "Rule", render: (r) => h("a", { href: "#/rules?chain=" + r.chain + "&rule=" + r.id }, "#" + r.id + " ", r.raw || describe(r)) },
|
||||
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function render(el, params, query) {
|
||||
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, es.map((e) => h("tr", null,
|
||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
||||
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
||||
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function render(el) {
|
||||
}
|
||||
|
||||
function detail(c) {
|
||||
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
|
||||
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain + "&rule=" + id, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
|
||||
const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]);
|
||||
const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString());
|
||||
const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
|
||||
|
||||
@@ -24,7 +24,14 @@ const builtins = {
|
||||
};
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const chain = query.get("chain") || "input";
|
||||
// ?rule=ID (from the matrix, the log, the tracer, statistics): show
|
||||
// that rule, in its chain, highlighted.
|
||||
const target = Number(query.get("rule")) || 0;
|
||||
let chain = query.get("chain");
|
||||
if (!chain && target) {
|
||||
try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ }
|
||||
}
|
||||
chain = chain || "input";
|
||||
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
||||
// As nftables checks them: the rules that count traffic first.
|
||||
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
|
||||
@@ -40,7 +47,8 @@ export async function render(el, params, query) {
|
||||
h("div", { class: "panel" },
|
||||
builtinsRow(chain, settings, cs),
|
||||
table([
|
||||
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||||
{ label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||||
{ label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" },
|
||||
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
||||
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||
{ label: "Action", render: actionBadge },
|
||||
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
|
||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||
];
|
||||
} } : null,
|
||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }),
|
||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => rowClass(r) + (target && r.id === target ? " row-target" : "") }),
|
||||
rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
|
||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||||
if (target) {
|
||||
const row = el.querySelector("tr.row-target");
|
||||
if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" }));
|
||||
else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more)."));
|
||||
}
|
||||
}
|
||||
|
||||
// everyPacket: checked before established connections are accepted (as
|
||||
|
||||
@@ -180,7 +180,7 @@ function rangeRow(since) {
|
||||
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
|
||||
// built-ins under Settings).
|
||||
function ruleHref(mark) {
|
||||
if (/^r\d/.test(mark)) return "#/rules";
|
||||
if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1);
|
||||
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
|
||||
if (mark.startsWith("f")) return "#/nat";
|
||||
return null;
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function render(el, params, query) {
|
||||
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
|
||||
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
|
||||
h("td", { class: "muted small" }, s.table + " " + s.chain),
|
||||
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label)
|
||||
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain + "&rule=" + s.mark.slice(1).split("-")[0] }, s.label) : s.label)
|
||||
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
|
||||
h("td", { class: "small" }, s.verdict))))));
|
||||
}
|
||||
|
||||
@@ -433,6 +433,9 @@ td.matrix-blocked { background: var(--del); color: var(--del-text); }
|
||||
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
|
||||
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
|
||||
td.matrix-same { opacity: 0.55; }
|
||||
/* The rule a link pointed at (?rule=ID). */
|
||||
tr.row-target td { background: var(--accent-soft); font-weight: 600; }
|
||||
tr.row-target td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
|
||||
td.matrix-none { color: var(--muted); }
|
||||
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
||||
|
||||
@@ -344,6 +344,7 @@ not_contains "wg0_net"
|
||||
run "the internet to the firewall" 0 H matrix wan firewall
|
||||
contains "blocked *sources that can't come from outside"
|
||||
contains "allowed *udp/51820"
|
||||
contains "allowed *raw: icmpv6 type"
|
||||
|
||||
# ---- statistics (sampled every 3 seconds here)
|
||||
|
||||
|
||||
Reference in new issue
Block a user