Showing 7 changed files with 412 additions and 5 deletions
+32 -0
.doc/local-hosts.md
@@ -51,6 +51,38 @@ Reguli de împăcare:
51 51
 - Prefixele istorice `is-`, `vad-` și `b-` se elimină la normalizarea numelor. `.vad.is.xdev.ro` rămâne deprecated și nu se reactivează prin aliasuri.
52 52
 - Dacă o sursă observată există doar în mDNS sau doar în lease-uri dinamice, se raportează ca propunere, nu se sincronizează automat în DNS.
53 53
 
54
+## Listener mDNS
55
+
56
+`scripts/mdns_host_seed.pl` ascultă multicast mDNS pe `224.0.0.251:5353`, colectează recorduri `A` pentru nume `*.local` din intervale private/link-local și seeduiește baza de observații mDNS.
57
+
58
+Locația implicită:
59
+
60
+```text
61
+var/mdns-observations.yaml
62
+```
63
+
64
+Regulă importantă: `config/hosts.yaml` este un output generat din surse, nu store-ul primar al listenerului. Listenerul mDNS nu modifică `config/hosts.yaml` și nu publică nimic direct în `config/local-hosts.tsv`.
65
+
66
+Rulare manuală pentru test:
67
+
68
+```bash
69
+perl scripts/mdns_host_seed.pl --verbose --once --timeout 120
70
+```
71
+
72
+Dry-run:
73
+
74
+```bash
75
+perl scripts/mdns_host_seed.pl --dry-run --verbose --timeout 30
76
+```
77
+
78
+Pe jumper, listenerul poate rula ca serviciu separat:
79
+
80
+```bash
81
+sudo install -m 0644 deploy/jumper/host-manager-mdns.service /etc/systemd/system/host-manager-mdns.service
82
+sudo systemctl daemon-reload
83
+sudo systemctl enable --now host-manager-mdns
84
+```
85
+
54 86
 Pe jumper, LAN-ul trebuie servit direct de `dnscrypt-proxy` pe `192.168.2.100:53`. `systemd-resolved` rămâne doar stub local și trebuie să aibă `ReadEtcHosts=no` plus upstream unic `DNS=127.0.0.1:5300`. Altfel fie publică intrările din `/etc/hosts` către clienții LAN, fie ocolește cloaking-rules și întreabă DNS public direct. Scriptul de sync verifică și aplică aceste setări.
55 87
 
56 88
 `dnscrypt-proxy` folosește blocklist; `google.com` și `www.google.com` sunt allowlist-uite explicit ca să nu fie întoarse ca `0.0.0.0`.
+1 -1
agents.md
@@ -19,7 +19,7 @@ Operational rules:
19 19
 - Treat `config/hosts.yaml`, `config/local-hosts.tsv`, and `config/work-orders.yaml` as operational data that may be changed by the live app.
20 20
 - Do not install npm, pip, or CPAN packages directly on hosts. Distribution packages are acceptable when needed.
21 21
 - Perl from the distribution and core/distribution modules are allowed.
22
-- CPAN dependencies must come from the audited local repository if they become necessary.
22
+- CPAN modules are allowed only after requesting an audit and RPM packaging for the local audited repository.
23 23
 - Secrets live outside git, mainly under `/etc/xdev/host-manager.env` on jumper.
24 24
 
25 25
 Before code changes:
+9 -0
deploy/jumper/README.md
@@ -31,11 +31,14 @@ sudo dnf install nginx
31 31
 /usr/local/xdev-host-manager
32 32
   config/hosts.yaml
33 33
   config/local-hosts.tsv
34
+  var/mdns-observations.yaml
34 35
   scripts/host_manager.pl
36
+  scripts/mdns_host_seed.pl
35 37
   scripts/sync_local_hosts.sh
36 38
 
37 39
 /etc/xdev/host-manager.env
38 40
 /etc/systemd/system/host-manager.service
41
+/etc/systemd/system/host-manager-mdns.service
39 42
 /etc/nginx/conf.d/hosts.madagascar.xdev.ro.conf
40 43
 ```
41 44
 
@@ -48,6 +51,7 @@ id -u host-manager >/dev/null 2>&1 || sudo useradd --system --home-dir /usr/loca
48 51
 sudo install -d -o host-manager -g host-manager /usr/local/xdev-host-manager
49 52
 sudo install -d -m 0750 /etc/xdev
50 53
 sudo install -m 0644 deploy/jumper/host-manager.service /etc/systemd/system/host-manager.service
54
+sudo install -m 0644 deploy/jumper/host-manager-mdns.service /etc/systemd/system/host-manager-mdns.service
51 55
 sudo install -m 0644 deploy/jumper/nginx-host-manager.conf /etc/nginx/conf.d/hosts.madagascar.xdev.ro.conf
52 56
 ```
53 57
 
