LocalAuthority / scripts / mdns_host_seed.pl
1 contributor
341 lines | 10.564kb
#!/usr/bin/env perl
#
# mdns_host_seed.pl - Collect mDNS A records into the host source database.
#

use strict;
use warnings;

use Cwd qw(abs_path);
use Fcntl qw(:flock);
use File::Basename qw(dirname);
use File::Path qw(make_path);
use Getopt::Long qw(GetOptions);
use IO::Socket::INET;
use POSIX qw(strftime);
use Socket qw(IPPROTO_IP IP_ADD_MEMBERSHIP SO_REUSEADDR inet_aton inet_ntoa sockaddr_in);

my $script_dir = dirname(abs_path($0));
my $project_dir = dirname($script_dir);

my %opt = (
    database => $ENV{HOST_MANAGER_MDNS_DB} || "$project_dir/var/mdns-observations.yaml",
    bind => $ENV{HOST_MANAGER_MDNS_BIND} || '0.0.0.0',
    group => $ENV{HOST_MANAGER_MDNS_GROUP} || '224.0.0.251',
    port => $ENV{HOST_MANAGER_MDNS_PORT} || 5353,
    once => 0,
    timeout => 0,
    dry_run => 0,
    verbose => 0,
);

GetOptions(
    'database|db=s' => \$opt{database},
    'bind=s' => \$opt{bind},
    'group=s' => \$opt{group},
    'port=i' => \$opt{port},
    'once' => \$opt{once},
    'timeout=i' => \$opt{timeout},
    'dry-run' => \$opt{dry_run},
    'verbose' => \$opt{verbose},
    'help|h' => sub { usage(); exit 0; },
) or do {
    usage();
    exit 2;
};

my $socket = IO::Socket::INET->new(
    LocalAddr => $opt{bind},
    LocalPort => $opt{port},
    Proto => 'udp',
    ReuseAddr => 1,
) or die "Cannot listen for mDNS on $opt{bind}:$opt{port}: $!\n";

setsockopt($socket, Socket::SOL_SOCKET(), SO_REUSEADDR, pack('i', 1));
setsockopt($socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, inet_aton($opt{group}) . inet_aton($opt{bind}))
    or die "Cannot join mDNS multicast group $opt{group}: $!\n";

print "mdns host seed listening on udp://$opt{bind}:$opt{port} group $opt{group}\n" if $opt{verbose};
print "mDNS source database: $opt{database}\n" if $opt{verbose};

my $deadline = $opt{timeout} ? time() + $opt{timeout} : 0;
while (1) {
    my $timeout = undef;
    if ($deadline) {
        $timeout = $deadline - time();
        last if $timeout <= 0;
    }

    my $rin = '';
    vec($rin, fileno($socket), 1) = 1;
    my $ready = select(my $rout = $rin, undef, undef, $timeout);
    last if !defined($ready) || $ready <= 0;

    my $peer = recv($socket, my $packet, 9000, 0);
    next unless defined $peer && length $packet;

    my ($peer_port, $peer_addr) = sockaddr_in($peer);
    my $peer_ip = inet_ntoa($peer_addr);
    my @records = grep { record_is_usable($_) } parse_mdns_packet($packet);
    next unless @records;

    my $changed = seed_observations(\%opt, \@records, $peer_ip);
    print "stored " . scalar(@$changed) . " observation change(s)\n" if $opt{verbose} && @$changed;
    last if $opt{once} && @$changed;
}

exit 0;

sub usage {
    print <<"EOF";
Usage: perl scripts/mdns_host_seed.pl [options]

Options:
  --database path  mDNS source database. Defaults to var/mdns-observations.yaml.
  --bind addr      Local bind address. Defaults to 0.0.0.0.
  --group addr     Multicast group. Defaults to 224.0.0.251.
  --port n         UDP port. Defaults to 5353.
  --once           Exit after the first stored observation.
  --timeout n      Exit after n seconds.
  --dry-run        Print proposed observation changes without writing.
  --verbose        Print listener and change details.

Only A records for private/link-local .local names are collected. hosts.yaml is
not modified here; it remains a generated output fed by source databases.
EOF
}

