LocalAuthority / scripts / mdns_host_seed.pl
Newer Older
341 lines | 10.564kb
Bogdan Timofte authored 5 days ago
1
#!/usr/bin/env perl
2
#
3
# mdns_host_seed.pl - Collect mDNS A records into the host source database.
4
#
5

            
6
use strict;
7
use warnings;
8

            
9
use Cwd qw(abs_path);
10
use Fcntl qw(:flock);
11
use File::Basename qw(dirname);
12
use File::Path qw(make_path);
13
use Getopt::Long qw(GetOptions);
14
use IO::Socket::INET;
15
use POSIX qw(strftime);
16
use Socket qw(IPPROTO_IP IP_ADD_MEMBERSHIP SO_REUSEADDR inet_aton inet_ntoa sockaddr_in);
17

            
18
my $script_dir = dirname(abs_path($0));
19
my $project_dir = dirname($script_dir);
20

            
21
my %opt = (
22
    database => $ENV{HOST_MANAGER_MDNS_DB} || "$project_dir/var/mdns-observations.yaml",
23
    bind => $ENV{HOST_MANAGER_MDNS_BIND} || '0.0.0.0',
24
    group => $ENV{HOST_MANAGER_MDNS_GROUP} || '224.0.0.251',
25
    port => $ENV{HOST_MANAGER_MDNS_PORT} || 5353,
26
    once => 0,
27
    timeout => 0,
28
    dry_run => 0,
29
    verbose => 0,
30
);
31

            
32
GetOptions(
33
    'database|db=s' => \$opt{database},
34
    'bind=s' => \$opt{bind},
35
    'group=s' => \$opt{group},
36
    'port=i' => \$opt{port},
37
    'once' => \$opt{once},
38
    'timeout=i' => \$opt{timeout},
39
    'dry-run' => \$opt{dry_run},
40
    'verbose' => \$opt{verbose},
41
    'help|h' => sub { usage(); exit 0; },
42
) or do {
43
    usage();
44
    exit 2;
45
};
46

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

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

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

            
61
my $deadline = $opt{timeout} ? time() + $opt{timeout} : 0;
62
while (1) {
63
    my $timeout = undef;
64
    if ($deadline) {
65
        $timeout = $deadline - time();
66
        last if $timeout <= 0;
67
    }
68

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

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

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

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

            
87
exit 0;
88

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

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

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

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

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

            
120
    my @records;
121
    for (1 .. ($an + $ns + $ar)) {
122
        my ($name, $next) = read_dns_name($packet, $offset);
123
        last unless defined $name && $next + 10 <= length($packet);
124
        my ($type, $class, $ttl, $rdlen) = unpack('nnNn', substr($packet, $next, 10));
125
        my $rdata_offset = $next + 10;
126
        last if $rdata_offset + $rdlen > length($packet);
127
        if ($type == 1 && $rdlen == 4) {
128
            push @records, {
129
                name => lc($name),
130
                ip => inet_ntoa(substr($packet, $rdata_offset, 4)),
131
                ttl => $ttl,
132
            };
133
        }
134
        $offset = $rdata_offset + $rdlen;
135
    }
136

            
137
    return @records;
138
}
139

            
140
sub read_dns_name {
141
    my ($packet, $offset) = @_;
142
    my @labels;
143
    my $pos = $offset;
144
    my $next;
145
    my %seen;
146

            
147
    while (1) {
148
        return unless $pos < length($packet);
149
        return if $seen{$pos}++;
150
        my $len = ord(substr($packet, $pos, 1));
151
        if (($len & 0xc0) == 0xc0) {
152
            return unless $pos + 1 < length($packet);
153
            my $ptr = unpack('n', substr($packet, $pos, 2)) & 0x3fff;
154
            $next = $pos + 2 unless defined $next;
155
            $pos = $ptr;
156
            next;
157
        }
158
        if ($len == 0) {
159
            $next = $pos + 1 unless defined $next;
160
            last;
161
        }
162
        return if ($len & 0xc0) || $pos + 1 + $len > length($packet);
163
        push @labels, substr($packet, $pos + 1, $len);
164
        $pos += 1 + $len;
165
    }
166

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

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

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

            
185
sub seed_observations {
186
    my ($opt, $records, $peer_ip) = @_;
187
    my $database = $opt->{database};
188
    ensure_parent_dir($database);
189
    open my $lock_fh, '+>>', $database or die "Cannot open $database: $!\n";
190
    flock($lock_fh, LOCK_EX) or die "Cannot lock $database: $!\n";
191
    seek($lock_fh, 0, 0);
192
    local $/;
193
    my $text = <$lock_fh>;
194
    my $db = parse_observations_yaml($text || '');
195
    my @changes;
196

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

            
202
    if (@changes) {
203
        if ($opt->{dry_run}) {
204
            print change_line($_) . "\n" for @changes;
205
        } else {
206
            $db->{updated_at} = iso_now();
207
            seek($lock_fh, 0, 0);
208
            truncate($lock_fh, 0) or die "Cannot truncate $database: $!\n";
209
            print {$lock_fh} render_observations_yaml($db);
210
            close $lock_fh or die "Cannot close $database: $!\n";
211
        }
212
    }
213

            
214
    return \@changes;
215
}
216

            
217
sub merge_observation {
218
    my ($db, $record, $peer_ip) = @_;
219
    my $now = iso_now();
220
    my $id = id_from_mdns_name($record->{name});
221
    my $key = "$record->{name}|$record->{ip}";
222
    my $existing;
223
    for my $observation (@{ $db->{observations} || [] }) {
224
        if (($observation->{key} || '') eq $key) {
225
            $existing = $observation;
226
            last;
227
        }
228
    }
229

            
230
    if (!$existing) {
231
        push @{ $db->{observations} }, {
232
            key => $key,
233
            id => $id,
234
            name => $record->{name},
235
            ip => $record->{ip},
236
            first_seen => $now,
237
            last_seen => $now,
238
            seen_count => 1,
239
            last_peer => $peer_ip,
240
            ttl => int($record->{ttl} || 0),
241
        };
242
        return { action => 'created', id => $id, name => $record->{name}, ip => $record->{ip} };
243
    }
244

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

            
252
sub parse_observations_yaml {
253
    my ($text) = @_;
254
    my %db = (
255
        version => 1,
256
        updated_at => '',
257
        source => 'mdns',
258
        observations => [],
259
    );
260
    my ($section, $current);
261
    for my $line (split /\n/, $text) {
262
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
263
        if ($line =~ /^version:\s*(\d+)/) {
264
            $db{version} = int($1);
265
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
266
            $db{updated_at} = yaml_unquote($1);
267
        } elsif ($line =~ /^source:\s*(.+)$/) {
268
            $db{source} = yaml_unquote($1);
269
        } elsif ($line =~ /^observations:\s*$/) {
270
            $section = 'observations';
271
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
272
            $current = { key => yaml_unquote($1) };
273
            push @{ $db{observations} }, $current;
274
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
275
            my ($key, $value) = ($1, yaml_unquote($2));
276
            $value = int($value || 0) if $key =~ /\A(?:seen_count|ttl)\z/;
277
            $current->{$key} = $value;
278
        }
279
    }
280
    return \%db;
281
}
282

            
283
sub render_observations_yaml {
284
    my ($db) = @_;
285
    my $out = "version: " . int($db->{version} || 1) . "\n";
286
    $out .= "updated_at: " . yq($db->{updated_at} || iso_now()) . "\n";
287
    $out .= "source: " . yq($db->{source} || 'mdns') . "\n";
288
    $out .= "observations:\n";
289
    for my $observation (sort { ($a->{name} || '') cmp ($b->{name} || '') || ($a->{ip} || '') cmp ($b->{ip} || '') } @{ $db->{observations} || [] }) {
290
        $out .= "  - key: " . yq($observation->{key}) . "\n";
291
        for my $key (qw(id name ip first_seen last_seen seen_count last_peer ttl)) {
292
            $out .= "    $key: " . yq($observation->{$key} || '') . "\n";
293
        }
294
    }
295
    return $out;
296
}
297

            
298
sub id_from_mdns_name {
299
    my ($name) = @_;
300
    $name =~ s/\.local\z//;
301
    $name =~ s/\([0-9]+\)\z//;
302
    $name = lc($name);
303
    $name =~ s/[^a-z0-9_.-]+/-/g;
304
    $name =~ s/^-+|-+$//g;
305
    return $name;
306
}
307

            
308
sub ensure_parent_dir {
309
    my ($path) = @_;
310
    my $dir = dirname($path);
311
    make_path($dir) unless -d $dir;
312
}
313

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

            
319
sub yq {
320
    my ($value) = @_;
321
    $value = '' unless defined $value;
322
    $value =~ s/\\/\\\\/g;
323
    $value =~ s/"/\\"/g;
324
    return qq("$value");
325
}
326

            
327
sub yaml_unquote {
328
    my ($value) = @_;
329
    $value = '' unless defined $value;
330
    $value =~ s/^\s+|\s+$//g;
331
    if ($value =~ /^"(.*)"$/) {
332
        $value = $1;
333
        $value =~ s/\\"/"/g;
334
        $value =~ s/\\\\/\\/g;
335
    }
336
    return $value;
337
}
338

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