From df2e465e1e4a126efe0b6696acf3c752277bde0d Mon Sep 17 00:00:00 2001 From: Jamkris Date: Fri, 10 Jul 2026 11:07:51 +0900 Subject: [PATCH 1/3] feat: render firewall rules in readable nft syntax --- CHANGELOG.md | 4 + bastion/services/nftexpr.py | 131 ++++++++++++++++++++++ bastion/web/app.py | 2 + bastion/web/templates/base.html | 2 + bastion/web/templates/views/firewall.html | 8 +- tests/test_nftexpr.py | 81 +++++++++++++ 6 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 bastion/services/nftexpr.py create mode 100644 tests/test_nftexpr.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ed5c7..4c0ea18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Home **trend sparklines** for banned IPs and attackers, backed by a capped time-series recorded by the poller. New `GET /api/history` endpoint. +### Changed +- Firewall page now renders rules in **readable nft syntax** (e.g. `tcp dport 22 + accept`) instead of raw JSON; the original JSON is available on hover. + ## [1.0.0] - 2026-07-10 Initial public release. diff --git a/bastion/services/nftexpr.py b/bastion/services/nftexpr.py new file mode 100644 index 0000000..59b3b62 --- /dev/null +++ b/bastion/services/nftexpr.py @@ -0,0 +1,131 @@ +"""Render an nftables JSON rule expression into readable nft-like syntax. + +`nft -j list ruleset` emits each rule's body as a list of statement objects. +Shown raw, that JSON is unreadable; this turns it into something close to what +`nft list ruleset` (non-JSON) prints, e.g. `tcp dport 22 accept` or +`ip saddr @allowlist accept`. + +Pure and defensive: any shape it doesn't recognise falls back to a compact +form rather than raising, so a novel rule never breaks the Firewall page. +""" + +from __future__ import annotations + +import json +from typing import Any + +# meta keys that nft prints without the leading "meta". +_BARE_META = {"iifname", "oifname", "iif", "oif", "mark", "l4proto", "nfproto"} +_VERDICTS = {"accept", "drop", "reject", "return", "continue", "masquerade"} + + +def render_expr(expr: Any) -> str: + """Render a rule expression (JSON string or parsed list) to nft-like text.""" + if isinstance(expr, str): + try: + items = json.loads(expr) + except ValueError: + return expr + else: + items = expr + if not isinstance(items, list): + return str(items) + parts = [_stmt(s) for s in items] + return " ".join(p for p in parts if p) + + +def _stmt(stmt: Any) -> str: + if not isinstance(stmt, dict) or len(stmt) != 1: + return _value(stmt) + key, val = next(iter(stmt.items())) + + if key in _VERDICTS: + return key + if key == "match": + return _match(val) + if key in ("jump", "goto"): + target = val.get("target") if isinstance(val, dict) else val + return f"{key} {target}" + if key == "counter": + return "counter" + if key == "log": + prefix = val.get("prefix") if isinstance(val, dict) else None + return f'log prefix "{prefix}"' if prefix else "log" + if key in ("dnat", "snat"): + return f"{key} to {_dnat_target(val)}" + if key == "mangle": + # {"mangle": {"key": , "value": }} -> " set " + return f"{_value(val.get('key'))} set {_value(val.get('value'))}".strip() + if key == "limit": + return "limit" + if key == "queue": + return "queue" + if key == "reject": + return "reject" + if key == "xt": + return "[iptables rule]" + # Unknown statement: show its key (and value if scalar). + if val in (None, {}, []): + return key + return f"{key} {_value(val)}" + + +def _match(val: Any) -> str: + if not isinstance(val, dict): + return _value(val) + op = val.get("op", "==") + left = _value(val.get("left")) + right = _value(val.get("right")) + if op == "==": + return f"{left} {right}".strip() + return f"{left} {op} {right}".strip() + + +def _dnat_target(val: Any) -> str: + if isinstance(val, dict): + addr = _value(val.get("addr")) if val.get("addr") is not None else "" + port = val.get("port") + return f"{addr}:{port}" if port is not None else addr + return _value(val) + + +def _value(v: Any) -> str: + if v is None: + return "" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return v + if isinstance(v, list): + return " . ".join(_value(x) for x in v) + if isinstance(v, dict): + return _dict_value(v) + return str(v) + + +def _dict_value(v: dict) -> str: + if "payload" in v: # {"payload": {"protocol": "tcp", "field": "dport"}} + p = v["payload"] + return f"{p.get('protocol', '')} {p.get('field', '')}".strip() or "payload" + if "meta" in v: # {"meta": {"key": "iifname"}} + key = v["meta"].get("key", "") + return key if key in _BARE_META else f"meta {key}".strip() + if "ct" in v: # {"ct": {"key": "state"}} + return f"ct {v['ct'].get('key', '')}".strip() + if "set" in v: # anonymous set {"set": ["a", "b"]} or named "@name" + elems = v["set"] + if isinstance(elems, list): + return "{ " + ", ".join(_value(e) for e in elems) + " }" + return _value(elems) + if "prefix" in v: # {"prefix": {"addr": "10.0.0.0", "len": 8}} + p = v["prefix"] + return f"{_value(p.get('addr'))}/{p.get('len')}" + if "range" in v: # {"range": [1, 1024]} + r = v["range"] + return f"{_value(r[0])}-{_value(r[1])}" if isinstance(r, list) and len(r) == 2 else _value(r) + if "concat" in v: + return " . ".join(_value(x) for x in v["concat"]) + # Unknown object: compact JSON so nothing is lost. + return json.dumps(v, ensure_ascii=False, separators=(",", ":")) diff --git a/bastion/web/app.py b/bastion/web/app.py index d51c792..fe40f8c 100644 --- a/bastion/web/app.py +++ b/bastion/web/app.py @@ -26,6 +26,7 @@ from bastion.ratelimit import RateLimiter from bastion.runner import CommandError from bastion.services import actions, allowlist, dashboard, geoip, ignoreip, notify +from bastion.services.nftexpr import render_expr from bastion.util import flag_emoji, port_scope, sparkline_points log = logging.getLogger("bastion") @@ -36,6 +37,7 @@ templates.env.globals["flag"] = flag_emoji templates.env.globals["scope"] = port_scope templates.env.globals["sparkline"] = sparkline_points +templates.env.globals["nft_expr"] = render_expr @asynccontextmanager async def _lifespan(app: FastAPI): diff --git a/bastion/web/templates/base.html b/bastion/web/templates/base.html index dc5f5fc..1ed83dd 100644 --- a/bastion/web/templates/base.html +++ b/bastion/web/templates/base.html @@ -98,6 +98,8 @@ .badge { background: #21262d; border-radius: 20px; padding: 1px 9px; font-size: 12px; color: #7d8590; } .num { font-variant-numeric: tabular-nums; } code { color: #79c0ff; } + .rule-expr { color: #adbac7; white-space: normal; word-break: break-word; } + .rule-expr:hover { color: #e6edf3; } .filterbar { padding: 8px 12px; border-bottom: 1px solid #21262d; background: #12161c; } .filterbar input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; diff --git a/bastion/web/templates/views/firewall.html b/bastion/web/templates/views/firewall.html index b8529fb..1a1f230 100644 --- a/bastion/web/templates/views/firewall.html +++ b/bastion/web/templates/views/firewall.html @@ -5,7 +5,7 @@
{{ error }}
{% elif ruleset %}
-