sub parse_mdns_packet {
    my ($packet) = @_;
    return () if length($packet) < 12;
    my ($id, $flags, $qd, $an, $ns, $ar) = unpack('n6', substr($packet, 0, 12));
    my $offset = 12;

    for (1 .. $qd) {
        my ($name, $next) = read_dns_name($packet, $offset);
        last unless defined $name && $next + 4 <= length($packet);
        $offset = $next + 4;
    }

    my @records;
    for (1 .. ($an + $ns + $ar)) {
        my ($name, $next) = read_dns_name($packet, $offset);
        last unless defined $name && $next + 10 <= length($packet);
        my ($type, $class, $ttl, $rdlen) = unpack('nnNn', substr($packet, $next, 10));
        my $rdata_offset = $next + 10;
        last if $rdata_offset + $rdlen > length($packet);
        if ($type == 1 && $rdlen == 4) {
            push @records, {
                name => lc($name),
                ip => inet_ntoa(substr($packet, $rdata_offset, 4)),
                ttl => $ttl,
            };
        }
        $offset = $rdata_offset + $rdlen;
    }

    return @records;
}

sub read_dns_name {
    my ($packet, $offset) = @_;
    my @labels;
    my $pos = $offset;
    my $next;
    my %seen;

    while (1) {
        return unless $pos < length($packet);
        return if $seen{$pos}++;
        my $len = ord(substr($packet, $pos, 1));
        if (($len & 0xc0) == 0xc0) {
            return unless $pos + 1 < length($packet);
            my $ptr = unpack('n', substr($packet, $pos, 2)) & 0x3fff;
            $next = $pos + 2 unless defined $next;
            $pos = $ptr;
            next;
        }
        if ($len == 0) {
            $next = $pos + 1 unless defined $next;
            last;
        }
        return if ($len & 0xc0) || $pos + 1 + $len > length($packet);
        push @labels, substr($packet, $pos + 1, $len);
        $pos += 1 + $len;
    }

    return (join('.', @labels), $next);
}

sub record_is_usable {
    my ($record) = @_;
    return 0 unless ($record->{name} || '') =~ /\A[a-z0-9][a-z0-9_.-]*\.local\z/;
    return 0 unless ip_is_observable($record->{ip} || '');
    return 1;
}

sub ip_is_observable {
    my ($ip) = @_;
    return $ip =~ /\A192\.168\.\d+\.\d+\z/
        || $ip =~ /\A10\.\d+\.\d+\.\d+\z/
        || $ip =~ /\A172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+\z/
        || $ip =~ /\A169\.254\.\d+\.\d+\z/;
}

sub seed_observations {
    my ($opt, $records, $peer_ip) = @_;
    my $database = $opt->{database};
    ensure_parent_dir($database);
    open my $lock_fh, '+>>', $database or die "Cannot open $database: $!\n";
    flock($lock_fh, LOCK_EX) or die "Cannot lock $database: $!\n";
    seek($lock_fh, 0, 0);
    local $/;
    my $text = <$lock_fh>;
    my $db = parse_observations_yaml($text || '');
    my @changes;

    for my $record (@$records) {
        my $change = merge_observation($db, $record, $peer_ip);
        push @changes, $change if $change;
    }

    if (@changes) {
        if ($opt->{dry_run}) {
            print change_line($_) . "\n" for @changes;
        } else {
            $db->{updated_at} = iso_now();
            seek($lock_fh, 0, 0);
            truncate($lock_fh, 0) or die "Cannot truncate $database: $!\n";
            print {$lock_fh} render_observations_yaml($db);
            close $lock_fh or die "Cannot close $database: $!\n";
        }
    }

    return \@changes;
}

