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 +13 -0
- nftgen/__main__.py +6 -0
- nftgen/cli.py +209 -0
- nftgen/definitions.py +180 -0
- nftgen/generate.py +334 -0
- nftgen/ir.py +588 -0
- nftgen/protocols.py +38 -0
- nftgen/rules.py +1191 -0
- nftgen/schema.py +317 -0
- nftgen/validate.py +89 -0
- nftgen-1.0.0.dist-info/METADATA +149 -0
- nftgen-1.0.0.dist-info/RECORD +15 -0
- nftgen-1.0.0.dist-info/WHEEL +4 -0
- nftgen-1.0.0.dist-info/entry_points.txt +2 -0
- nftgen-1.0.0.dist-info/licenses/LICENSE +21 -0
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
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
|