@@ -64,6 +68,7 @@ Validare:
64 68
 ```bash
65 69
 sudo systemctl daemon-reload
66 70
 sudo systemctl enable --now host-manager
71
+sudo systemctl enable --now host-manager-mdns
67 72
 sudo nginx -t
68 73
 sudo systemctl reload nginx
69 74
 curl -fsS http://127.0.0.1:8088/healthz
@@ -87,3 +92,7 @@ hosts.madagascar.xdev.ro -> 192.168.2.100
87 92
 ```
88 93
 
89 94
 Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat.
95
+
96
+## mDNS discovery
97
+
98
+`host-manager-mdns` este un listener separat care observă mDNS și seeduiește `var/mdns-observations.yaml`. `config/hosts.yaml` rămâne output generat din surse și nu este modificat direct de listener.
+23 -0
deploy/jumper/host-manager-mdns.service
@@ -0,0 +1,23 @@
1
+[Unit]
2
+Description=Xdev Host Manager mDNS Seed Listener
3
+After=network-online.target
4
+Wants=network-online.target
5
+
6
+[Service]
7
+Type=simple
8
+User=host-manager
9
+Group=host-manager
10
+WorkingDirectory=/usr/local/xdev-host-manager
11
+EnvironmentFile=/etc/xdev/host-manager.env
12
+ExecStartPre=+/usr/bin/install -d -o host-manager -g host-manager /usr/local/xdev-host-manager/var
13
+ExecStart=/usr/bin/perl /usr/local/xdev-host-manager/scripts/mdns_host_seed.pl --database /usr/local/xdev-host-manager/var/mdns-observations.yaml
14
+Restart=on-failure
15
+RestartSec=3
16
+NoNewPrivileges=true
17
+PrivateTmp=true
18
+ProtectSystem=full
19
+ProtectHome=true
20
+ReadWritePaths=/usr/local/xdev-host-manager/var
21
+
22
+[Install]
23
+WantedBy=multi-user.target
+3 -2
scripts/deploy_to_jumper.sh
@@ -79,6 +79,7 @@ ssh "$TARGET_HOST" "command -v rsync >/dev/null 2>&1" || {
79 79
 }
80 80
 
81 81
 perl -c scripts/host_manager.pl >/dev/null
82
+perl -c scripts/mdns_host_seed.pl >/dev/null
82 83
 BUILD_REVISION="$(git rev-parse --short=12 HEAD)"
83 84
 BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
84 85
 BUILD_DIRTY=0
@@ -92,7 +93,7 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
92 93
     rsync_args+=(--dry-run --itemize-changes)
93 94
 fi
94 95
 
95
-ssh "$TARGET_HOST" "mkdir -p '$TARGET_DIR/scripts' '$TARGET_DIR/deploy' '$TARGET_DIR/.doc'"
96
+ssh "$TARGET_HOST" "mkdir -p '$TARGET_DIR/scripts' '$TARGET_DIR/deploy' '$TARGET_DIR/.doc' '$TARGET_DIR/var'"
96 97
 rsync "${rsync_args[@]}" scripts/ "$TARGET_HOST:$TARGET_DIR/scripts/"
97 98
 rsync "${rsync_args[@]}" deploy/ "$TARGET_HOST:$TARGET_DIR/deploy/"
98 99
 rsync "${rsync_args[@]}" .doc/ "$TARGET_HOST:$TARGET_DIR/.doc/"
@@ -111,7 +112,7 @@ printf 'revision=%s\nbranch=%s\ndirty=%s\ndeployed_at=%s\n' \
111 112
     "$BUILD_REVISION" "$BUILD_BRANCH" "$BUILD_DIRTY" "$BUILD_DEPLOYED_AT" |
112 113
     ssh "$TARGET_HOST" "cat > '$TARGET_DIR/BUILD'"
113 114
 
114
-ssh "$TARGET_HOST" "cd '$TARGET_DIR' && perl -c scripts/host_manager.pl >/dev/null"
115
+ssh "$TARGET_HOST" "cd '$TARGET_DIR' && perl -c scripts/host_manager.pl >/dev/null && perl -c scripts/mdns_host_seed.pl >/dev/null"
115 116
 
116 117
 if [[ "$RESTART" -eq 1 ]]; then
117 118
     ssh "$TARGET_HOST" "sudo -n systemctl restart host-manager && systemctl is-active host-manager >/dev/null"
+3 -2
scripts/host_manager.pl
@@ -1396,8 +1396,9 @@ sub app_html {
1396 1396
       padding: 2px 5px;
1397 1397
       font-size: 10px;
1398 1398
       line-height: 1.2;
1399
-      pointer-events: none;
1400
-      user-select: none;
1399
+      cursor: text;
1400
+      pointer-events: auto;
1401
+      user-select: text;
1401 1402
     }
1402 1403
     body.is-app .build-badge {
1403 1404
       color: rgba(100,112,132,.58);
+341 -0
scripts/mdns_host_seed.pl
@@ -0,0 +1,341 @@
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
+}