From 96be667c01134acd376a34e32a144363dbb214cd Mon Sep 17 00:00:00 2001 From: =?utf8?q?Martin=20Mare=C5=A1?= Date: Sun, 23 Nov 2025 12:37:02 +0100 Subject: [PATCH] Add support for views (semi-automatic) --- README.md | 7 +++++++ TODO | 1 - example/__init__.py | 4 ++++ nsconfig/cli.py | 9 +++++++-- nsconfig/core.py | 36 ++++++++++++++++++++++++++---------- nsconfig/daemon/bind.py | 2 +- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 06f8d0f..03ed417 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ Dependencies: dnspython, texttable Integration with BIND + +## Random hints + +- If you want to use multiple views in BIND: NSC can generate + different views of the same zone (they are stored as "zone/$VIEW/$ZONE") + and configuration file fragments ("config/zone/$VIEW/$ZONE"), + which you include from your master configuration file. diff --git a/TODO b/TODO index f3ad0e9..51f4125 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,3 @@ - DNSSEC - Logging - More records -- Multiple versions of a single zone (for views) diff --git a/example/__init__.py b/example/__init__.py index c01c23e..c987698 100644 --- a/example/__init__.py +++ b/example/__init__.py @@ -16,6 +16,10 @@ for rev in ['10.1.0.0/16', '10.2.0.0/16', 'fd12:3456:789a::/48']: rz.root.NS('ns1.example.org', 'ns2.example.org') nsc.add_zone('example.net', secondary_to='10.42.0.1') +nsc.add_zone('example.net', secondary_to='10.42.0.1', view='internal') + +nsc.add_zone('example.gov') +nsc.add_zone('example.gov', view='internal') rz = nsc.add_zone(reverse_for='10.3.0.0/16') rz.delegate_classless('10.3.16.0/20').NS('ns1.example.org') diff --git a/nsconfig/cli.py b/nsconfig/cli.py index ee8b516..7bebfc6 100644 --- a/nsconfig/cli.py +++ b/nsconfig/cli.py @@ -16,6 +16,7 @@ def do_test(nsc: Nsc, args: Namespace) -> None: for z in nsc.get_zones(): print(f'Zone: {z.name}') + print(f'View: {z.config.view or "-"}') print(f'Type: {z.zone_type.name}') if isinstance(z, NscZonePrimary): if z.aliases: @@ -25,8 +26,9 @@ def do_test(nsc: Nsc, args: Namespace) -> None: print(f'Old hash: {z.prev_state.hash}') print(f'New serial: {z.state.serial}') print(f'New hash: {z.state.hash}') - out_file = zone_dir / z.safe_name + out_file = zone_dir / z.file_name print(f'Dumping to: {out_file}') + out_file.parent.mkdir(parents=True, exist_ok=True) with open(out_file, 'w') as f: z.dump(file=f) elif isinstance(z, NscZoneSecondary): @@ -69,6 +71,8 @@ def do_status(nsc: Nsc, args: Namespace) -> None: name = z.name if len(name) > 30 and not args.long: name = name[:16] + '[...]' + name[-16:] + if z.config.view: + name += ' (' + z.config.view + ')' if do_show: table.add_row([action, name, z.zone_type.name, status]) @@ -80,7 +84,8 @@ def do_update(nsc: Nsc) -> None: for z in nsc.get_zones(): if isinstance(z, NscZonePrimary) and z.is_changed(): - print(f'Updating zone {z.name} (serial {z.state.serial})') + in_view = ' (' + z.config.view + ')' if z.config.view else "" + print(f'Updating zone {z.name}{in_view} (serial {z.state.serial})') z.write_zone() nsc.daemon.reload_zone(z) for alias in z.aliases: diff --git a/nsconfig/core.py b/nsconfig/core.py index 1eb12a8..91c6a35 100644 --- a/nsconfig/core.py +++ b/nsconfig/core.py @@ -138,6 +138,7 @@ class NscZoneConfig: daemon_options: List[str] add_null_mx: bool name_parse_mode: NameParseMode + view: str # "" for main default_config: Optional['NscZoneConfig'] = None @@ -154,6 +155,7 @@ class NscZoneConfig: add_daemon_options: Optional[List[str]] = None, add_null_mx: Optional[bool] = None, name_parse_mode: Optional[NameParseMode] = None, + view: Optional[str] = None, inherit_config: Optional['NscZoneConfig'] = None, ) -> None: if inherit_config is None: @@ -168,6 +170,7 @@ class NscZoneConfig: self.daemon_options = daemon_options if daemon_options is not None else inherit_config.daemon_options self.add_null_mx = add_null_mx if add_null_mx is not None else inherit_config.add_null_mx self.name_parse_mode = name_parse_mode if name_parse_mode is not None else inherit_config.name_parse_mode + self.view = view if view is not None else inherit_config.view if add_daemon_options is not None: self.daemon_options += add_daemon_options @@ -192,6 +195,7 @@ NscZoneConfig.default_config = NscZoneConfig( daemon_options=[], add_null_mx=False, name_parse_mode=NameParseMode.absolute, + view="", ) @@ -216,6 +220,7 @@ class NscZoneState: pass def save(self, file: Path) -> None: + file.parent.mkdir(parents=True, exist_ok=True) new_file = Path(str(file) + '.new') with open(new_file, 'w') as f: js = { @@ -236,7 +241,7 @@ class NscZone: nsc: 'Nsc' name: str dns_name: Name - safe_name: str # For use in file names + file_name: str # May contain slashes zone_type: ZoneType config: NscZoneConfig reverse_for: Optional[IPNetwork] @@ -249,8 +254,10 @@ class NscZone: self.nsc = nsc self.name = name self.dns_name = dns.name.from_text(name) - self.safe_name = name.replace('/', '@') self.config = NscZoneConfig(**kwargs).finalize() + self.file_name = name.replace('/', '@') + if self.config.view: + self.file_name = f'{self.config.view}/{self.file_name}' self.reverse_for = reverse_for def process(self) -> None: @@ -272,8 +279,8 @@ class NscZonePrimary(NscZone): super().__init__(*args, **kwargs) self.zone_type = ZoneType.primary - self.zone_file = self.nsc.zone_dir / self.safe_name - self.state_file = self.nsc.state_dir / (self.safe_name + '.json') + self.zone_file = self.nsc.zone_dir / self.file_name + self.state_file = self.nsc.state_dir / (self.file_name + '.json') self.state = NscZoneState() self.prev_state = NscZoneState() @@ -315,8 +322,9 @@ class NscZonePrimary(NscZone): return n def zone_header(self) -> str: + view = f' (view {self.config.view})' if self.config.view else "" return ( - f'; Zone file for {self.name}\n' + f'; Zone file for {self.name}{view}\n' + '; Generated by NSC, please do not edit manually.\n' + '\n') @@ -378,6 +386,7 @@ class NscZonePrimary(NscZone): def write_zone(self) -> None: self.update_soa() + self.zone_file.parent.mkdir(parents=True, exist_ok=True) new_file = Path(str(self.zone_file) + '.new') with open(new_file, 'w') as f: self.dump(file=f) @@ -432,7 +441,10 @@ class NscZoneSecondary(NscZone): super().__init__(*args, **kwargs) self.zone_type = ZoneType.secondary self.primary_server = primary_server - self.secondary_file = self.nsc.secondary_dir / self.safe_name + self.secondary_file = self.nsc.secondary_dir / self.file_name + + def process(self) -> None: + self.secondary_file.parent.mkdir(parents=True, exist_ok=True) class NscZoneAlias(NscZone): @@ -452,7 +464,7 @@ class NscZoneAlias(NscZone): class Nsc: start_time: datetime - zones: Dict[str, NscZone] + zones: Dict[Tuple[str, str], NscZone] # key is (zone name, view) default_zone_config: NscZoneConfig ipv4_reverse: DefaultDict[IPv4Address, List[Name]] ipv6_reverse: DefaultDict[IPv6Address, List[Name]] @@ -536,7 +548,6 @@ class Nsc: reverse_for = ip_network(reverse_for, strict=True) name = name or self._reverse_zone_name(reverse_for) assert name is not None - assert name not in self.zones z: NscZone if alias_for is not None: @@ -549,11 +560,16 @@ class Nsc: secondary_to = ip_address(secondary_to) z = NscZoneSecondary(self, name, reverse_for=reverse_for, primary_server=secondary_to, inherit_config=inherit_config, **kwargs) - self.zones[name] = z + key = (name, z.config.view) + assert key not in self.zones + self.zones[key] = z return z def __getitem__(self, name: str) -> NscZone: - return self.zones[name] + return self.zones[name, ""] + + def get_zone(self, name: str, view: str = "") -> NscZone: + return self.zones[name, view] def _reverse_zone_name(self, net: IPNetwork) -> str: if isinstance(net, IPv4Network): diff --git a/nsconfig/daemon/bind.py b/nsconfig/daemon/bind.py index d0ac161..ea34530 100644 --- a/nsconfig/daemon/bind.py +++ b/nsconfig/daemon/bind.py @@ -37,7 +37,7 @@ class NscDaemonBind(NscDaemon): cf.append(WARNING) cf.append("") cf.extend(self._zone_config(z)) - configs.append((f'zone/{z.safe_name}', cf)) + configs.append((f'zone/{z.file_name}', cf)) return configs else: cf = [] -- 2.47.3