Skip to content

Instantly share code, notes, and snippets.

@webstrand
Last active November 13, 2022 10:04
Show Gist options
  • Select an option

  • Save webstrand/601e4f26fe4ba7a92eb16747138860b1 to your computer and use it in GitHub Desktop.

Select an option

Save webstrand/601e4f26fe4ba7a92eb16747138860b1 to your computer and use it in GitHub Desktop.
dnsmasq dhcp-script hook
#!/usr/bin/perl
use strict;
use warnings;
use v5.22;
use feature qw(signatures);
no warnings qw(experimental::signatures);
# This script will, when called by dnsmasq's dhcp-script hook, create a per-host
# configuration file. The default configuration will instruct dnsmasq to route
# all subdomains (*.hostname.$DOMAIN) to the hosts leased ip address.
#
# WARNING: NOT TO BE USED ON NETWORKS WITH POTENTIALLY MALICIOUS HOSTS! HOSTNAME
# COLLISIONS AND LEASE RENEWAL SPAM ARE UNHANDLED.
#
# To install, add:
# dhcp-script=/path/to/lease-change.pl
# conf-dir=/var/run/dnsmasq.leases.d,*.conf
# $CONF_DIR configures where this script will install the generated per-hostname
# configuration file.
my $CONF_DIR = "/var/run/dnsmasq.leases.d";
# $DOMAIN configures the parent domain name that hosts are added into. This
# should match the local=/inside.example.com/ directive in dnsmasq.conf
my $DOMAIN = "inside.example.com";
# Number of seconds that must pass between lease changes to trigger dnsmasq
# restart. This prevents constant thrashing of the dnsmasq process on busy
# networks.
#
# Note: It's possible for clients to effectively prevent configuration changes
# by renewing their lease more frequently than this timeout.
my $SETTLING_TIMEOUT = 1;
# Generate the name of the per-host configuration file.
sub generate_conf_path($mac, $ip, $hostname) {
"$CONF_DIR/$hostname.conf"
}
# Generate the contents of the per-host configuration file.
sub generate_conf($mac, $ip, $hostname) {
<<CONTENTS
address=/$hostname.$DOMAIN/$ip
CONTENTS
}
sub restart_dnsmasq() {
exec qw(service dnsmasq restart);
}
my $CHANGE_LOCK_FILE = "/tmp/dnsmasq-lease.lock";
my $RESTART_LOCK_FILE = "/tmp/dnsmasq-restart.lock";
#### end configuration #########################################################
use Errno qw(ENOENT);
use Fcntl qw(:flock O_CREAT O_RDONLY);
BEGIN { $^F = 1000000 }
mkdir($CONF_DIR) or die "Unable to mkdir $CONF_DIR"
unless -d $CONF_DIR;
# The $CHANGE_LOCK_FILE is locked in shared, preventing
# the restart of dnsmasq while files are being updated.
sysopen(my $lock, $CHANGE_LOCK_FILE, O_CREAT | O_RDONLY)
or die "Unable to open $CHANGE_LOCK_FILE: $!";
flock($lock, LOCK_SH);
# Overwrite $path with $data and return true if contents are changed.
sub overwrite($path, $data) {
local $/ = undef;
sysopen(my $in, $path, O_CREAT | O_RDONLY)
or die "Unable to open $path: $!";
flock($in, LOCK_EX) or die "Unable to lock $path: $!";
my $contents = <$in>;
if($contents ne $data) {
open(my $out, ">", $path) or die "Unable to open $path: $!";
print $out $data or die "Unable to write to $path";
return 1;
}
else {
return 0;
}
}
# Unlink $path and return true if $path existed.
sub remove($path) {
unlink($path) or $! == ENOENT or die "Unable to unlink $path: $!";
}
# Spawn a child process to wait for configuration changes to settle and then
# restart dnsmasq.
sub restart($path) {
say STDERR "Changed $path";
return if fork;
# The $RESTART_LOCK_FILE is locked exclusive, preventing
# more than one restart worker from spawning.
sysopen(my $restart_lock, $RESTART_LOCK_FILE, O_CREAT | O_RDONLY)
or die "Unable to open $RESTART_LOCK_FILE: $!";
exit 0 unless flock($restart_lock, LOCK_EX | LOCK_NB);
# Wait for changes to leases to settle before restarting dnsmasq.
sleep $SETTLING_TIMEOUT;
sleep $SETTLING_TIMEOUT until flock($lock, LOCK_EX | LOCK_NB);
restart_dnsmasq or die "Unable to restart dnsmasq service";
die "restart_dnsmasq() returned true, but did not exec";
# We must exec here, otherwise we enter a race condition with restarted
# dnsmasq's dhcp-script that gets invoked during startup while recovering
# saved leases.
}
sub main($action, $mac, $ip, $hostname = "") {
die "Invalid action: $action" unless $action;
die "Invalid mac: $mac" unless $mac;
die "Invalid ip: $ip" unless $ip;
return say STDERR "Ignoring unknown hostname for $ip" unless($hostname);
my $path = generate_conf_path($mac, $ip, $hostname) or return;
if($action eq "add" or $action eq "old") {
return restart($path) if overwrite($path, generate_conf($mac, $ip, $hostname));
say STDERR "Not writing $path: up-to-date";
}
elsif($action eq "del") {
return restart($path) if remove($path);
say STDERR "Not removing $path: up-to-date";
}
else {
die "Unexpected action: $action";
}
}
main(@ARGV);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment