LocalAuthority / scripts / mdns_host_seed.pl
Newer Older
326 lines | 10.103kb
Bogdan Timofte authored 4 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 File::Basename qw(dirname);
11
use File::Path qw(make_path);
12
use Getopt::Long qw(GetOptions);
13
use IO::Socket::INET;
14
use POSIX qw(strftime);
15
use Socket qw(IPPROTO_IP IP_ADD_MEMBERSHIP SO_REUSEADDR inet_aton inet_ntoa sockaddr_in);
Bogdan Timofte authored 4 days ago
16
use DBI;
Bogdan Timofte authored 4 days ago
17

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

            
21
my %opt = (
Bogdan Timofte authored 4 days ago
22
    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
23
    worker_id => $ENV{HOST_MANAGER_MDNS_WORKER_ID} || 'mdns-listener',
Bogdan Timofte authored 4 days ago
24
    bind => $ENV{HOST_MANAGER_MDNS_BIND} || '0.0.0.0',
25
    group => $ENV{HOST_MANAGER_MDNS_GROUP} || '224.0.0.251',
26
    port => $ENV{HOST_MANAGER_MDNS_PORT} || 5353,
27
    once => 0,
28
    timeout => 0,
29
    dry_run => 0,
30
    verbose => 0,
31
);
32

            
33
GetOptions(
Bogdan Timofte authored 4 days ago
34
    'db=s' => \$opt{db},
35
    'worker-id=s' => \$opt{worker_id},
Bogdan Timofte authored 4 days ago
36
    'bind=s' => \$opt{bind},
37
    'group=s' => \$opt{group},
38
    'port=i' => \$opt{port},
39
    'once' => \$opt{once},
40
    'timeout=i' => \$opt{timeout},
41
    'dry-run' => \$opt{dry_run},
42
    'verbose' => \$opt{verbose},
43
    'help|h' => sub { usage(); exit 0; },
44
) or do {
45
    usage();
46
    exit 2;
47
};
48

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

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

            
60
print "mdns host seed listening on udp://$opt{bind}:$opt{port} group $opt{group}\n" if $opt{verbose};
Bogdan Timofte authored 4 days ago
61
my $dbh = open_database(\%opt);
62
print "mDNS SQLite database: $opt{db}\n" if $opt{verbose};
63
print "mDNS worker id: $opt{worker_id}\n" if $opt{verbose};
Bogdan Timofte authored 4 days ago
64

            
65
my $deadline = $opt{timeout} ? time() + $opt{timeout} : 0;
66
while (1) {
67
    my $timeout = undef;
68
    if ($deadline) {
69
        $timeout = $deadline - time();
70
        last if $timeout <= 0;
71
    }
72

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

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

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

            
Bogdan Timofte authored 4 days ago
86
    my $changed = seed_observations($dbh, \%opt, \@records, $peer_ip);
Bogdan Timofte authored 4 days ago
87
    print "stored " . scalar(@$changed) . " observation change(s)\n" if $opt{verbose} && @$changed;
88
    last if $opt{once} && @$changed;
89
}
90

            
91
exit 0;
92

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

            
97
Options:
Bogdan Timofte authored 4 days ago
98
  --db path        SQLite database. Defaults to var/host-manager.sqlite.
99
  --worker-id id   data_workers.worker_id. Defaults to mdns-listener.
Bogdan Timofte authored 4 days ago
100
  --bind addr      Local bind address. Defaults to 0.0.0.0.
101
  --group addr     Multicast group. Defaults to 224.0.0.251.
102
  --port n         UDP port. Defaults to 5353.
103
  --once           Exit after the first stored observation.
104
  --timeout n      Exit after n seconds.
105
  --dry-run        Print proposed observation changes without writing.
106
  --verbose        Print listener and change details.
107

            
Bogdan Timofte authored 4 days ago
108
Only A records for private/link-local .local names are collected. Observations
109
are upserted into SQLite table mdns_observations. hosts.yaml is not modified.
Bogdan Timofte authored 4 days ago
110
EOF
111
}
112

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

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

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

            
142
    return @records;
