Showing 4 changed files with 118 additions and 134 deletions
+3 -3
.doc/local-hosts.md
@@ -55,15 +55,15 @@ Reguli de împăcare:
55 55
 
56 56
 ## Listener mDNS
57 57
 
58
-`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.
58
+`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 runtime SQLite.
59 59
 
60 60
 Locația implicită:
61 61
 
62 62
 ```text
63
-var/mdns-observations.yaml
63
+var/host-manager.sqlite#mdns_observations
64 64
 ```
65 65
 
66
-Regulă importantă: listenerul mDNS nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review.
66
+Regulă importantă: listenerul mDNS scrie doar în tabelul `mdns_observations`; nu modifică registry-ul de hosturi, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review.
67 67
 
68 68
 Rulare manuală pentru test:
69 69
 
+1 -2
deploy/jumper/README.md
@@ -34,7 +34,6 @@ sudo dnf install nginx
34 34
   config/hosts.yaml
35 35
   config/local-hosts.tsv
36 36
   var/host-manager.sqlite
37
-  var/mdns-observations.yaml
38 37
   scripts/host_manager.pl
39 38
   scripts/mdns_host_seed.pl
40 39
   scripts/sync_local_hosts.sh
@@ -102,4 +101,4 @@ Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat.
102 101
 
103 102
 ## mDNS discovery
104 103
 
