|
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
|
}
|