143
}
144

            
145
sub read_dns_name {
146
    my ($packet, $offset) = @_;
147
    my @labels;
148
    my $pos = $offset;
149
    my $next;
150
    my %seen;
151

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

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

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

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

            
Bogdan Timofte authored 4 days ago
190
sub open_database {
191
    my ($opt) = @_;
192
    ensure_parent_dir($opt->{db});
193
    my $dbh = DBI->connect(
194
        "dbi:SQLite:dbname=$opt->{db}",
195
        '',
196
        '',
197
        {
198
            RaiseError => 1,
199
            PrintError => 0,
200
            AutoCommit => 1,
201
            sqlite_unicode => 1,
202
        },
203
    ) or die "Cannot open SQLite database $opt->{db}\n";
204
    $dbh->do('PRAGMA journal_mode = WAL');
205
    $dbh->do('PRAGMA foreign_keys = ON');
206
    ensure_runtime_schema_exists($dbh);
207
    upsert_worker($dbh, $opt->{worker_id});
208
    return $dbh;
209
}
210

            
211
sub ensure_runtime_schema_exists {
212
    my ($dbh) = @_;
213
    for my $table (qw(data_workers mdns_observations)) {
214
        my ($exists) = $dbh->selectrow_array(
215
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
216
            undef,
217
            $table,
218
        );
219
        die "Missing SQLite table $table; start host-manager once to initialize schema\n" unless $exists;
220
    }
221
}
222

            
223
sub upsert_worker {
224
    my ($dbh, $worker_id) = @_;
225
    my $now = iso_now();
226
    $dbh->do(
227
        'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
228
        . "VALUES (?, 'mdns', 'mDNS listener', 'active', 'udp://224.0.0.251:5353', ?, 'mDNS observation collector source.', ?, ?) "
229
        . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, status = excluded.status, '
230
        . 'source = excluded.source, last_run_at = excluded.last_run_at, notes = excluded.notes, updated_at = excluded.updated_at',
231
        undef,
232
        $worker_id,
233
        $now,
234
        $now,
235
        $now,
236
    );
237
}
238

            
Bogdan Timofte authored 4 days ago
239
sub seed_observations {
Bogdan Timofte authored 4 days ago
240
    my ($dbh, $opt, $records, $peer_ip) = @_;
Bogdan Timofte authored 4 days ago
241
    my @changes;
242

            
243
    for my $record (@$records) {
Bogdan Timofte authored 4 days ago
244
        my $change = merge_observation($dbh, $opt, $record, $peer_ip);
Bogdan Timofte authored 4 days ago
245
        push @changes, $change if $change;
246
    }
247

            
Bogdan Timofte authored 4 days ago
248
    if (@changes && $opt->{dry_run}) {
249
        print change_line($_) . "\n" for @changes;
Bogdan Timofte authored 4 days ago
250
    }
251

            
252
    return \@changes;
253
}
254

            
255
sub merge_observation {
Bogdan Timofte authored 4 days ago
256
    my ($dbh, $opt, $record, $peer_ip) = @_;
Bogdan Timofte authored 4 days ago
257
    my $now = iso_now();
258
    my $key = "$record->{name}|$record->{ip}";
Bogdan Timofte authored 4 days ago
259
    my $existing = $dbh->selectrow_hashref(
260
        'SELECT observation_key, seen_count FROM mdns_observations WHERE observation_key = ?',
261
        undef,
262
        $key,
263
    );
264
    my $raw = raw_observation($record, $peer_ip);
Bogdan Timofte authored 4 days ago
265

            
266
    if (!$existing) {
Bogdan Timofte authored 4 days ago
267
        unless ($opt->{dry_run}) {
268
            $dbh->do(
269
                'INSERT INTO mdns_observations '
270
                . '(observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer, raw) '
271
                . "VALUES (?, ?, NULL, ?, ?, 'A', ?, ?, ?, 1, ?, ?)",
272
                undef,
273
                $key,
274
                $opt->{worker_id},
275
                $record->{name},
276
                $record->{ip},
277
                int($record->{ttl} || 0),
278
                $now,
279
                $now,
280
                $peer_ip,
281
                $raw,
282
            );
Bogdan Timofte authored 4 days ago
283
        }
Bogdan Timofte authored 4 days ago
284
        return { action => 'created', key => $key, name => $record->{name}, ip => $record->{ip} };
Bogdan Timofte authored 4 days ago
285
    }
286

            
Bogdan Timofte authored 4 days ago
287
    unless ($opt->{dry_run}) {
288
        $dbh->do(
289
            'UPDATE mdns_observations SET ttl = ?, last_seen = ?, seen_count = seen_count + 1, last_peer = ?, raw = ? WHERE observation_key = ?',
290
            undef,
291
            int($record->{ttl} || 0),
292
            $now,
293
            $peer_ip,
294
            $raw,
295
            $key,
296
        );
Bogdan Timofte authored 4 days ago
297
    }
Bogdan Timofte authored 4 days ago
298
    return { action => 'updated', key => $key, name => $record->{name}, ip => $record->{ip} };
Bogdan Timofte authored 4 days ago
299
}
300

            
Bogdan Timofte authored 4 days ago
301
sub raw_observation {
302
    my ($record, $peer_ip) = @_;
303
    return join(' ', (
304
        'source=mdns',
305
        'rr_type=A',
306
        'name=' . ($record->{name} || ''),
307
        'ip=' . ($record->{ip} || ''),
308
        'ttl=' . int($record->{ttl} || 0),
309
        'peer=' . ($peer_ip || ''),
310
    ));
Bogdan Timofte authored 4 days ago
311
}
312

            
313
sub ensure_parent_dir {
314
    my ($path) = @_;
315
    my $dir = dirname($path);
316
    make_path($dir) unless -d $dir;
317
}
318

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

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