2
0
mirror of https://github.com/xcat2/confluent.git synced 2026-07-31 18:19:44 +00:00

Implement confluent2dnsmasqdhcp

confleunt2dnsmasqdhcp creates static DHCP entries for dnsmasq
for nodes with defined net.*.hwaddr.
This commit is contained in:
Markus Hilger
2026-06-28 21:31:11 +02:00
parent 2c669358b3
commit 007b374c73
2 changed files with 726 additions and 0 deletions
+488
View File
@@ -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.<iface>.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.<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.<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()
@@ -0,0 +1,238 @@
confluent2dnsmasqdhcp(8) -- Generate dnsmasq static DHCP reservations for nodes
===============================================================================
## SYNOPSIS
`confluent2dnsmasqdhcp [options] <noderange>`
`confluent2dnsmasqdhcp <noderange>`
`confluent2dnsmasqdhcp --all-options <noderange>`
`confluent2dnsmasqdhcp -n <noderange>`
## 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.<network>.<attribute>` 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.<network>.ipv4_address` supplies the reserved
address. The `/prefixlen` suffix on that address is used to compute the
enclosing subnet, and one `dhcp-range=<network>,static,<netmask>,<lease>` 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.<network>.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:<tag>` and the corresponding `dhcp-option` lines a
matching `tag:<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.<network>.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.<network>.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)