]> mj.ucw.cz Git - pynsc.git/commitdiff
Add support for views (semi-automatic)
authorMartin Mareš <mj@ucw.cz>
Sun, 23 Nov 2025 11:37:02 +0000 (12:37 +0100)
committerMartin Mareš <mj@ucw.cz>
Sun, 23 Nov 2025 11:37:02 +0000 (12:37 +0100)
README.md
TODO
example/__init__.py
nsconfig/cli.py
nsconfig/core.py
nsconfig/daemon/bind.py

index 06f8d0f68b81f8235ffec66b7e9db23afb26af3e..03ed417f3eec5953abf28778a2329eb95e0a2faf 100644 (file)
--- 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 f3ad0e9541aea190465b0cf5c58404244cfbf57a..51f4125bdedb4565355af168c8226502ca05957e 100644 (file)
--- a/TODO
+++ b/TODO
@@ -1,4 +1,3 @@
 - DNSSEC
 - Logging
 - More records
-- Multiple versions of a single zone (for views)
index c01c23eea457a5d4f7c12336c2d82e661e9eb491..c9876988e5b5f32ff889121d22709c82b87fc77f 100644 (file)
@@ -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')
index ee8b516600d6891ddd844b5ff417bef68257d2cd..7bebfc6b582b9f4bf7633283b7d95f3467a4d1ee 100644 (file)
@@ -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:
index 1eb12a84e1807d5f5520df0bf51aee04d8937d7e..91c6a35c2cbd073f158936941ac7032a883c2442 100644 (file)
@@ -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):
index d0ac1616a3ecf5b14cc7b7811b4c7605285b791f..ea345309839c0982f3962c9bb4de3cad142c685f 100644 (file)
@@ -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 = []