From 007b374c7399b59f667373af1b6414742136f7c6 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sun, 28 Jun 2026 21:31:11 +0200 Subject: [PATCH] Implement confluent2dnsmasqdhcp confleunt2dnsmasqdhcp creates static DHCP entries for dnsmasq for nodes with defined net.*.hwaddr. --- confluent_client/bin/confluent2dnsmasqdhcp | 488 ++++++++++++++++++ .../doc/man/confluent2dnsmasqdhcp.ronn | 238 +++++++++ 2 files changed, 726 insertions(+) create mode 100644 confluent_client/bin/confluent2dnsmasqdhcp create mode 100644 confluent_client/doc/man/confluent2dnsmasqdhcp.ronn diff --git a/confluent_client/bin/confluent2dnsmasqdhcp b/confluent_client/bin/confluent2dnsmasqdhcp new file mode 100644 index 00000000..4427be6c --- /dev/null +++ b/confluent_client/bin/confluent2dnsmasqdhcp @@ -0,0 +1,488 @@ +#!/usr/bin/python3 +# Generate a dnsmasq static DHCP configuration (a file under /etc/dnsmasq.d) +# from the confluent attribute database. One "dhcp-host=" reservation is +# emitted for every network of every node that has a hardware (MAC) address +# defined (net.hwaddr, net..hwaddr, net.bmc.hwaddr, ...). + +import argparse +import ipaddress +import os +import re +import signal +import socket +import sys +import time + +try: + signal.signal(signal.SIGPIPE, signal.SIG_DFL) +except AttributeError: + pass + +path = os.path.dirname(os.path.realpath(__file__)) +path = os.path.realpath(os.path.join(path, '..', 'lib', 'python')) +if path.startswith('/opt'): + sys.path.append(path) + +import confluent.client as client + +TARGET = '/etc/dnsmasq.d/confluent-dhcp.conf' +TAGPREFIX = 'cdhcp' +# Invocation flags that do not affect the generated content; excluded from the +# recorded "Regenerate:" line and from the settings-changed comparison. +NONCONFIG_ARGS = {'-y', '--yes', '-n', '--stdout'} + + +def split_list(val): + """Split a confluent comma/whitespace delimited attribute value.""" + if not val: + return [] + return [v for v in re.split(r'[\s,]+', val.strip()) if v] + + +def config_args(argv): + """Join the invocation arguments that affect the generated content.""" + return ' '.join(a for a in argv if a not in NONCONFIG_ARGS) + + +def parse_net_attrib(attrib): + """Split a net.* attribute into (network_name, field). + + The field is always the final dotted component; everything between + 'net.' and the field is the network name, which may itself span + several dotted components. + """ + parts = attrib.split('.') + field = parts[-1] + if len(parts) == 2: + currnet = None + else: + currnet = '.'.join(parts[1:-1]) + return currnet, field + + +def subnet_sort_key(netaddr): + try: + return tuple(int(o) for o in netaddr.split('.')) + except ValueError: + return (0, 0, 0, 0) + + +def local_ip_for_network(network): + """Return this host's source address for `network`, but only when that + address is itself within `network` (i.e. the host is directly attached); + otherwise None. Uses a connected UDP socket, which performs a routing + lookup without sending any packet.""" + family = socket.AF_INET6 if network.version == 6 else socket.AF_INET + try: + target = str(next(network.hosts())) + except StopIteration: + target = str(network.network_address) + s = socket.socket(family, socket.SOCK_DGRAM) + try: + s.connect((target, 9)) + local = s.getsockname()[0] + except OSError: + return None + finally: + s.close() + local = local.split('%', 1)[0] # drop any IPv6 zone id + try: + if ipaddress.ip_address(local) in network: + return local + except ValueError: + pass + return None + + +def existing_regen_args(targetpath): + """Return the content arguments recorded in an existing config's + 'Regenerate:' header line, or None if not present/readable.""" + try: + with open(targetpath) as cfg: + for line in cfg: + if line.startswith('# Regenerate:'): + rest = line[len('# Regenerate:'):].strip() + marker = 'confluent2dnsmasqdhcp' + if rest.startswith(marker): + return rest[len(marker):].strip() + return rest + if not line.startswith('#'): + break + except OSError: + pass + return None + + +def main(): + ap = argparse.ArgumentParser( + description='Create /etc/dnsmasq.d static DHCP reservations for a ' + 'noderange from the confluent database') + ap.add_argument('noderange', + help='Noderange to generate DHCP reservations for') + ap.add_argument('--target', default=TARGET, metavar='PATH', + help='Output configuration file (default: %(default)s).') + ap.add_argument('--tag-prefix', default=TAGPREFIX, metavar='PREFIX', + help='Prefix for generated dnsmasq tag names ' + '(default: %(default)s).') + ap.add_argument('--no-bind-dynamic', dest='emit_bind_dynamic', + action='store_false', + help="Do not emit the 'bind-dynamic' line " + '(it is emitted by default).') + ap.add_argument('--no-listen-address', dest='detect_listen', + action='store_false', + help='Do not autodetect and emit a listen-address line for ' + "this host's address on each managed subnet. By " + 'default they are emitted together with localhost ' + '(127.0.0.1 and ::1).') + ap.add_argument('--no-range', dest='emit_range', action='store_false', + help='Do not emit dhcp-range lines (manage ranges ' + 'elsewhere). Note: dnsmasq will not answer DHCP on a ' + 'subnet that has no covering dhcp-range.') + ap.add_argument('--lease', default='24h', metavar='TIME', + help='Lease time for emitted dhcp-range lines ' + '(default: 24h; e.g. 1h, 24h, infinite). ' + 'Pass an empty string to omit it.') + ap.add_argument('-n', '--stdout', action='store_true', + help='Write the configuration to stdout instead of ' + 'writing the target file.') + ap.add_argument('-y', '--yes', dest='assume_yes', action='store_true', + help='Do not prompt for confirmation when an existing ' + 'config was generated with different settings.') + ap.add_argument('--allow-empty', action='store_true', + help='Write the file even if no reservations were ' + 'produced (default: refuse, to avoid clobbering an ' + 'existing config on an empty/failed read).') + + # Optional extra data injected into the DHCP reply, sourced from the + # confluent database. All are OFF by default and scoped per-subnet. + grp = ap.add_argument_group( + 'optional DHCP reply data (sourced from confluent, off by default)') + grp.add_argument('--all-options', action='store_true', + help='Enable every optional reply datum listed below.') + grp.add_argument('--gateway', '--router', dest='opt_gateway', + action='store_true', + help='router, option 3, from net..ipv4_gateway') + grp.add_argument('--dns', dest='opt_dns', action='store_true', + help='dns-server, option 6, from dns.servers') + grp.add_argument('--domain', dest='opt_domain', action='store_true', + help='domain-name, option 15, from dns.domain') + grp.add_argument('--ntp', dest='opt_ntp', action='store_true', + help='ntp-server, option 42, from ntp.servers') + grp.add_argument('--mtu', dest='opt_mtu', action='store_true', + help='interface-mtu, option 26, from net..mtu') + args = ap.parse_args() + + if args.all_options: + args.opt_gateway = True + args.opt_dns = True + args.opt_domain = True + args.opt_ntp = True + args.opt_mtu = True + + c = client.Command() + + # Node level attributes + domainbynode = {} + dnsbynode = {} + ntpbynode = {} + # Per (node, net) network attributes: nets[node][net] = {field: value} + nets = {} + + read_error = False + for ent in c.read('/noderange/{0}/attributes/current'.format( + args.noderange)): + if 'error' in ent: + sys.stderr.write(ent['error'] + '\n') + read_error = True + continue + ent = ent.get('databynode', {}) + for node in ent: + for attrib in ent[node]: + val = ent[node][attrib] + if not isinstance(val, dict): + continue + val = val.get('value', None) + if val in (None, ''): + continue + if attrib == 'dns.domain': + domainbynode[node] = val + elif attrib == 'dns.servers': + dnsbynode[node] = val + elif attrib == 'ntp.servers': + ntpbynode[node] = val + elif attrib.startswith('net.'): + currnet, field = parse_net_attrib(attrib) + if field not in ('hwaddr', 'ipv4_address', + 'ipv4_gateway', 'hostname', 'mtu'): + continue + nets.setdefault(node, {}).setdefault(currnet, {}) + nets[node][currnet][field] = val + + # Build reservation entries and aggregate per-subnet information. + hostentries = [] # {subnetkey, macs, ip, hostname} + subnets = {} # subnetkey (netaddr, prefixlen) -> aggregate dict + + for node in sorted(nets): + netnames = sorted(nets[node], key=lambda n: (n is not None, n or '')) + for currnet in netnames: + slot = nets[node][currnet] + hwaddr = slot.get('hwaddr') + if not hwaddr: + continue + label = currnet if currnet else '(primary)' + netprefix = (currnet + '.') if currnet else '' + addr = slot.get('ipv4_address') + if not addr: + sys.stderr.write( + '{0}: net {1} has a hwaddr but no net.{2}ipv4_address; ' + 'skipping\n'.format(node, label, netprefix)) + continue + ip = addr.split('/', 1)[0] + + # Reservation hostname: first token of the per-net hostname, + # else the node name for the primary (unnamed) network, else + # omit it (named net with no hostname). + hostname = None + if slot.get('hostname'): + hostname = split_list(slot['hostname'])[0] + elif currnet is None: + hostname = node + + # Derive the subnet (needed for dhcp-range and for scoping + # the optional dhcp-option tags). + subnetkey = None + if '/' in addr: + try: + iface = ipaddress.ip_interface(addr) + except ValueError as e: + sys.stderr.write( + '{0}: net {1} has invalid address {2} ({3}); ' + 'skipping\n'.format(node, label, addr, e)) + continue + network = iface.network + subnetkey = (str(network.network_address), network.prefixlen) + sub = subnets.get(subnetkey) + if sub is None: + tag = '{0}_{1}_{2}'.format( + args.tag_prefix, + str(network.network_address).replace('.', '_'), + network.prefixlen) + sub = {'netmask': str(network.netmask), 'tag': tag, + 'gateway': set(), 'dns': set(), 'domain': set(), + 'ntp': set(), 'mtu': set()} + subnets[subnetkey] = sub + if slot.get('ipv4_gateway'): + sub['gateway'].add(slot['ipv4_gateway']) + if node in dnsbynode: + sub['dns'].add(dnsbynode[node]) + if node in domainbynode: + sub['domain'].add(domainbynode[node]) + if node in ntpbynode: + sub['ntp'].add(ntpbynode[node]) + if slot.get('mtu'): + sub['mtu'].add(slot['mtu']) + elif args.emit_range: + sys.stderr.write( + '{0}: net {1} address {2} has no /prefix; cannot derive a ' + 'subnet for dhcp-range (add a prefix or use --no-range). ' + 'Emitting the dhcp-host anyway.\n'.format( + node, label, addr)) + + hostentries.append({'subnetkey': subnetkey, + 'macs': split_list(hwaddr), + 'ip': ip, 'hostname': hostname}) + + # Resolve optional per-subnet options from the aggregated values. + def resolve(values, what, subnetkey): + if not values: + return None + if len(values) > 1: + chosen = sorted(values)[0] + sys.stderr.write( + 'subnet {0}/{1}: inconsistent {2} across nodes {3}; using ' + '{4}\n'.format(subnetkey[0], subnetkey[1], what, + sorted(values), chosen)) + return chosen + return next(iter(values)) + + optionlines = {} # subnetkey -> [ 'option:...,value', ... ] + for subnetkey, sub in subnets.items(): + lines = [] + if args.opt_gateway: + gw = resolve(sub['gateway'], 'net.ipv4_gateway', subnetkey) + if gw: + lines.append('option:router,{0}'.format(gw)) + if args.opt_dns: + dv = resolve(sub['dns'], 'dns.servers', subnetkey) + if dv: + lines.append('option:dns-server,{0}'.format( + ','.join(split_list(dv)))) + if args.opt_domain: + dom = resolve(sub['domain'], 'dns.domain', subnetkey) + if dom: + lines.append('option:domain-name,{0}'.format(dom)) + if args.opt_ntp: + nv = resolve(sub['ntp'], 'ntp.servers', subnetkey) + if nv: + lines.append('option:ntp-server,{0}'.format( + ','.join(split_list(nv)))) + if args.opt_mtu: + mv = resolve(sub['mtu'], 'net.mtu', subnetkey) + if mv: + lines.append('option:mtu,{0}'.format(mv)) + if lines: + optionlines[subnetkey] = lines + + orderedsubnets = sorted(subnets, + key=lambda k: subnet_sort_key(k[0]) + (k[1],)) + + # Render. + out = ['# Managed by confluent2dnsmasqdhcp -- DO NOT EDIT BY HAND.', + '# Regenerate: confluent2dnsmasqdhcp {0}'.format( + config_args(sys.argv[1:])), + '# Generated {0}'.format(time.strftime('%Y-%m-%d %H:%M:%S %z')), + ''] + if args.emit_bind_dynamic: + out.append('# Allow confluent and dnsmasq to share the same network ' + 'for DHCP') + out.append('bind-dynamic') + out.append('') + + detected = [] + if args.detect_listen: + for subnetkey in orderedsubnets: + netaddr, prefixlen = subnetkey + try: + net = ipaddress.ip_network('{0}/{1}'.format(netaddr, prefixlen)) + except ValueError: + continue + lip = local_ip_for_network(net) + if lip and lip not in detected: + detected.append(lip) + out.append('# Listen on localhost plus this host\'s address on each ' + 'managed subnet,') + out.append('# so dnsmasq serves these networks alongside confluent ' + 'and bind-dynamic') + out.append('# (a listen-address line also overrides local-service in ' + 'dnsmasq.conf).') + out.append('listen-address=127.0.0.1') + out.append('listen-address=::1') + for addr in detected: + out.append('listen-address={0}'.format(addr)) + out.append('') + + hosts_by_subnet = {} + nosubnet_hosts = [] + for e in hostentries: + fields = list(e['macs']) + if e['subnetkey'] in optionlines: + fields.append('set:{0}'.format(subnets[e['subnetkey']]['tag'])) + fields.append(e['ip']) + if e['hostname']: + fields.append(e['hostname']) + line = 'dhcp-host={0}'.format(','.join(fields)) + if e['subnetkey'] is None: + nosubnet_hosts.append(line) + else: + hosts_by_subnet.setdefault(e['subnetkey'], []).append(line) + + for subnetkey in orderedsubnets: + sub = subnets[subnetkey] + netaddr, prefixlen = subnetkey + out.append('# subnet {0}/{1}'.format(netaddr, prefixlen)) + if args.emit_range: + rangefields = [netaddr, 'static', sub['netmask']] + if args.lease: + rangefields.append(args.lease) + out.append('dhcp-range={0}'.format(','.join(rangefields))) + for optline in optionlines.get(subnetkey, []): + out.append('dhcp-option=tag:{0},{1}'.format(sub['tag'], optline)) + for line in sorted(hosts_by_subnet.get(subnetkey, [])): + out.append(line) + out.append('') + + if nosubnet_hosts: + out.append('# reservations with no derivable subnet (address had no ' + '/prefix)') + out.extend(sorted(nosubnet_hosts)) + out.append('') + + text = '\n'.join(out).rstrip('\n') + '\n' + + # Warnings about configuration choices that affect coexistence with + # confluent's own DHCP service. + if args.detect_listen and subnets and not detected: + sys.stderr.write( + 'WARNING: no local address was found on any managed subnet, so ' + 'dnsmasq will only listen on localhost. Run this on the dnsmasq ' + 'host, or set interface/listen-address manually.\n') + if not args.detect_listen: + sys.stderr.write( + 'WARNING: listen-address autodetection disabled. For dnsmasq to ' + 'serve these networks together with confluent and bind-dynamic, ' + 'set interface, except-interface or listen-address manually, or ' + 'disable local-service=host in dnsmasq.conf.\n') + if not args.emit_bind_dynamic: + sys.stderr.write( + 'WARNING: bind-dynamic not emitted; this can conflict with ' + "confluent's DHCP unless bind-dynamic is set elsewhere in the " + 'dnsmasq configuration.\n') + + if args.stdout: + sys.stdout.write(text) + return + + if not hostentries and not args.allow_empty: + sys.stderr.write( + 'No DHCP reservations were generated (no networks with both a ' + 'hwaddr and an ipv4_address were found){0}. Refusing to overwrite ' + '{1}; pass --allow-empty to override.\n'.format( + ' and there were read errors' if read_error else '', + args.target)) + sys.exit(1) + + # If a config already exists that was generated with different + # content-affecting settings, confirm before overwriting it. + if os.path.exists(args.target): + prev = existing_regen_args(args.target) + cur = config_args(sys.argv[1:]) + if prev is not None and prev != cur: + sys.stderr.write( + 'Existing {0} was generated with different settings:\n' + ' old: confluent2dnsmasqdhcp {1}\n' + ' new: confluent2dnsmasqdhcp {2}\n'.format( + args.target, prev or '(no arguments)', + cur or '(no arguments)')) + if not args.assume_yes: + if not sys.stdin.isatty(): + sys.stderr.write( + 'Refusing to regenerate with changed settings ' + 'non-interactively; pass -y/--yes to confirm.\n') + sys.exit(1) + try: + resp = input('Are you sure you want to regenerate using the new settings? [y/N] ') + except EOFError: + resp = '' + if resp.strip().lower() not in ('y', 'yes'): + sys.stderr.write( + 'Aborted; {0} left unchanged.\n'.format(args.target)) + sys.exit(1) + + targetdir = os.path.dirname(args.target) + targetname = os.path.basename(args.target) + if targetdir and not os.path.isdir(targetdir): + os.makedirs(targetdir, exist_ok=True) + # Write via a hidden temp file in the same directory, then rename: the + # rename is atomic, and the dot-prefix keeps a dnsmasq conf-dir from + # loading the temp file if it scans mid-write. + tmp = os.path.join(targetdir, '.' + targetname + '.tmp') + with open(tmp, 'w') as cfg: + cfg.write(text) + os.replace(tmp, args.target) + sys.stderr.write('Wrote {0} reservation(s) across {1} subnet(s) to {2}\n' + .format(len(hostentries), len(subnets), args.target)) + sys.stderr.write('Be sure to restart dnsmasq for the changes to take effect\n') + + +if __name__ == '__main__': + main() diff --git a/confluent_client/doc/man/confluent2dnsmasqdhcp.ronn b/confluent_client/doc/man/confluent2dnsmasqdhcp.ronn new file mode 100644 index 00000000..d6fb0e80 --- /dev/null +++ b/confluent_client/doc/man/confluent2dnsmasqdhcp.ronn @@ -0,0 +1,238 @@ +confluent2dnsmasqdhcp(8) -- Generate dnsmasq static DHCP reservations for nodes +=============================================================================== + +## SYNOPSIS + +`confluent2dnsmasqdhcp [options] ` +`confluent2dnsmasqdhcp ` +`confluent2dnsmasqdhcp --all-options ` +`confluent2dnsmasqdhcp -n ` + +## DESCRIPTION + +`confluent2dnsmasqdhcp` generates a dnsmasq configuration fragment of static +DHCP reservations for a noderange, using the confluent database as the source +of truth. Each `net..` group is pulled together and one +`dhcp-host=` reservation is emitted for **every network that has a hardware +address** (`net.hwaddr`, `net.bmc.hwaddr`, `net.eth.mynetwork.hwaddr`, and so +on; the network component may itself contain several dotted parts). Networks +without a `hwaddr` (for example InfiniBand interfaces identified only by a +GUID) are skipped. + +For each reservation, `net..ipv4_address` supplies the reserved +address. The `/prefixlen` suffix on that address is used to compute the +enclosing subnet, and one `dhcp-range=,static,,` line +is emitted per distinct subnet. dnsmasq will not answer DHCP requests on a +subnet that has no covering `dhcp-range`, even when every address on it is +statically reserved, so these ranges are emitted by default (see `--no-range`). + +The reservation name is the first token of `net..hostname`, falling +back to the node name itself for the primary (unnamed) network, and omitted for +a named network that has no hostname. Only IPv4 node addresses are used for +reservations and ranges. + +By default the fragment begins with a `bind-dynamic` directive so that dnsmasq +can bind DHCP to networks confluent is using, too (see `--no-bind-dynamic`). + +Also by default, a `listen-address` line is emitted for `127.0.0.1`, for +`::1`, and for this host's own address on each managed subnet (see +`--no-listen-address`). The per-subnet addresses are detected from the host +the command runs on, so run it on the dnsmasq host; only subnets the host is +directly attached to are added. Besides telling dnsmasq where to listen, the +presence of any `listen-address` line disables the default `local-service` +(or `local-service=host`) restriction in `dnsmasq.conf` that would otherwise +limit dnsmasq to localhost. + +Additional data can optionally be added to the DHCP reply from the confluent +database (see OPTIONS; all are off by default). Each such option is scoped to +the subnet it belongs to using a dnsmasq tag: the `dhcp-host` lines for that +subnet are given a `set:` and the corresponding `dhcp-option` lines a +matching `tag:`. Values are aggregated per subnet; if two nodes on the +same subnet disagree (for example two different gateways), a warning is printed +and the first value is used. + +The configuration is written to `/etc/dnsmasq.d/confluent-dhcp.conf` (override +with `--target`) and regenerated on each run, replacing the file atomically. +If the existing file was generated with different content-affecting arguments +(the preview and confirmation flags `-n` and `-y` are ignored for this +comparison), you are asked to confirm before it is overwritten; the prompt is +skipped with `-y` and is refused when there is no controlling terminal. If a +run produces no reservations at all (for example an empty noderange or a failed +read), `confluent2dnsmasqdhcp` refuses to overwrite the existing file unless +`--allow-empty` is given. + +## OPTIONS + +* `--target PATH`: + Path of the configuration file to write. Defaults to + `/etc/dnsmasq.d/confluent-dhcp.conf`. + +* `--tag-prefix PREFIX`: + Prefix used when generating dnsmasq tag names for per-subnet options. + Defaults to `cdhcp`, yielding tags such as `cdhcp_10_28_104_0_21`. + +* `--no-bind-dynamic`: + Do not emit the `bind-dynamic` line. It is emitted by default. Note that + `bind-dynamic` is a global dnsmasq directive and must not be combined with + a `bind-interfaces` directive set elsewhere. Without it, dnsmasq may conflict + with confluent's own DHCP unless `bind-dynamic` is set elsewhere in the + dnsmasq configuration; a warning is printed. + +* `--no-listen-address`: + Do not autodetect or emit `listen-address` lines. By default a + `listen-address` line is emitted for `127.0.0.1`, for `::1`, and for this + host's address on each managed subnet. With this flag you must set + `interface`, `except-interface`, or `listen-address` yourself (or disable + `local-service`/`local-service=host` in `dnsmasq.conf`) for dnsmasq to serve + the cluster networks alongside confluent; a warning is printed as a reminder. + +* `--no-range`: + Do not emit `dhcp-range` lines, leaving range and subnet declarations to be + managed elsewhere. Remember that dnsmasq will not serve DHCP on a subnet + that has no covering `dhcp-range`. Per-subnet options are still scoped via + host tags, so they keep working with a range you declare yourself. + +* `--lease TIME`: + Lease time applied to the emitted `dhcp-range` lines. Defaults to `24h`. + Accepts any dnsmasq lease syntax (for example `1h`, `24h`, `infinite`). + Pass an empty string to omit the lease field entirely. + +* `-n`, `--stdout`: + Write the configuration to standard output instead of the target file, which + is left untouched. Useful for previewing. + +* `-y`, `--yes`: + Do not prompt for confirmation when the existing configuration was generated + with different settings; regenerate anyway. Without it a settings change is + confirmed interactively, and refused when there is no controlling terminal. + +* `--allow-empty`: + Write the file even when no reservations were produced. By default an empty + result does not overwrite the existing file, to avoid discarding a good + configuration after an empty or failed read. + +* `--all-options`: + Enable all of the optional reply-data options below at once. + +* `--gateway`, `--router`: + Add a router (DHCP option 3) per subnet, taken from + `net..ipv4_gateway`. + +* `--dns`: + Add DNS servers (DHCP option 6) per subnet, taken from `dns.servers`. + +* `--domain`: + Add a domain name (DHCP option 15) per subnet, taken from `dns.domain`. + +* `--ntp`: + Add NTP servers (DHCP option 42) per subnet, taken from `ntp.servers`. + +* `--mtu`: + Add an interface MTU (DHCP option 26) per subnet, taken from + `net..mtu`. + +* `-h`, `--help`: + Show a help message and exit. + +## EXAMPLES + +* Generate reservations for a noderange and write the default file: + `# confluent2dnsmasqdhcp everything` + +* Preview the configuration for one node without writing anything: + `# confluent2dnsmasqdhcp -n node01` + +* Include all optional reply data (gateway, DNS, domain, NTP, MTU): + `# confluent2dnsmasqdhcp --all-options everything` + +* Include only the gateway, and use an eight hour lease: + `# confluent2dnsmasqdhcp --gateway --lease 8h everything` + +* Regenerate non-interactively (for example from cron) after changing options, without the confirmation prompt: + `# confluent2dnsmasqdhcp --all-options -y everything` + +* Emit only reservations (no ranges) to a custom file kept beside your own range and listen interface definitions: + `# confluent2dnsmasqdhcp --no-listen-address --no-range --target /etc/dnsmasq.d/confluent-reservations.conf everything` + +## GENERATED CONFIGURATION + +Given the following confluent attributes for node `node01`: + + node01: net.hwaddr: 10:ff:e0:af:af:f5 + node01: net.ipv4_address: 10.28.90.1/21 + node01: net.ipv4_gateway: 10.28.88.1 + node01: net.bmc.hwaddr: 10:ff:e0:a4:cf:b6 + node01: net.bmc.hostname: node01-bmc + node01: net.bmc.ipv4_address: 10.28.106.1/21 + node01: net.bmc.ipv4_gateway: 10.28.104.1 + node01: net.ib0.hostname: node01-ib0 + node01: net.ib0.ipv4_address: 10.28.101.1/21 + +`confluent2dnsmasqdhcp node01`, run on a dnsmasq host whose own addresses on the +two managed subnets are `10.28.88.250` and `10.28.104.250`, writes the file +below. The `ib0` network is skipped because it has no `hwaddr`; the two +remaining networks each yield a subnet (with its `dhcp-range`) and a +reservation, and a `listen-address` line is emitted for localhost and for this +host's address on each subnet: + + # Managed by confluent2dnsmasqdhcp -- DO NOT EDIT BY HAND. + # Regenerate: confluent2dnsmasqdhcp node01 + # Generated 2026-06-28 12:00:00 +0000 + + # Allow confluent and dnsmasq to share the same network for DHCP + bind-dynamic + + # Listen on localhost plus this host's address on each managed subnet, + # so dnsmasq serves these networks alongside confluent and bind-dynamic + # (a listen-address line also overrides local-service in dnsmasq.conf). + listen-address=127.0.0.1 + listen-address=::1 + listen-address=10.28.88.250 + listen-address=10.28.104.250 + + # subnet 10.28.88.0/21 + dhcp-range=10.28.88.0,static,255.255.248.0,24h + dhcp-host=10:ff:e0:af:af:f5,10.28.90.1,node01 + + # subnet 10.28.104.0/21 + dhcp-range=10.28.104.0,static,255.255.248.0,24h + dhcp-host=10:ff:e0:a4:cf:b6,10.28.106.1,node01-bmc + +Adding `--gateway` scopes a router option to each subnet through a tag, and the +matching `dhcp-host` lines gain a `set:` tag so the option reaches them: + + # subnet 10.28.88.0/21 + dhcp-range=10.28.88.0,static,255.255.248.0,24h + dhcp-option=tag:cdhcp_10_28_88_0_21,option:router,10.28.88.1 + dhcp-host=10:ff:e0:af:af:f5,set:cdhcp_10_28_88_0_21,10.28.90.1,node01 + + # subnet 10.28.104.0/21 + dhcp-range=10.28.104.0,static,255.255.248.0,24h + dhcp-option=tag:cdhcp_10_28_104_0_21,option:router,10.28.104.1 + dhcp-host=10:ff:e0:a4:cf:b6,set:cdhcp_10_28_104_0_21,10.28.106.1,node01-bmc + +## NOTES + +`bind-dynamic` is a global dnsmasq option; if it is set here it must not be +contradicted by a `bind-interfaces` directive elsewhere in the configuration. + +The autodetected `listen-address` lines double as the mechanism that relaxes +dnsmasq's default `local-service`/`local-service=host` restriction (any +`interface`, `except-interface`, or `listen-address` line does), which is what +lets dnsmasq answer on the cluster networks instead of only localhost. +Detection only covers subnets the host running the command is directly attached +to, so run it there; with `--no-listen-address` you must supply `interface`, +`except-interface`, or `listen-address` yourself, or disable `local-service`. + +After (re)generating the file, validate it with `dnsmasq --test` and restart +dnsmasq (for example `systemctl restart dnsmasq`); a reload (SIGHUP) does not +re-read `listen-address` or interface bindings. + +## FILES + +* `/etc/dnsmasq.d/confluent-dhcp.conf`: + Default output file (override with `--target`). + +## SEE ALSO + +nodeattrib(8), noderange(5), dnsmasq(8)