105
-`host-manager-mdns` este un listener separat care observă mDNS și seeduiește `var/mdns-observations.yaml`. Listenerul nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`.
104
+`host-manager-mdns` este un listener separat care observă mDNS și scrie direct în tabelul SQLite `mdns_observations`. Listenerul nu modifică host registry-ul, `config/hosts.yaml` sau `config/local-hosts.tsv`.
+3 -3
deploy/jumper/host-manager-mdns.service
@@ -1,7 +1,7 @@
1 1
 [Unit]
2 2
 Description=Xdev Host Manager mDNS Seed Listener
3
-After=network-online.target
4
-Wants=network-online.target
3
+After=network-online.target host-manager.service
4
+Wants=network-online.target host-manager.service
5 5
 
6 6
 [Service]
7 7
 Type=simple
@@ -10,7 +10,7 @@ Group=host-manager
10 10
 WorkingDirectory=/usr/local/xdev-host-manager
11 11
 EnvironmentFile=/etc/xdev/host-manager.env
12 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
13
+ExecStart=/usr/bin/perl /usr/local/xdev-host-manager/scripts/mdns_host_seed.pl --db /usr/local/xdev-host-manager/var/host-manager.sqlite
14 14
 Restart=on-failure
15 15
 RestartSec=3
16 16
 NoNewPrivileges=true
+111 -126
scripts/mdns_host_seed.pl
@@ -7,19 +7,20 @@ use strict;
7 7
 use warnings;
8 8
 
9 9
 use Cwd qw(abs_path);
10
-use Fcntl qw(:flock);
11 10
 use File::Basename qw(dirname);
12 11
 use File::Path qw(make_path);
13 12
 use Getopt::Long qw(GetOptions);
14 13
 use IO::Socket::INET;
15 14
 use POSIX qw(strftime);
16 15
 use Socket qw(IPPROTO_IP IP_ADD_MEMBERSHIP SO_REUSEADDR inet_aton inet_ntoa sockaddr_in);
16
+use DBI;
17 17
 
18 18
 my $script_dir = dirname(abs_path($0));
19 19
 my $project_dir = dirname($script_dir);
20 20
 
21 21
 my %opt = (
22
-    database => $ENV{HOST_MANAGER_MDNS_DB} || "$project_dir/var/mdns-observations.yaml",
22
+    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
23
+    worker_id => $ENV{HOST_MANAGER_MDNS_WORKER_ID} || 'mdns-listener',
23 24
     bind => $ENV{HOST_MANAGER_MDNS_BIND} || '0.0.0.0',
24 25
     group => $ENV{HOST_MANAGER_MDNS_GROUP} || '224.0.0.251',
25 26
     port => $ENV{HOST_MANAGER_MDNS_PORT} || 5353,
@@ -30,7 +31,8 @@ my %opt = (
30 31
 );
31 32
 
32 33
 GetOptions(
33
-    'database|db=s' => \$opt{database},
34
+    'db=s' => \$opt{db},
35
+    'worker-id=s' => \$opt{worker_id},
34 36
     'bind=s' => \$opt{bind},
35 37
     'group=s' => \$opt{group},
36 38
     'port=i' => \$opt{port},
@@ -56,7 +58,9 @@ setsockopt($socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, inet_aton($opt{group}) . inet
56 58
     or die "Cannot join mDNS multicast group $opt{group}: $!\n";
57 59
 
58 60
 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};
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};
60 64
 
61 65
 my $deadline = $opt{timeout} ? time() + $opt{timeout} : 0;
62 66
 while (1) {
@@ -79,7 +83,7 @@ while (1) {
79 83
     my @records = grep { record_is_usable($_) } parse_mdns_packet($packet);
80 84
     next unless @records;
81 85
 
82
-    my $changed = seed_observations(\%opt, \@records, $peer_ip);
86
+    my $changed = seed_observations($dbh, \%opt, \@records, $peer_ip);
83 87
     print "stored " . scalar(@$changed) . " observation change(s)\n" if $opt{verbose} && @$changed;
84 88
     last if $opt{once} && @$changed;
85 89
 }
@@ -91,7 +95,8 @@ sub usage {
91 95
 Usage: perl scripts/mdns_host_seed.pl [options]
92 96
 
93 97
 Options:
94
-  --database path  mDNS source database. Defaults to var/mdns-observations.yaml.
98
+  --db path        SQLite database. Defaults to var/host-manager.sqlite.
99
+  --worker-id id   data_workers.worker_id. Defaults to mdns-listener.
95 100
   --bind addr      Local bind address. Defaults to 0.0.0.0.
96 101
   --group addr     Multicast group. Defaults to 224.0.0.251.
97 102
   --port n         UDP port. Defaults to 5353.
@@ -100,8 +105,8 @@ Options:
100 105
   --dry-run        Print proposed observation changes without writing.
101 106
   --verbose        Print listener and change details.
102 107
 
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.
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.
105 110
 EOF
106 111
 }
107 112
 
@@ -182,127 +187,127 @@ sub ip_is_observable {
182 187
         || $ip =~ /\A169\.254\.\d+\.\d+\z/;
183 188
 }
184 189
 
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
+
185 239
 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 || '');
240
+    my ($dbh, $opt, $records, $peer_ip) = @_;
195 241
     my @changes;
196 242
 
197 243
     for my $record (@$records) {
198
-        my $change = merge_observation($db, $record, $peer_ip);
244
+        my $change = merge_observation($dbh, $opt, $record, $peer_ip);
199 245
         push @changes, $change if $change;
200 246
     }
201 247
 
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
-        }
248
+    if (@changes && $opt->{dry_run}) {
249
+        print change_line($_) . "\n" for @changes;
212 250
     }
213 251
 
214 252
     return \@changes;
215 253
 }
216 254
 
217 255
 sub merge_observation {
218
-    my ($db, $record, $peer_ip) = @_;
256
+    my ($dbh, $opt, $record, $peer_ip) = @_;
219 257
     my $now = iso_now();
220
-    my $id = id_from_mdns_name($record->{name});
221 258
     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
-    }
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);
229 265
 
230 266
     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;
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
+            );
278 283
         }
284
+        return { action => 'created', key => $key, name => $record->{name}, ip => $record->{ip} };
279 285
     }
280
-    return \%db;
281
-}
282 286
 
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
-        }
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
+        );
294 297
     }
295
-    return $out;
298
+    return { action => 'updated', key => $key, name => $record->{name}, ip => $record->{ip} };
296 299
 }
297 300
 
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;
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
+    ));
306 311
 }
307 312
 
308 313
 sub ensure_parent_dir {
@@ -316,26 +321,6 @@ sub change_line {
316 321
     return join(' ', map { "$_=$change->{$_}" } sort keys %$change);
317 322
 }
318 323
 
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 324
 sub iso_now {
340 325
     return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
341 326
 }