sub merge_observation {
    my ($db, $record, $peer_ip) = @_;
    my $now = iso_now();
    my $id = id_from_mdns_name($record->{name});
    my $key = "$record->{name}|$record->{ip}";
    my $existing;
    for my $observation (@{ $db->{observations} || [] }) {
        if (($observation->{key} || '') eq $key) {
            $existing = $observation;
            last;
        }
    }

    if (!$existing) {
        push @{ $db->{observations} }, {
            key => $key,
            id => $id,
            name => $record->{name},
            ip => $record->{ip},
            first_seen => $now,
            last_seen => $now,
            seen_count => 1,
            last_peer => $peer_ip,
            ttl => int($record->{ttl} || 0),
        };
        return { action => 'created', id => $id, name => $record->{name}, ip => $record->{ip} };
    }

    $existing->{last_seen} = $now;
    $existing->{seen_count} = int($existing->{seen_count} || 0) + 1;
    $existing->{last_peer} = $peer_ip;
    $existing->{ttl} = int($record->{ttl} || 0);
    return { action => 'updated', id => $existing->{id}, name => $record->{name}, ip => $record->{ip} };
}

sub parse_observations_yaml {
    my ($text) = @_;
    my %db = (
        version => 1,
        updated_at => '',
        source => 'mdns',
        observations => [],
    );
    my ($section, $current);
    for my $line (split /\n/, $text) {
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
        if ($line =~ /^version:\s*(\d+)/) {
            $db{version} = int($1);
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
            $db{updated_at} = yaml_unquote($1);
        } elsif ($line =~ /^source:\s*(.+)$/) {
            $db{source} = yaml_unquote($1);
        } elsif ($line =~ /^observations:\s*$/) {
            $section = 'observations';
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
            $current = { key => yaml_unquote($1) };
            push @{ $db{observations} }, $current;
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
            my ($key, $value) = ($1, yaml_unquote($2));
            $value = int($value || 0) if $key =~ /\A(?:seen_count|ttl)\z/;
            $current->{$key} = $value;
        }
    }
    return \%db;
}

sub render_observations_yaml {
    my ($db) = @_;
    my $out = "version: " . int($db->{version} || 1) . "\n";
    $out .= "updated_at: " . yq($db->{updated_at} || iso_now()) . "\n";
    $out .= "source: " . yq($db->{source} || 'mdns') . "\n";
    $out .= "observations:\n";
    for my $observation (sort { ($a->{name} || '') cmp ($b->{name} || '') || ($a->{ip} || '') cmp ($b->{ip} || '') } @{ $db->{observations} || [] }) {
        $out .= "  - key: " . yq($observation->{key}) . "\n";
        for my $key (qw(id name ip first_seen last_seen seen_count last_peer ttl)) {
            $out .= "    $key: " . yq($observation->{$key} || '') . "\n";
        }
    }
    return $out;
}

sub id_from_mdns_name {
    my ($name) = @_;
    $name =~ s/\.local\z//;
    $name =~ s/\([0-9]+\)\z//;
    $name = lc($name);
    $name =~ s/[^a-z0-9_.-]+/-/g;
    $name =~ s/^-+|-+$//g;
    return $name;
}

sub ensure_parent_dir {
    my ($path) = @_;
    my $dir = dirname($path);
    make_path($dir) unless -d $dir;
}

sub change_line {
    my ($change) = @_;
    return join(' ', map { "$_=$change->{$_}" } sort keys %$change);
}

sub yq {
    my ($value) = @_;
    $value = '' unless defined $value;
    $value =~ s/\\/\\\\/g;
    $value =~ s/"/\\"/g;
    return qq("$value");
}

sub yaml_unquote {
    my ($value) = @_;
    $value = '' unless defined $value;
    $value =~ s/^\s+|\s+$//g;
    if ($value =~ /^"(.*)"$/) {
        $value = $1;
        $value =~ s/\\"/"/g;
        $value =~ s/\\\\/\\/g;
    }
    return $value;
}

sub iso_now {
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
}