{{ t("th_type") }}: chain {{ ruleset.chains|length }}

+

{{ t("panel_firewall") }} · {{ t("fw_chains") }} {{ ruleset.chains|length }}

@@ -25,7 +25,7 @@

{{ t("th_type") }}: chain {{ ruleset.chains|le
-

{{ t("th_type") }}: set {{ ruleset.sets|length }}

+

{{ t("fw_sets") }} {{ ruleset.sets|length }}

{{ t("th_family_table") }}
@@ -45,7 +45,7 @@

{{ t("th_type") }}: set {{ ruleset.sets|length
-

{{ t("th_type") }}: rule {{ ruleset.rules|length }}

+

{{ t("fw_rules") }} {{ ruleset.rules|length }}

@@ -58,7 +58,7 @@

{{ t("th_type") }}: rule {{ ruleset.rules|leng {% for r in ruleset.rules %}

- + {% endfor %}
{{ r.family }}/{{ r.table }}{{ r.chain }} {{ r.handle }}{{ r.expr }}
{{ nft_expr(r.expr) }}
diff --git a/tests/test_nftexpr.py b/tests/test_nftexpr.py new file mode 100644 index 0000000..7fa1cf1 --- /dev/null +++ b/tests/test_nftexpr.py @@ -0,0 +1,81 @@ +import json + +from bastion.services.nftexpr import render_expr + + +def test_simple_dport_accept(): + expr = [ + {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, + {"accept": None}, + ] + assert render_expr(expr) == "tcp dport 22 accept" + + +def test_saddr_named_set_accept(): + expr = [ + {"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@bastion_allow"}}, + {"accept": None}, + ] + assert render_expr(expr) == "ip saddr @bastion_allow accept" + + +def test_not_equal_operator(): + expr = [ + {"match": {"op": "!=", "left": {"payload": {"protocol": "ip", "field": "daddr"}}, "right": "@nozapret"}}, + ] + assert render_expr(expr) == "ip daddr != @nozapret" + + +def test_anonymous_set_dport(): + expr = [ + {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, + "right": {"set": [80, 443]}}}, + {"accept": None}, + ] + assert render_expr(expr) == "tcp dport { 80, 443 } accept" + + +def test_meta_iifname_bare(): + expr = [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "enp4s0"}}] + assert render_expr(expr) == "iifname enp4s0" + + +def test_ct_state(): + expr = [ + {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": {"set": ["established", "related"]}}}, + {"accept": None}, + ] + assert render_expr(expr) == "ct state in { established, related } accept" + + +def test_jump_and_counter(): + expr = [{"counter": {"packets": 5, "bytes": 300}}, {"jump": {"target": "ufw-user-input"}}] + assert render_expr(expr) == "counter jump ufw-user-input" + + +def test_prefix_and_range(): + expr = [ + {"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, + "right": {"prefix": {"addr": "10.0.0.0", "len": 8}}}}, + ] + assert render_expr(expr) == "ip saddr 10.0.0.0/8" + + +def test_log_with_prefix(): + expr = [{"log": {"prefix": "[UFW BLOCK] "}}] + assert render_expr(expr) == 'log prefix "[UFW BLOCK] "' + + +def test_accepts_json_string_input(): + expr_str = json.dumps([{"drop": None}]) + assert render_expr(expr_str) == "drop" + + +def test_invalid_json_returns_input(): + assert render_expr("{not json") == "{not json" + + +def test_unknown_statement_falls_back_gracefully(): + expr = [{"weird_stmt": {"a": 1}}] + out = render_expr(expr) + assert "weird_stmt" in out # does not raise, keeps information From a9b2c67489f8ab8a0c38387d69100601d683ab0f Mon Sep 17 00:00:00 2001 From: Jamkris Date: Fri, 10 Jul 2026 11:14:24 +0900 Subject: [PATCH 2/3] feat: name iptables-nft (xt) matches; validate against real UFW rules --- bastion/services/nftexpr.py | 5 ++++- tests/test_nftexpr.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/bastion/services/nftexpr.py b/bastion/services/nftexpr.py index 59b3b62..8c7e90c 100644 --- a/bastion/services/nftexpr.py +++ b/bastion/services/nftexpr.py @@ -63,7 +63,10 @@ def _stmt(stmt: Any) -> str: if key == "reject": return "reject" if key == "xt": - return "[iptables rule]" + # iptables-nft compatibility match/target (e.g. UFW's `-m conntrack`). + # nft can't express it natively; surface the extension name. + name = val.get("name") if isinstance(val, dict) else None + return name or "xt" # Unknown statement: show its key (and value if scalar). if val in (None, {}, []): return key diff --git a/tests/test_nftexpr.py b/tests/test_nftexpr.py index 7fa1cf1..30c2bcf 100644 --- a/tests/test_nftexpr.py +++ b/tests/test_nftexpr.py @@ -79,3 +79,37 @@ def test_unknown_statement_falls_back_gracefully(): expr = [{"weird_stmt": {"a": 1}}] out = render_expr(expr) assert "weird_stmt" in out # does not raise, keeps information + + +# ---------- real UFW / iptables-nft rules from server1 ---------- +def test_ufw_iifname_lo_accept(): + expr = [ + {"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lo"}}, + {"counter": {"packets": 92, "bytes": 7604}}, + {"accept": None}, + ] + assert render_expr(expr) == "iifname lo counter accept" + + +def test_ufw_xt_conntrack_names_the_match(): + expr = [ + {"xt": {"type": "match", "name": "conntrack"}}, + {"counter": {"packets": 7860, "bytes": 994422}}, + {"drop": None}, + ] + assert render_expr(expr) == "conntrack counter drop" + + +def test_ufw_l4proto_icmp_xt_accept(): + expr = [ + {"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": "icmp"}}, + {"xt": {"type": "match", "name": "icmp"}}, + {"counter": {"packets": 362, "bytes": 33264}}, + {"accept": None}, + ] + assert render_expr(expr) == "l4proto icmp icmp counter accept" + + +def test_docker_jump_chain(): + expr = [{"counter": {"packets": 0, "bytes": 0}}, {"jump": {"target": "DOCKER-USER"}}] + assert render_expr(expr) == "counter jump DOCKER-USER" From eaf3d5a64ad992b54a7aa4d274631cb40f0f9ed6 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Fri, 10 Jul 2026 11:28:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20meta-?= =?UTF-8?q?prefix=20keys,=20implicit=20set=20membership,=20defensive=20fal?= =?UTF-8?q?lback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bastion/services/nftexpr.py | 19 ++++++++++++++----- tests/test_nftexpr.py | 21 +++++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/bastion/services/nftexpr.py b/bastion/services/nftexpr.py index 8c7e90c..60d937b 100644 --- a/bastion/services/nftexpr.py +++ b/bastion/services/nftexpr.py @@ -14,8 +14,8 @@ import json from typing import Any -# meta keys that nft prints without the leading "meta". -_BARE_META = {"iifname", "oifname", "iif", "oif", "mark", "l4proto", "nfproto"} +# meta keys that nft prints without the leading "meta" (others show as "meta "). +_BARE_META = {"iifname", "oifname", "iif", "oif"} _VERDICTS = {"accept", "drop", "reject", "return", "continue", "masquerade"} @@ -30,7 +30,12 @@ def render_expr(expr: Any) -> str: items = expr if not isinstance(items, list): return str(items) - parts = [_stmt(s) for s in items] + parts = [] + for s in items: + try: + parts.append(_stmt(s)) + except Exception: # a novel/malformed shape must never break the page + parts.append(json.dumps(s, ensure_ascii=False, separators=(",", ":"))) return " ".join(p for p in parts if p) @@ -55,7 +60,9 @@ def _stmt(stmt: Any) -> str: return f"{key} to {_dnat_target(val)}" if key == "mangle": # {"mangle": {"key": , "value": }} -> " set " - return f"{_value(val.get('key'))} set {_value(val.get('value'))}".strip() + if isinstance(val, dict): + return f"{_value(val.get('key'))} set {_value(val.get('value'))}".strip() + return f"mangle {_value(val)}".strip() if key == "limit": return "limit" if key == "queue": @@ -79,7 +86,9 @@ def _match(val: Any) -> str: op = val.get("op", "==") left = _value(val.get("left")) right = _value(val.get("right")) - if op == "==": + # nft prints set membership implicitly (`ct state { ... }`), so "==" and "in" + # carry no visible operator. + if op in ("==", "in"): return f"{left} {right}".strip() return f"{left} {op} {right}".strip() diff --git a/tests/test_nftexpr.py b/tests/test_nftexpr.py index 30c2bcf..24d2ce5 100644 --- a/tests/test_nftexpr.py +++ b/tests/test_nftexpr.py @@ -41,11 +41,28 @@ def test_meta_iifname_bare(): def test_ct_state(): + # nft prints set membership implicitly — no visible "in" operator. expr = [ {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": {"set": ["established", "related"]}}}, {"accept": None}, ] - assert render_expr(expr) == "ct state in { established, related } accept" + assert render_expr(expr) == "ct state { established, related } accept" + + +def test_meta_mark_keeps_meta_prefix(): + # Only iifname/oifname/iif/oif are bare; other meta keys show "meta ". + expr = [{"match": {"op": "==", "left": {"meta": {"key": "mark"}}, "right": 1}}] + assert render_expr(expr) == "meta mark 1" + + +def test_mangle_non_dict_does_not_raise(): + assert render_expr([{"mangle": None}]) # must not raise; returns some string + + +def test_malformed_nested_shape_falls_back(): + # payload as a bare string (not the expected object) must not raise. + out = render_expr([{"match": {"op": "==", "left": {"payload": "weird"}, "right": 1}}]) + assert isinstance(out, str) and out def test_jump_and_counter(): @@ -107,7 +124,7 @@ def test_ufw_l4proto_icmp_xt_accept(): {"counter": {"packets": 362, "bytes": 33264}}, {"accept": None}, ] - assert render_expr(expr) == "l4proto icmp icmp counter accept" + assert render_expr(expr) == "meta l4proto icmp icmp counter accept" def test_docker_jump_chain():