nftgen 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nftgen/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """nftgen — a small, nftables-only firewall-as-code generator.
2
+
3
+ YAML definitions + host policies -> a native nftables ruleset. See DESIGN.md.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ # Single source of truth is pyproject.toml; read it from installed metadata
10
+ # so __version__ can never drift from the packaged version.
11
+ __version__ = version("nftgen")
12
+ except PackageNotFoundError: # running from a source tree without an install
13
+ __version__ = "0.0.0+source"
nftgen/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from nftgen.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
nftgen/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ """Command-line entry point for nftgen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import functools
8
+ import os
9
+ import pathlib
10
+ import sys
11
+
12
+ import yaml
13
+
14
+ from nftgen import __version__, validate
15
+ from nftgen.definitions import DefinitionError
16
+ from nftgen.generate import build, generate
17
+ from nftgen.ir import BuildError
18
+
19
+
20
+ def _clean_errors(fn):
21
+ """Authoring mistakes get a one-line `nftgen: error:` instead of a traceback.
22
+ Anything else is a bug in nftgen: one line saying so, the traceback only
23
+ with NFTGEN_DEBUG=1."""
24
+
25
+ @functools.wraps(fn)
26
+ def wrapper(*args, **kwargs):
27
+ try:
28
+ return fn(*args, **kwargs)
29
+ except (BuildError, DefinitionError, FileNotFoundError, yaml.YAMLError) as e:
30
+ print(f"nftgen: error: {e}", file=sys.stderr)
31
+ return 1
32
+ except BrokenPipeError:
33
+ # the reader went away (`… --stdout | head`): stop quietly, and
34
+ # point stdout at /dev/null so the exit flush doesn't fail again
35
+ with contextlib.suppress(OSError, ValueError): # not a real file
36
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
37
+ return 141 # 128 + SIGPIPE, the shell convention
38
+ except OSError as e:
39
+ # permission denied, a directory where a file goes, a full disk:
40
+ # the environment, not a bug in nftgen
41
+ print(f"nftgen: error: {e}", file=sys.stderr)
42
+ return 1
43
+ except Exception as e:
44
+ if os.environ.get("NFTGEN_DEBUG"):
45
+ raise
46
+ print(
47
+ f"nftgen: internal error: {type(e).__name__}: {e} — this is a bug "
48
+ f"in nftgen, please report it (NFTGEN_DEBUG=1 shows the traceback)",
49
+ file=sys.stderr,
50
+ )
51
+ return 70 # EX_SOFTWARE
52
+
53
+ return wrapper
54
+
55
+
56
+ def build_parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(
58
+ prog="nftgen",
59
+ description="nftables-only firewall generator (YAML -> .nft)",
60
+ )
61
+ parser.add_argument("--version", action="version", version=f"nftgen {__version__}")
62
+ parser.add_argument("policy", nargs="?", help="host policy YAML file")
63
+ parser.add_argument("--defs", help="definitions dir (default: <root>/definitions)")
64
+ parser.add_argument("--base", help="include base dir (default: the policies dir)")
65
+ parser.add_argument("--sites", help="sites dir (default: <root>/sites)")
66
+ parser.add_argument("--out", help="output .nft file (default: stdout)")
67
+ parser.add_argument(
68
+ "--check", action="store_true", help="validate the output with `nft -c -f`"
69
+ )
70
+ return parser
71
+
72
+
73
+ def _defaults(policy: pathlib.Path):
74
+ """For a <root>/policies/hosts/<host>.yaml layout, infer defs/base/sites."""
75
+ include_base = policy.parent.parent # .../policies
76
+ root = policy.parents[2] if len(policy.parents) >= 3 else policy.parent
77
+ return root / "definitions", include_base, root / "sites"
78
+
79
+
80
+ @_clean_errors
81
+ def _build_cmd(argv: list[str]) -> int:
82
+ """`nftgen build <root>` — regenerate generated/<host>.nft for every host."""
83
+ parser = argparse.ArgumentParser(
84
+ prog="nftgen build",
85
+ description="generate generated/<host>.nft for every host under <root>",
86
+ )
87
+ parser.add_argument("root", help="project root (definitions/, sites/, policies/)")
88
+ parser.add_argument("--host", help="build only this host (default: all)")
89
+ parser.add_argument("--out-dir", help="output dir (default: <root>/generated)")
90
+ parser.add_argument(
91
+ "--stdout",
92
+ action="store_true",
93
+ help="print the ruleset to stdout, write nothing (requires --host)",
94
+ )
95
+ parser.add_argument(
96
+ "--check", action="store_true", help="validate each output with `nft -c -f`"
97
+ )
98
+ args = parser.parse_args(argv)
99
+
100
+ # stdout is a single stream, so it maps to exactly one host.
101
+ if args.stdout and not args.host:
102
+ print(
103
+ "nftgen: error: --stdout requires --host (stdout is a single stream "
104
+ "— one host)",
105
+ file=sys.stderr,
106
+ )
107
+ return 2
108
+
109
+ # Probe before generating: a --check that can't run must fail loudly, not
110
+ # silently skip validation the caller (CI, the Ansible play) asked for.
111
+ if args.check and not validate.can_check():
112
+ print(
113
+ "nftgen: error: --check requested but `nft -c` is not usable here "
114
+ "(nft missing, or no netlink and no unshare fallback)",
115
+ file=sys.stderr,
116
+ )
117
+ return 2
118
+
119
+ results = build(args.root, host=args.host)
120
+
121
+ # --stdout: emit the one host's ruleset and write nothing (previews, pipes,
122
+ # a deployer rendering on the fly without touching the committed tree).
123
+ if args.stdout:
124
+ text = next(iter(results.values()))
125
+ rc = 0
126
+ if args.check:
127
+ result = validate.check(text)
128
+ if not result.ok:
129
+ print(
130
+ f"nftgen: {args.host}: nft -c FAILED:\n{result.stderr}",
131
+ file=sys.stderr,
132
+ )
133
+ rc = 1
134
+ sys.stdout.write(text)
135
+ sys.stdout.flush() # a closed pipe fails here, inside the guard
136
+ return rc
137
+
138
+ out_dir = (
139
+ pathlib.Path(args.out_dir)
140
+ if args.out_dir
141
+ else pathlib.Path(args.root) / "generated"
142
+ )
143
+ out_dir.mkdir(parents=True, exist_ok=True)
144
+ rc = 0
145
+ for name, text in sorted(results.items()):
146
+ path = out_dir / f"{name}.nft"
147
+ path.write_text(text)
148
+ print(f"wrote {path}", file=sys.stderr)
149
+ if args.check:
150
+ result = validate.check(text)
151
+ if not result.ok:
152
+ print(
153
+ f"nftgen: {name}: nft -c FAILED:\n{result.stderr}", file=sys.stderr
154
+ )
155
+ rc = 1
156
+ return rc
157
+
158
+
159
+ @_clean_errors
160
+ def main(argv: list[str] | None = None) -> int:
161
+ argv = list(sys.argv[1:] if argv is None else argv)
162
+ if argv and argv[0] == "build":
163
+ return _build_cmd(argv[1:])
164
+
165
+ args = build_parser().parse_args(argv)
166
+ if not args.policy:
167
+ build_parser().print_help()
168
+ return 0
169
+
170
+ # Same probe as `build --check`, before rendering: a check that can't run
171
+ # must fail loudly, not after output was already written.
172
+ if args.check and not validate.can_check():
173
+ print(
174
+ "nftgen: error: --check requested but `nft -c` is not usable here "
175
+ "(nft missing, or no netlink and no unshare fallback)",
176
+ file=sys.stderr,
177
+ )
178
+ return 2
179
+
180
+ policy = pathlib.Path(args.policy)
181
+ def_default, base_default, sites_default = _defaults(policy)
182
+ text = generate(
183
+ policy,
184
+ defs_dir=args.defs or def_default,
185
+ include_base=args.base or base_default,
186
+ sites_dir=args.sites or sites_default,
187
+ )
188
+
189
+ if args.out:
190
+ out = pathlib.Path(args.out)
191
+ out.parent.mkdir(parents=True, exist_ok=True)
192
+ out.write_text(text)
193
+ print(f"wrote {out}", file=sys.stderr)
194
+ else:
195
+ sys.stdout.write(text)
196
+ sys.stdout.flush()
197
+
198
+ if args.check:
199
+ result = validate.check(text)
200
+ if result.ok:
201
+ print("nftgen: nft -c passed", file=sys.stderr)
202
+ else:
203
+ print(f"nftgen: nft -c FAILED:\n{result.stderr}", file=sys.stderr)
204
+ return 1
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ sys.exit(main())
nftgen/definitions.py ADDED
@@ -0,0 +1,180 @@
1
+ """Definitions: networks / services / interfaces, with composition.
2
+
3
+ A definition value is a list whose items are either a literal (an IP/CIDR, a
4
+ ``port/proto``, or an interface device name) or the name of another group in the
5
+ same category (composition, resolved recursively). Groups merge across files;
6
+ a host may overlay one or more per-site definition files. Names are unique
7
+ within a category — a duplicate (including a site overlay redefining a common
8
+ name) is an error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import pathlib
15
+ from collections.abc import Iterable, Mapping
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ from nftgen.protocols import PORT_PROTOS, proto_name
21
+
22
+ CATEGORIES = ("networks", "services", "interfaces")
23
+
24
+
25
+ class DefinitionError(Exception):
26
+ """A definition is malformed, duplicated, or references something undefined."""
27
+
28
+
29
+ class Definitions:
30
+ def __init__(self) -> None:
31
+ self.networks: dict[str, list] = {}
32
+ self.services: dict[str, list] = {}
33
+ self.interfaces: dict[str, list] = {}
34
+ # (category, name) -> the source that first defined it, for dual-origin
35
+ # provenance in duplicate errors.
36
+ self._origin: dict[tuple[str, str], str] = {}
37
+
38
+ # -- construction -------------------------------------------------------- #
39
+ @classmethod
40
+ def load(
41
+ cls, def_dir: str | pathlib.Path, site_files: Iterable[str | pathlib.Path] = ()
42
+ ) -> Definitions:
43
+ """Merge every ``*.yaml`` under ``def_dir`` recursively (common), then site overlays."""
44
+ def_dir = pathlib.Path(def_dir)
45
+ if not def_dir.is_dir():
46
+ # A missing dir would silently yield empty definitions, degrading
47
+ # every group reference downstream — fail here instead.
48
+ raise DefinitionError(f"definitions directory not found: {def_dir}")
49
+ defs = cls()
50
+ for path in sorted(def_dir.rglob("*.y*ml")):
51
+ defs._merge(yaml.safe_load(path.read_text()) or {}, str(path))
52
+ for sf in map(pathlib.Path, site_files):
53
+ if not sf.is_file():
54
+ raise DefinitionError(f"site definitions file not found: {sf}")
55
+ defs._merge(yaml.safe_load(sf.read_text()) or {}, str(sf))
56
+ return defs
57
+
58
+ @classmethod
59
+ def from_mappings(cls, *mappings: Mapping[str, Any]) -> Definitions:
60
+ """Build directly from in-memory mappings (tests / programmatic use)."""
61
+ defs = cls()
62
+ for i, mapping in enumerate(mappings):
63
+ defs._merge(mapping or {}, f"<mapping {i}>")
64
+ return defs
65
+
66
+ @classmethod
67
+ def from_named_mappings(
68
+ cls, mappings: Mapping[str, Mapping[str, Any]]
69
+ ) -> Definitions:
70
+ """Build from named layers ``{layer_name: mapping}``.
71
+
72
+ The layer name is the provenance reported in duplicate errors (both the
73
+ first-seen and the duplicating layer). Layers merge in sorted-name order
74
+ — determinism, not precedence: names are unique, so order never changes
75
+ the result, only which duplicate is reported first. This is the surface
76
+ the Ansible vars front-end feeds (layer name == the chunk var name).
77
+ """
78
+ defs = cls()
79
+ for name in sorted(mappings):
80
+ defs._merge(mappings[name] or {}, name)
81
+ return defs
82
+
83
+ def _merge(self, data: Mapping[str, Any], source: str) -> None:
84
+ if not isinstance(data, Mapping):
85
+ # A malformed layer (e.g. a list where a mapping was expected) would
86
+ # otherwise blow up with an opaque AttributeError deep in the loop.
87
+ raise DefinitionError(
88
+ f"definition layer {source!r} must be a mapping of "
89
+ f"category -> {{name: list}}, got {type(data).__name__}"
90
+ )
91
+ for category in CATEGORIES:
92
+ cat_data = data.get(category) or {}
93
+ if not isinstance(cat_data, Mapping):
94
+ raise DefinitionError(
95
+ f"{category} in {source} must be a mapping of name -> list, "
96
+ f"got {type(cat_data).__name__}"
97
+ )
98
+ target = getattr(self, category)
99
+ for name, value in cat_data.items():
100
+ if name in target:
101
+ first = self._origin.get((category, name), "?")
102
+ raise DefinitionError(
103
+ f"duplicate {category} definition {name!r} ({first}, {source})"
104
+ )
105
+ if not isinstance(value, list):
106
+ raise DefinitionError(
107
+ f"{category} {name!r} must be a list (in {source})"
108
+ )
109
+ target[name] = value
110
+ self._origin[(category, name)] = source
111
+
112
+ # -- resolution ---------------------------------------------------------- #
113
+ def network(self, name: str) -> list[str]:
114
+ """Resolve a network group to an ordered, deduped list of IP/CIDR strings."""
115
+ return self._expand("networks", self.networks, name, _net_leaf, ())
116
+
117
+ def service(self, name: str) -> list[tuple[str, str]]:
118
+ """Resolve a service group to ordered, deduped (proto, port) pairs."""
119
+ return self._expand("services", self.services, name, _svc_leaf, ())
120
+
121
+ def interface(self, name: str) -> list[str]:
122
+ """Resolve an interface group to an ordered, deduped list of device names."""
123
+ return self._expand("interfaces", self.interfaces, name, _iface_leaf, ())
124
+
125
+ def service_ports(self, name: str, proto: str) -> list[str]:
126
+ """Just the ports of a service for one protocol (e.g. tcp)."""
127
+ return [port for p, port in self.service(name) if p == proto]
128
+
129
+ def _expand(self, category, table, name, leaf, stack: tuple) -> list:
130
+ if name not in table:
131
+ raise DefinitionError(f"undefined {category[:-1]}: {name!r}")
132
+ if name in stack:
133
+ raise DefinitionError(
134
+ f"{category} definition cycle: {' -> '.join((*stack, name))}"
135
+ )
136
+ stack = (*stack, name)
137
+ out: list = []
138
+ for item in table[name]:
139
+ key = str(item)
140
+ # A group referencing itself is meaningless as composition; read it
141
+ # as a literal so `eth0: [eth0]` names a one-device group.
142
+ if key in table and key != name:
143
+ out.extend(self._expand(category, table, key, leaf, stack))
144
+ else:
145
+ out.extend(leaf(key, name))
146
+ return list(dict.fromkeys(out)) # dedupe, preserve order
147
+
148
+
149
+ # -- leaf parsers ------------------------------------------------------------ #
150
+ def _net_leaf(item: str, group: str) -> list[str]:
151
+ try:
152
+ ipaddress.ip_network(item, strict=False)
153
+ except ValueError as e:
154
+ raise DefinitionError(
155
+ f"network {group!r}: {item!r} is not a known group or an IP/CIDR ({e})"
156
+ ) from e
157
+ return [item] # preserve the author's form
158
+
159
+
160
+ def _svc_leaf(item: str, group: str) -> list[tuple[str, str]]:
161
+ if "/" not in item:
162
+ raise DefinitionError(
163
+ f"service {group!r}: {item!r} must be 'port/proto' or a known service"
164
+ )
165
+ port, written = item.rsplit("/", 1)
166
+ if not port or not written:
167
+ raise DefinitionError(f"service {group!r}: malformed entry {item!r}")
168
+ # one spelling everywhere: `53/17` is stored as udp
169
+ proto = proto_name(written)
170
+ if proto not in PORT_PROTOS:
171
+ raise DefinitionError(
172
+ f"service {group!r}: {item!r} — {written} has no ports; services are "
173
+ f"port/protocol pairs ({', '.join(PORT_PROTOS)}). Match {written} "
174
+ f"with `proto: {written}` in a rule instead"
175
+ )
176
+ return [(proto, port)]
177
+
178
+
179
+ def _iface_leaf(item: str, group: str) -> list[str]:
180
+ return [item] # any non-group string is a literal device name