Skip to content

ufw: add UfwStatus + UfwRules facts and service/default/logging/rule ops#1671

Open
wowi42 wants to merge 5 commits into
pyinfra-dev:3.xfrom
KalvadTech:feat/ufw
Open

ufw: add UfwStatus + UfwRules facts and service/default/logging/rule ops#1671
wowi42 wants to merge 5 commits into
pyinfra-dev:3.xfrom
KalvadTech:feat/ufw

Conversation

@wowi42

@wowi42 wowi42 commented Apr 17, 2026

Copy link
Copy Markdown
Collaborator

Adds a new ufw module (facts + operations) so pyinfra can manage Uncomplicated Firewall state and rules on Debian/Ubuntu hosts idempotently, matching the conventions of the existing iptables module.

Facts (pyinfra.facts.ufw)

  • UfwStatus parses ufw status verbose into a structured dict:

    {
        "active": True,
        "logging": "low",
        "default": {"incoming": "deny", "outgoing": "allow", "routed": "disabled"},
        "new_profiles": "skip",
    }

    The inactive case returns just {"active": False}.

  • UfwRules parses ufw show added into a list of canonical rule dicts with keys action, direction, interface, from_, to, port, proto, app, log, comment. Non-ufw … lines (the header, blanks) are skipped.

Both facts declare requires_command = "ufw" so hosts without UFW (Alpine / FreeBSD in the test fleet) short-circuit cleanly.

Operations (pyinfra.operations.ufw)

All operations read one fact up front and call host.noop() when the host is already in the desired state.

  • service(enabled=True, reload=False): ufw --force enable / ufw disable. reload=True triggers ufw reload when already active (no idempotency check on reload — it's an action).
  • default(policy, direction="incoming"): policy in {allow, deny, reject}, direction in {incoming, outgoing, routed}.
  • logging(level): accepts True / False or off / on / low / medium / high / full.
  • rule(action, port=None, proto=None, from_ip=None, to_ip=None, direction=None, interface=None, app=None, log=False, comment=None, present=True): structured kwargs → canonical rule dict → membership check against UfwRules; emits ufw … or ufw delete ….

Idempotency guarantee

A shared _canonical_rule / _render_rule pair in facts/ufw.py is used by both the fact parser and the operation renderer, so the invariant parse(render(rule)) == rule holds. That is what makes rule(...) noop on re-runs even though UFW itself will happily create duplicate rules.

Tests

  • Facts: tests/facts/ufw.UfwStatus/{active,inactive}.json, tests/facts/ufw.UfwRules/mixed.json (limit, allow with no proto, full direction+interface+from+to+proto, deny-by-source).
  • Operations: full matrix under tests/operations/ufw.{service,default,logging,rule}/… covering mutation + noop + complex rule cases.

Full suite pytest tests/ is green (1563 passing), ruff check and ruff format --check clean.

Scope not included (deferred)

Application profiles (ufw app list/info), reset, numbered-insert, delete-by-number. Can ship in a follow-up if desired.

Live verification

Tested on Ubuntu 0.36.2: real ufw status verbose and ufw show added output parses correctly, and the render-then-reparse roundtrip returns identical canonical rule dicts.

  • Pull request is based on the default branch (3.x at this time)
  • Pull request includes tests for any new/updated operations/facts
  • Pull request includes documentation for any new/updated operations/facts
  • Tests pass (see scripts/dev-test.sh)
  • Type checking & code style passes (see scripts/dev-lint.sh)

Facts:

- UfwStatus parses `ufw status verbose` into {active, logging, default,
  new_profiles}. Handles the inactive case (no verbose body).
- UfwRules parses `ufw show added` into a list of canonical rule dicts
  (action, direction, interface, from_, to, port, proto, app, log,
  comment).

Operations (all idempotent via the facts above):

- service(enabled, reload): toggles ufw via `ufw --force enable` /
  `ufw disable`; reload=True triggers `ufw reload` when already active.
- default(policy, direction): noop when the existing default already
  matches, else `ufw default <policy> <direction>`.
- logging(level): accepts bool or one of off/on/low/medium/high/full.
- rule(action, port, proto, from_ip, to_ip, direction, interface, app,
  log, comment, present): builds a canonical dict, compares against
  UfwRules membership and emits `ufw ...` or `ufw delete ...`.

A shared `_canonical_rule`/`_render_rule` pair in facts/ufw.py is used
by both the fact parser and the operation renderer so that parse(render
(rule)) == rule; that invariant is what makes idempotency hold.

Verified live on ubuntu 10.0.10.23 (`ufw 0.36.2`): parsing the actual
status/show-added output and running a rule render+reparse roundtrip
both succeed. Alpine and FreeBSD do not ship ufw, so the
`requires_command = "ufw"` declaration short-circuits cleanly there.
@wowi42 wowi42 changed the title ufw: add facts and operations for Uncomplicated Firewall ufw: add UfwStatus + UfwRules facts and service/default/logging/rule ops Apr 17, 2026
@wowi42 wowi42 added new feature operations Issues with operations. facts Issues with facts. labels May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

facts Issues with facts. new feature operations Issues with operations.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant