LocalAuthority / scripts / host_manager.pl
Newer Older
3602 lines | 138.255kb
Xdev Host Manager authored a week ago
1
#!/usr/bin/env perl
2
#
3
# host_manager.pl - Minimal host registry web app with no CPAN dependencies.
4
#
5

            
6
use strict;
7
use warnings;
8

            
9
use Cwd qw(abs_path);
Bogdan Timofte authored 4 days ago
10
use DBI;
Xdev Host Manager authored a week ago
11
use Digest::SHA qw(hmac_sha1 hmac_sha256_hex sha256_hex);
12
use File::Basename qw(dirname);
13
use File::Path qw(make_path);
14
use IO::Socket::INET;
15
use POSIX qw(strftime);
16
use Time::HiRes qw(time);
17

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

            
21
my %opt = (
22
    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
23
    port => $ENV{HOST_MANAGER_PORT} || 8088,
Bogdan Timofte authored 4 days ago
24
    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
Xdev Host Manager authored a week ago
25
    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
26
    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
Xdev Host Manager authored a week ago
27
    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
Xdev Host Manager authored a week ago
28
);
29

            
30
while (@ARGV) {
31
    my $arg = shift @ARGV;
32
    if ($arg eq '--bind') {
33
        $opt{bind} = shift @ARGV;
34
    } elsif ($arg eq '--port') {
35
        $opt{port} = shift @ARGV;
Bogdan Timofte authored 4 days ago
36
    } elsif ($arg eq '--db') {
37
        $opt{db} = shift @ARGV;
Xdev Host Manager authored a week ago
38
    } elsif ($arg eq '--data') {
39
        $opt{data} = shift @ARGV;
40
    } elsif ($arg eq '--local-hosts-tsv') {
41
        $opt{local_hosts_tsv} = shift @ARGV;
Xdev Host Manager authored a week ago
42
    } elsif ($arg eq '--work-orders') {
43
        $opt{work_orders} = shift @ARGV;
Xdev Host Manager authored a week ago
44
    } elsif ($arg eq '--help' || $arg eq '-h') {
45
        usage();
46
        exit 0;
47
    } else {
48
        die "Unknown option: $arg\n";
49
    }
50
}
51

            
52
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
53
my %sessions;
54

            
55
my $server = IO::Socket::INET->new(
56
    LocalHost => $opt{bind},
57
    LocalPort => $opt{port},
58
    Proto => 'tcp',
59
    Listen => 10,
60
    ReuseAddr => 1,
61
) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
62

            
63
print "host-manager listening on http://$opt{bind}:$opt{port}\n";
Bogdan Timofte authored 4 days ago
64
print "database: $opt{db}\n";
65
print "seed/export hosts file: $opt{data}\n";
Xdev Host Manager authored a week ago
66
print "OTP login: " . ($ENV{HOST_MANAGER_TOTP_SECRET} ? "enabled\n" : "disabled; set HOST_MANAGER_TOTP_SECRET\n");
67

            
68
while (my $client = $server->accept) {
69
    eval {
70
        $client->autoflush(1);
71
        handle_client($client);
72
    };
73
    if ($@) {
74
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
75
    }
76
    close $client;
77
}
78

            
79
sub usage {
80
    print <<"EOF";
81
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
82

            
83
Environment:
84
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
85
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
Bogdan Timofte authored 4 days ago
86
  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
Xdev Host Manager authored a week ago
87
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
88
  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
Xdev Host Manager authored a week ago
89
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Xdev Host Manager authored a week ago
90

            
Bogdan Timofte authored 4 days ago
91
SQLite is the runtime source of truth. YAML files seed a new database and remain
92
download/export compatibility artifacts. The nginx vhost keeps registry, CA,
93
work order and download endpoints behind OTP.
Xdev Host Manager authored a week ago
94
EOF
95
}
96

            
97
sub handle_client {
98
    my ($client) = @_;
99
    my $request_line = <$client>;
100
    return unless defined $request_line;
101
    $request_line =~ s/\r?\n$//;
102
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
103
    return send_text($client, 400, 'bad request') unless $method && $target;
104

            
105
    my %headers;
106
    while (my $line = <$client>) {
107
        $line =~ s/\r?\n$//;
108
        last if $line eq '';
109
        my ($k, $v) = split /:\s*/, $line, 2;
110
        $headers{lc $k} = $v if defined $k && defined $v;
111
    }
112

            
113
    my $body = '';
114
    if (($headers{'content-length'} || 0) > 0) {
115
        read($client, $body, int($headers{'content-length'}));
116
    }
117

            
118
    my ($path, $query) = split /\?/, $target, 2;
119
    my %query = parse_params($query || '');
120

            
Bogdan Timofte authored 5 days ago
121
    if ($method eq 'GET' && app_page_path($path)) {
Xdev Host Manager authored a week ago
122
        return send_html($client, 200, app_html());
123
    }
124
    if ($method eq 'GET' && $path eq '/healthz') {
Xdev Host Manager authored a week ago
125
        return send_json($client, 200, { ok => json_bool(1) });
Xdev Host Manager authored a week ago
126
    }
127
    if ($method eq 'GET' && $path eq '/api/session') {
128
        return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
129
    }
Xdev Host Manager authored a week ago
130
    if ($method eq 'POST' && $path eq '/api/login') {
131
        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
132
        my $payload = request_payload(\%headers, $body);
133
        my $otp = $payload->{otp} || '';
134
        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
135
            return send_json($client, 401, { error => 'invalid_otp' });
136
        }
137
        my $token = create_session();
138
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
139
    }
140
    if ($method eq 'POST' && $path eq '/api/logout') {
141
        expire_session(\%headers);
142
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
143
    }
144

            
145
    return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
146

            
Xdev Host Manager authored a week ago
147
    if ($method eq 'GET' && $path eq '/api/hosts') {
148
        my $registry = load_registry();
149
        return send_json($client, 200, registry_payload($registry));
150
    }
Xdev Host Manager authored a week ago
151
    if ($method eq 'GET' && $path eq '/api/work-orders') {
152
        return send_json($client, 200, work_orders_payload(load_work_orders()));
153
    }
Bogdan Timofte authored 4 days ago
154
    if ($method eq 'GET' && $path eq '/api/debug/database/tables') {
155
        return send_json($client, 200, debug_database_tables_payload());
156
    }
157
    if ($method eq 'GET' && $path eq '/api/debug/database/table') {
158
        return send_json($client, 200, debug_database_table_payload($query{name} || $query{table} || '', $query{limit} || 100));
159
    }
Bogdan Timofte authored 4 days ago
160
    if ($method eq 'GET' && $path eq '/download/debug/database/table.json') {
161
        my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
162
        return send_json($client, 400, { error => $export->{error} }) if $export->{error};
163
        return send_download($client, 200, json_encode($export), 'application/json; charset=utf-8', debug_table_export_filename($export->{table}, 'json'));
164
    }
165
    if ($method eq 'GET' && $path eq '/download/debug/database/table.csv') {
166
        my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
167
        return send_json($client, 400, { error => $export->{error} }) if $export->{error};
168
        return send_download($client, 200, render_debug_table_csv($export), 'text/csv; charset=utf-8', debug_table_export_filename($export->{table}, 'csv'));
169
    }
Xdev Host Manager authored a week ago
170
    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
Bogdan Timofte authored 4 days ago
171
        my $registry = load_registry();
172
        return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
Xdev Host Manager authored a week ago
173
    }
174
    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
175
        my $registry = load_registry();
176
        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
177
    }
178
    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
179
        my $registry = load_registry();
180
        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
181
    }
Xdev Host Manager authored a week ago
182
    if ($method eq 'GET' && $path eq '/api/ca/status') {
183
        return send_json_raw($client, 200, ca_manager_json('status-json'));
184
    }
185
    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
186
        return send_json_raw($client, 200, ca_manager_json('list-json'));
187
    }
188
    if ($method eq 'GET' && $path eq '/download/ca.crt') {
189
        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
190
    }
Bogdan Timofte authored 5 days ago
191
    if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
192
        my $name = $1;
193
        return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
194
    }
Xdev Host Manager authored a week ago
195

            
196
    if ($method eq 'POST' && $path =~ m{^/api/}) {
197
        if ($path eq '/api/hosts/upsert') {
198
            my $payload = request_payload(\%headers, $body);
199
            return upsert_host($client, $payload);
200
        }
201
        if ($path eq '/api/hosts/delete') {
202
            my $payload = request_payload(\%headers, $body);
203
            return delete_host($client, $payload->{id} || '');
204
        }
Xdev Host Manager authored a week ago
205
        if ($path eq '/api/work-orders/confirm') {
206
            my $payload = request_payload(\%headers, $body);
207
            return confirm_work_order($client, $payload);
208
        }
Xdev Host Manager authored a week ago
209
        if ($path eq '/api/work-orders/checklist') {
210
            my $payload = request_payload(\%headers, $body);
211
            return update_work_order_checklist($client, $payload);
212
        }
Xdev Host Manager authored a week ago
213
        if ($path eq '/api/render/local-hosts-tsv') {
214
            my $registry = load_registry();
215
            my $content = render_local_hosts_tsv($registry);
216
            backup_file($opt{local_hosts_tsv});
217
            write_file($opt{local_hosts_tsv}, $content);
218
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
219
        }
220
    }
221

            
222
    return send_json($client, 404, { error => 'not_found' });
223
}
224

            
Bogdan Timofte authored 5 days ago
225
sub app_page_path {
226
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
227
    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
228
}
229

            
Xdev Host Manager authored a week ago
230
sub load_registry {
Bogdan Timofte authored 4 days ago
231
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
232
    normalize_registry_policy($registry);
233
    return $registry;
Xdev Host Manager authored a week ago
234
}
235

            
236
sub save_registry {
237
    my ($registry) = @_;
238
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
239
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
240
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
241
}
242

            
Xdev Host Manager authored a week ago
243
sub load_work_orders {
Bogdan Timofte authored 4 days ago
244
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
245
}
246

            
247
sub save_work_orders {
248
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
249
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
250
}
251

            
252
sub work_orders_payload {
253
    my ($orders) = @_;
254
    my $pending = 0;
255
    for my $wo (@{ $orders->{work_orders} || [] }) {
256
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
257
    }
258
    return {
259
        version => $orders->{version},
260
        work_orders => $orders->{work_orders} || [],
261
        counts => {
262
            work_orders => scalar @{ $orders->{work_orders} || [] },
263
            pending => $pending,
264
        },
265
    };
266
}
267

            
268
sub confirm_work_order {
269
    my ($client, $payload) = @_;
270
    my $id = clean_scalar($payload->{id} || '');
271
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
272
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
273

            
274
    my $orders = load_work_orders();
275
    my $work_order;
276
    for my $wo (@{ $orders->{work_orders} || [] }) {
277
        if (($wo->{id} || '') eq $id) {
278
            $work_order = $wo;
279
            last;
280
        }
281
    }
282
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
283
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
284
    my $incomplete = incomplete_work_order_items($work_order);
285
    return send_json($client, 409, {
286
        error => 'work_order_incomplete',
287
        incomplete => $incomplete,
288
    }) if @$incomplete;
Xdev Host Manager authored a week ago
289

            
290
    my $registry = load_registry();
291
    my $results = apply_work_order($registry, $work_order);
292
    $work_order->{status} = 'confirmed';
293
    $work_order->{confirmed_at} = iso_now();
294
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
295

            
296
    save_registry($registry);
297
    save_work_orders($orders);
298
    backup_file($opt{local_hosts_tsv});
299
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
300

            
301
    return send_json($client, 200, {
302
        ok => json_bool(1),
303
        work_order => $work_order,
304
        results => $results,
305
        local_hosts_tsv => $opt{local_hosts_tsv},
306
    });
307
}
308

            
Xdev Host Manager authored a week ago
309
sub update_work_order_checklist {
310
    my ($client, $payload) = @_;
311
    my $id = clean_scalar($payload->{id} || '');
312
    my $item_id = clean_scalar($payload->{item_id} || '');
313
    my $status = clean_scalar($payload->{status} || '');
314
    my $notes = clean_scalar($payload->{notes} || '');
315
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
316
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
317
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
318

            
319
    my $orders = load_work_orders();
320
    my $work_order;
321
    for my $wo (@{ $orders->{work_orders} || [] }) {
322
        if (($wo->{id} || '') eq $id) {
323
            $work_order = $wo;
324
            last;
325
        }
326
    }
327
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
328
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
329

            
330
    my $item;
331
    for my $candidate (@{ $work_order->{checklist} || [] }) {
332
        if (($candidate->{id} || '') eq $item_id) {
333
            $item = $candidate;
334
            last;
335
        }
336
    }
337
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
338

            
339
    $item->{status} = $status;
340
    $item->{updated_at} = iso_now();
341
    $item->{notes} = $notes if length $notes;
342
    save_work_orders($orders);
343
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
344
}
345

            
346
sub incomplete_work_order_items {
347
    my ($work_order) = @_;
348
    my @incomplete;
349
    for my $item (@{ $work_order->{checklist} || [] }) {
350
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
351
    }
352
    return \@incomplete;
353
}
354

            
Xdev Host Manager authored a week ago
355
sub apply_work_order {
356
    my ($registry, $work_order) = @_;
357
    my @results;
358
    for my $action (@{ $work_order->{actions} || [] }) {
359
        my $type = $action->{type} || '';
360
        if ($type eq 'remove_name') {
361
            my $host_id = $action->{host_id} || '';
362
            my $name = $action->{name} || '';
363
            my $removed = 0;
364
            for my $host (@{ $registry->{hosts} || [] }) {
365
                next unless ($host->{id} || '') eq $host_id;
366
                my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
367
                $removed = @kept != @{ $host->{names} || [] };
368
                $host->{names} = \@kept;
369
                last;
370
            }
371
            push @results, {
372
                type => $type,
373
                host_id => $host_id,
374
                name => $name,
375
                removed => json_bool($removed),
376
            };
377
        } else {
378
            die "Unsupported work order action: $type\n";
379
        }
380
    }
381
    return \@results;
382
}
383

            
Xdev Host Manager authored a week ago
384
sub registry_payload {
385
    my ($registry) = @_;
386
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
387
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Xdev Host Manager authored a week ago
388
    return {
389
        version => $registry->{version},
390
        updated_at => $registry->{updated_at},
391
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
392
        hosts => \@hosts,
Xdev Host Manager authored a week ago
393
        problems => $problems,
394
        counts => {
395
            hosts => scalar @{ $registry->{hosts} },
396
            problems => scalar @$problems,
397
        },
398
    };
399
}
400

            
401
sub upsert_host {
402
    my ($client, $payload) = @_;
403
    my $id = clean_id($payload->{id} || '');
404
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
405

            
406
    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
407
    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
408
    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
409

            
Xdev Host Manager authored a week ago
410
    my @names = remove_derived_names(clean_list($payload->{names}));
Xdev Host Manager authored a week ago
411
    return send_json($client, 400, { error => 'missing_names' }) unless @names;
412

            
413
    my $registry = load_registry();
414
    my %host = (
415
        id => $id,
416
        status => clean_scalar($payload->{status} || 'active'),
417
        hosts_ip => $hosts_ip,
418
        dns_ip => $dns_ip,
419
        names => \@names,
420
        roles => [ clean_list($payload->{roles}) ],
421
        sources => [ clean_list($payload->{sources}) ],
422
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
423
        notes => clean_scalar($payload->{notes} || ''),
424
    );
425

            
426
    my $replaced = 0;
427
    for my $i (0 .. $#{ $registry->{hosts} }) {
428
        if ($registry->{hosts}->[$i]{id} eq $id) {
429
            $registry->{hosts}->[$i] = \%host;
430
            $replaced = 1;
431
            last;
432
        }
433
    }
434
    push @{ $registry->{hosts} }, \%host unless $replaced;
435
    save_registry($registry);
436
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
437
}
438

            
439
sub delete_host {
440
    my ($client, $id) = @_;
441
    $id = clean_id($id);
442
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
443

            
444
    my $registry = load_registry();
445
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
446
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
447
    $registry->{hosts} = \@kept;
448
    save_registry($registry);
449
    return send_json($client, 200, { ok => json_bool(1) });
450
}
451

            
452
sub analyze_hosts {
453
    my ($hosts) = @_;
454
    my @problems;
455
    my (%names, %ids);
456
    for my $host (@$hosts) {
457
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
458
        my @fqdn = grep { /\.madagascar\.xdev\.ro$/ } @{ $host->{names} || [] };
459
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless @fqdn || ($host->{status} || '') ne 'active';
460
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
461
            if grep { /\.vad\.is\.xdev\.ro$/ } @{ $host->{names} || [] };
462
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
463
            if grep { /^(is|vad|b)-/ } @{ $host->{names} || [] };
464
        for my $name (@{ $host->{names} || [] }) {
465
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
466
        }
Xdev Host Manager authored a week ago
467
        my %declared = map { $_ => 1 } @{ $host->{names} || [] };
468
        for my $derived (derived_names($host)) {
469
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
470
                if $declared{$derived};
471
        }
Xdev Host Manager authored a week ago
472
        if (($host->{hosts_ip} || '') ne ($host->{dns_ip} || '') && ($host->{hosts_ip} || '') ne '127.0.0.1') {
473
            push @problems, problem($host, 'split-ip', 'hosts_ip differs from dns_ip; check that this is intentional');
474
        }
475
    }
476
    return \@problems;
477
}
478

            
Xdev Host Manager authored a week ago
479
sub host_payload {
480
    my ($host) = @_;
481
    my %copy = %$host;
482
    $copy{names} = [ effective_names($host) ];
483
    $copy{declared_names} = [ @{ $host->{names} || [] } ];
484
    $copy{derived_names} = [ derived_names($host) ];
485
    return \%copy;
486
}
487

            
488
sub effective_names {
489
    my ($host) = @_;
490
    my @names = @{ $host->{names} || [] };
491
    push @names, derived_names($host);
492
    return unique_preserve(@names);
493
}
494

            
495
sub derived_names {
496
    my ($host) = @_;
497
    my @derived;
498
    for my $name (@{ $host->{names} || [] }) {
499
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
500
        push @derived, $1 if length $1;
501
    }
502
    return unique_preserve(@derived);
503
}
504

            
505
sub remove_derived_names {
506
    my @names = @_;
507
    my %derived;
508
    for my $name (@names) {
509
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
510
        $derived{$1} = 1;
511
    }
512
    return grep { !$derived{$_} } @names;
513
}
514

            
515
sub unique_preserve {
516
    my @values = @_;
517
    my %seen;
518
    return grep { !$seen{$_}++ } @values;
519
}
520

            
Xdev Host Manager authored a week ago
521
sub problem {
522
    my ($host, $code, $message) = @_;
523
    return { host_id => $host->{id}, code => $code, message => $message };
524
}
525

            
526
sub render_local_hosts_tsv {
527
    my ($registry) = @_;
528
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
529
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
530
    $out .= "#\n";
531
    $out .= "# Format:\n";
532
    $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
533
    $out .= "#\n";
534
    $out .= "# Priority rule:\n";
535
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
536
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
537
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
538
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
539
        next unless ($host->{status} || 'active') eq 'active';
Xdev Host Manager authored a week ago
540
        my @names = effective_names($host);
541
        next unless @names;
542
        $out .= join("\t", $host->{hosts_ip}, $host->{dns_ip}, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
543
    }
544
    return $out;
545
}
546

            
547
sub render_monitoring {
548
    my ($registry) = @_;
549
    my @hosts;
550
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
551
        next unless ($host->{status} || 'active') eq 'active';
552
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
553
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
554
        push @hosts, {
555
            id => $host->{id},
Xdev Host Manager authored a week ago
556
            primary_name => $names[0],
Xdev Host Manager authored a week ago
557
            address => $host->{dns_ip},
Xdev Host Manager authored a week ago
558
            aliases => \@names,
559
            declared_names => [ @{ $host->{names} || [] } ],
560
            derived_names => [ derived_names($host) ],
Xdev Host Manager authored a week ago
561
            roles => [ @{ $host->{roles} || [] } ],
562
            monitoring => $host->{monitoring} || 'pending',
563
            notes => $host->{notes} || '',
564
        };
565
    }
566
    return {
567
        version => $registry->{version},
568
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
569
        source => $opt{db},
Xdev Host Manager authored a week ago
570
        hosts => \@hosts,
571
    };
572
}
573

            
Bogdan Timofte authored 4 days ago
574
sub debug_database_tables_payload {
575
    my $dbh = dbh();
576
    my @tables;
577
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
578
    $sth->execute;
579
    while (my ($name) = $sth->fetchrow_array) {
580
        my $quoted = $dbh->quote_identifier($name);
581
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
582
        push @tables, {
583
            name => $name,
584
            rows => int($count || 0),
585
        };
586
    }
587
    return {
588
        database => $opt{db},
589
        generated_at => iso_now(),
590
        tables => \@tables,
591
        counts => {
592
            tables => scalar @tables,
593
            rows => sum(map { $_->{rows} } @tables),
594
        },
595
    };
596
}
597

            
598
sub debug_database_table_payload {
599
    my ($table, $limit) = @_;
600
    my $dbh = dbh();
601
    $table = clean_scalar($table);
602
    return { error => 'missing_table' } unless length $table;
603
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
604
    $limit = int($limit || 100);
605
    $limit = 1 if $limit < 1;
606
    $limit = 500 if $limit > 500;
607

            
608
    my $quoted = $dbh->quote_identifier($table);
609
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
610
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
611
    my @index_details;
612
    for my $index (@$indexes) {
613
        my $index_name = $index->{name} || '';
614
        next unless length $index_name;
615
        my $quoted_index = $dbh->quote_identifier($index_name);
616
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
617
        push @index_details, {
618
            name => $index_name,
619
            unique => int($index->{unique} || 0),
620
            origin => $index->{origin} || '',
621
            partial => int($index->{partial} || 0),
622
            columns => [ map { $_->{name} || '' } @$index_columns ],
623
        };
624
    }
625
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
626
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
627
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
628

            
629
    return {
630
        database => $opt{db},
631
        table => $table,
632
        generated_at => iso_now(),
633
        limit => $limit,
634
        row_count => int($row_count || 0),
635
        columns => $columns,
636
        indexes => \@index_details,
637
        foreign_keys => $foreign_keys,
638
        rows => $rows,
639
    };
640
}
641

            
Bogdan Timofte authored 4 days ago
642
sub debug_database_table_export_payload {
643
    my ($table) = @_;
644
    my $dbh = dbh();
645
    $table = clean_scalar($table);
646
    return { error => 'missing_table' } unless length $table;
647
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
648

            
649
    my $quoted = $dbh->quote_identifier($table);
650
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
651
    my @column_names = map { $_->{name} || '' } @$columns;
652
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
653
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
654

            
655
    return {
656
        database => $opt{db},
657
        table => $table,
658
        generated_at => iso_now(),
659
        row_count => int($row_count || 0),
660
        columns => \@column_names,
661
        rows => $rows,
662
    };
663
}
664

            
665
sub render_debug_table_csv {
666
    my ($export) = @_;
667
    my @columns = @{ $export->{columns} || [] };
668
    my @lines = (join(',', map { csv_cell($_) } @columns));
669
    for my $row (@{ $export->{rows} || [] }) {
670
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
671
    }
672
    return join("\n", @lines) . "\n";
673
}
674

            
675
sub csv_cell {
676
    my ($value) = @_;
677
    $value = '' unless defined $value;
678
    $value = "$value";
679
    $value =~ s/"/""/g;
680
    return qq("$value") if $value =~ /[",\r\n]/;
681
    return $value;
682
}
683

            
684
sub debug_table_export_filename {
685
    my ($table, $extension) = @_;
686
    $table = clean_scalar($table || 'table');
687
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
688
    $table = 'table' unless length $table;
689
    return "debug-$table.$extension";
690
}
691

            
Bogdan Timofte authored 4 days ago
692
sub debug_table_exists {
693
    my ($dbh, $table) = @_;
694
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
695
    my ($exists) = $dbh->selectrow_array(
696
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
697
        undef,
698
        $table,
699
    );
700
    return $exists ? 1 : 0;
701
}
702

            
703
sub sum {
704
    my $total = 0;
705
    $total += $_ || 0 for @_;
706
    return $total;
707
}
708

            
Xdev Host Manager authored a week ago
709
sub ca_script_path {
710
    return "$project_dir/scripts/ca_manager.sh";
711
}
712

            
713
sub ca_dir {
714
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
715
}
716

            
717
sub ca_cert_path {
718
    return ca_dir() . "/certs/ca.cert.pem";
719
}
720

            
Bogdan Timofte authored 5 days ago
721
sub ca_issued_cert_path {
722
    my ($name) = @_;
723
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
724
    return ca_dir() . "/issued/$name.cert.pem";
725
}
726

            
Xdev Host Manager authored a week ago
727
sub ca_manager_json {
728
    my ($command) = @_;
729
    my $script = ca_script_path();
730
    die "CA manager script is missing\n" unless -x $script;
731
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
732
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
733
    local $/;
734
    my $out = <$fh>;
735
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
736
    $out ||= $command eq 'list-json' ? '[]' : '{}';
737
    sync_certificates_from_json($out) if $command eq 'list-json';
738
    return $out;
739
}
740

            
741
sub sync_certificates_from_json {
742
    my ($json) = @_;
743
    my $certs = eval { json_decode($json || '[]') };
744
    return if $@ || ref($certs) ne 'ARRAY';
745
    my $dbh = dbh();
746
    my $now = iso_now();
747
    with_transaction($dbh, sub {
748
        for my $cert (@$certs) {
749
            next unless ref($cert) eq 'HASH';
750
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
751
            next unless $name;
752
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
753
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
754
            my $cert_path = ca_issued_cert_path($name);
755
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
756
            my $serial = clean_scalar($cert->{serial} || '');
757
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
758
            $dbh->do(
759
                'INSERT INTO certificates (certificate_id, host_fqdn, common_name, subject, issuer, serial, status, not_before, not_after, fingerprint_sha256, cert_path, csr_path, created_at, updated_at, notes) '
760
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
761
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
762
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
763
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
764
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
765
                undef,
766
                $name,
767
                $host_fqdn || undef,
768
                $dns_names[0] || '',
769
                clean_scalar($cert->{subject} || ''),
770
                clean_scalar($cert->{issuer} || ''),
771
                length($serial) ? $serial : undef,
772
                clean_scalar($cert->{not_before} || ''),
773
                clean_scalar($cert->{not_after} || ''),
774
                length($fingerprint) ? $fingerprint : undef,
775
                $cert_path,
776
                $csr_path,
777
                $now,
778
                $now,
779
            );
780
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
781
            for my $dns_name (@dns_names) {
782
                next unless length $dns_name;
783
                $dbh->do(
784
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
785
                    undef,
786
                    $name,
787
                    $dns_name,
788
                );
789
            }
790
        }
791
    });
792
}
793

            
794
sub infer_certificate_host_fqdn {
795
    my ($dbh, $dns_names) = @_;
796
    for my $name (@$dns_names) {
797
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
798
        return $fqdn if $fqdn;
799
    }
800
    for my $name (@$dns_names) {
801
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
802
        return $fqdn if $fqdn;
803
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
804
        return $fqdn if $fqdn;
805
    }
806
    return '';
Xdev Host Manager authored a week ago
807
}
808

            
Xdev Host Manager authored a week ago
809
sub parse_hosts_yaml {
810
    my ($text) = @_;
811
    my %registry = (
812
        version => 1,
813
        updated_at => '',
814
        policy => {},
815
        hosts => [],
816
    );
817
    my ($section, $current, $list_key);
818
    for my $line (split /\n/, $text) {
819
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
820
        if ($line =~ /^version:\s*(\d+)/) {
821
            $registry{version} = int($1);
822
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
823
            $registry{updated_at} = yaml_unquote($1);
824
        } elsif ($line =~ /^policy:\s*$/) {
825
            $section = 'policy';
826
        } elsif ($line =~ /^hosts:\s*$/) {
827
            $section = 'hosts';
828
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
829
            $registry{policy}{$1} = yaml_unquote($2);
830
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
831
            $current = {
832
                id => yaml_unquote($1),
833
                status => 'active',
834
                hosts_ip => '',
835
                dns_ip => '',
836
                names => [],
837
                roles => [],
838
                sources => [],
839
                monitoring => 'pending',
840
                notes => '',
841
            };
842
            push @{ $registry{hosts} }, $current;
843
            $list_key = undef;
844
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
845
            $list_key = $1;
846
            $current->{$list_key} ||= [];
847
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
848
            push @{ $current->{$list_key} }, yaml_unquote($1);
849
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
850
            $current->{$1} = yaml_unquote($2);
851
            $list_key = undef;
852
        }
853
    }
854
    return \%registry;
855
}
856

            
857
sub render_hosts_yaml {
858
    my ($registry) = @_;
859
    my $out = "version: " . int($registry->{version} || 1) . "\n";
860
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
861
    $out .= "policy:\n";
862
    for my $key (sort keys %{ $registry->{policy} || {} }) {
863
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
864
    }
865
    $out .= "hosts:\n";
866
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
867
        $out .= "  - id: " . yq($host->{id}) . "\n";
868
        for my $key (qw(status hosts_ip dns_ip)) {
869
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
870
        }
871
        for my $key (qw(names roles sources)) {
872
            $out .= "    $key:\n";
873
            for my $value (@{ $host->{$key} || [] }) {
874
                $out .= "      - " . yq($value) . "\n";
875
            }
876
        }
877
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
878
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
879
    }
880
    return $out;
881
}
882

            
Xdev Host Manager authored a week ago
883
sub parse_work_orders_yaml {
884
    my ($text) = @_;
885
    my %orders = (
886
        version => 1,
887
        work_orders => [],
888
    );
Xdev Host Manager authored a week ago
889
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
890
    for my $line (split /\n/, $text) {
891
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
892
        if ($line =~ /^version:\s*(\d+)/) {
893
            $orders{version} = int($1);
894
        } elsif ($line =~ /^work_orders:\s*$/) {
895
            $section = 'work_orders';
896
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
897
            $current = {
898
                id => yaml_unquote($1),
899
                status => 'pending',
Xdev Host Manager authored a week ago
900
                checklist => [],
Xdev Host Manager authored a week ago
901
                actions => [],
902
            };
903
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
904
            $list_section = '';
Xdev Host Manager authored a week ago
905
            $current_action = undef;
Xdev Host Manager authored a week ago
906
            $current_item = undef;
907
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
908
            $list_section = 'checklist';
909
            $current->{checklist} ||= [];
910
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
911
            $current_item = { id => yaml_unquote($1), status => 'pending' };
912
            push @{ $current->{checklist} }, $current_item;
913
            $current_action = undef;
914
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
915
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
916
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
917
            $list_section = 'actions';
Xdev Host Manager authored a week ago
918
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
919
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
920
            $current_action = { type => yaml_unquote($1) };
921
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
922
            $current_item = undef;
923
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
924
            $current_action->{$1} = yaml_unquote($2);
925
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
926
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
927
            $list_section = '';
Xdev Host Manager authored a week ago
928
            $current_action = undef;
Xdev Host Manager authored a week ago
929
            $current_item = undef;
Xdev Host Manager authored a week ago
930
        }
931
    }
932
    return \%orders;
933
}
934

            
935
sub render_work_orders_yaml {
936
    my ($orders) = @_;
937
    my $out = "version: " . int($orders->{version} || 1) . "\n";
938
    $out .= "work_orders:\n";
939
    for my $wo (@{ $orders->{work_orders} || [] }) {
940
        $out .= "  - id: " . yq($wo->{id}) . "\n";
941
        for my $key (qw(status title reason created_at confirmed_at result)) {
942
            next unless exists $wo->{$key} && length($wo->{$key} || '');
943
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
944
        }
Xdev Host Manager authored a week ago
945
        $out .= "    checklist:\n";
946
        for my $item (@{ $wo->{checklist} || [] }) {
947
            $out .= "      - id: " . yq($item->{id}) . "\n";
948
            for my $key (qw(text status owner notes updated_at)) {
949
                next unless exists $item->{$key} && length($item->{$key} || '');
950
                $out .= "        $key: " . yq($item->{$key}) . "\n";
951
            }
952
        }
Xdev Host Manager authored a week ago
953
        $out .= "    actions:\n";
954
        for my $action (@{ $wo->{actions} || [] }) {
955
            $out .= "      - type: " . yq($action->{type}) . "\n";
956
            for my $key (qw(host_id name)) {
957
                next unless exists $action->{$key} && length($action->{$key} || '');
958
                $out .= "        $key: " . yq($action->{$key}) . "\n";
959
            }
960
        }
961
    }
962
    return $out;
963
}
964

            
Xdev Host Manager authored a week ago
965
sub request_payload {
966
    my ($headers, $body) = @_;
967
    my $type = $headers->{'content-type'} || '';
968
    if ($type =~ m{application/json}) {
969
        return json_decode($body || '{}');
970
    }
971
    return { parse_params($body || '') };
972
}
973

            
974
sub json_bool {
975
    my ($value) = @_;
976
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
977
}
978

            
979
sub json_encode {
980
    my ($value) = @_;
981
    if (!defined $value) {
982
        return 'null';
983
    }
984
    my $ref = ref($value);
985
    if (!$ref) {
986
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
987
        return json_string($value);
988
    }
989
    if ($ref eq 'HostManager::JSONBool') {
990
        return $$value ? 'true' : 'false';
991
    }
992
    if ($ref eq 'ARRAY') {
993
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
994
    }
995
    if ($ref eq 'HASH') {
996
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
997
    }
998
    return json_string("$value");
999
}
1000

            
1001
sub json_string {
1002
    my ($value) = @_;
1003
    $value = '' unless defined $value;
1004
    $value =~ s/\\/\\\\/g;
1005
    $value =~ s/"/\\"/g;
1006
    $value =~ s/\n/\\n/g;
1007
    $value =~ s/\r/\\r/g;
1008
    $value =~ s/\t/\\t/g;
1009
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1010
    return qq("$value");
1011
}
1012

            
1013
sub json_decode {
1014
    my ($text) = @_;
1015
    my $i = 0;
1016
    my $len = length($text);
1017
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1018

            
1019
    $skip_ws = sub {
1020
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1021
    };
1022

            
1023
    $parse_string = sub {
1024
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1025
        $i++;
1026
        my $out = '';
1027
        while ($i < $len) {
1028
            my $ch = substr($text, $i++, 1);
1029
            return $out if $ch eq '"';
1030
            if ($ch eq "\\") {
1031
                die "Bad JSON escape\n" if $i >= $len;
1032
                my $esc = substr($text, $i++, 1);
1033
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1034
                    $out .= $esc;
1035
                } elsif ($esc eq 'b') {
1036
                    $out .= "\b";
1037
                } elsif ($esc eq 'f') {
1038
                    $out .= "\f";
1039
                } elsif ($esc eq 'n') {
1040
                    $out .= "\n";
1041
                } elsif ($esc eq 'r') {
1042
                    $out .= "\r";
1043
                } elsif ($esc eq 't') {
1044
                    $out .= "\t";
1045
                } elsif ($esc eq 'u') {
1046
                    my $hex = substr($text, $i, 4);
1047
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1048
                    $out .= chr(hex($hex));
1049
                    $i += 4;
1050
                } else {
1051
                    die "Bad JSON escape\n";
1052
                }
1053
            } else {
1054
                $out .= $ch;
1055
            }
1056
        }
1057
        die "Unterminated JSON string\n";
1058
    };
1059

            
1060
    $parse_number = sub {
1061
        my $start = $i;
1062
        $i++ if substr($text, $i, 1) eq '-';
1063
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1064
        if ($i < $len && substr($text, $i, 1) eq '.') {
1065
            $i++;
1066
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1067
        }
1068
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1069
            $i++;
1070
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1071
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1072
        }
1073
        return 0 + substr($text, $start, $i - $start);
1074
    };
1075

            
1076
    $parse_array = sub {
1077
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1078
        $i++;
1079
        my @out;
1080
        $skip_ws->();
1081
        if ($i < $len && substr($text, $i, 1) eq ']') {
1082
            $i++;
1083
            return \@out;
1084
        }
1085
        while (1) {
1086
            push @out, $parse_value->();
1087
            $skip_ws->();
1088
            my $ch = substr($text, $i++, 1);
1089
            last if $ch eq ']';
1090
            die "Expected JSON array comma\n" unless $ch eq ',';
1091
        }
1092
        return \@out;
1093
    };
1094

            
1095
    $parse_object = sub {
1096
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1097
        $i++;
1098
        my %out;
1099
        $skip_ws->();
1100
        if ($i < $len && substr($text, $i, 1) eq '}') {
1101
            $i++;
1102
            return \%out;
1103
        }
1104
        while (1) {
1105
            $skip_ws->();
1106
            my $key = $parse_string->();
1107
            $skip_ws->();
1108
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1109
            $out{$key} = $parse_value->();
1110
            $skip_ws->();
1111
            my $ch = substr($text, $i++, 1);
1112
            last if $ch eq '}';
1113
            die "Expected JSON object comma\n" unless $ch eq ',';
1114
        }
1115
        return \%out;
1116
    };
1117

            
1118
    $parse_value = sub {
1119
        $skip_ws->();
1120
        die "Unexpected end of JSON\n" if $i >= $len;
1121
        my $ch = substr($text, $i, 1);
1122
        return $parse_string->() if $ch eq '"';
1123
        return $parse_object->() if $ch eq '{';
1124
        return $parse_array->() if $ch eq '[';
1125
        if (substr($text, $i, 4) eq 'true') {
1126
            $i += 4;
1127
            return json_bool(1);
1128
        }
1129
        if (substr($text, $i, 5) eq 'false') {
1130
            $i += 5;
1131
            return json_bool(0);
1132
        }
1133
        if (substr($text, $i, 4) eq 'null') {
1134
            $i += 4;
1135
            return undef;
1136
        }
1137
        return $parse_number->() if $ch =~ /[-0-9]/;
1138
        die "Unexpected JSON token\n";
1139
    };
1140

            
1141
    my $value = $parse_value->();
1142
    $skip_ws->();
1143
    die "Trailing JSON content\n" if $i != $len;
1144
    return $value;
1145
}
1146

            
1147
sub parse_params {
1148
    my ($text) = @_;
1149
    my %out;
1150
    for my $pair (split /&/, $text) {
1151
        next unless length $pair;
1152
        my ($k, $v) = split /=/, $pair, 2;
1153
        $out{url_decode($k)} = url_decode($v || '');
1154
    }
1155
    return %out;
1156
}
1157

            
1158
sub clean_id {
1159
    my ($value) = @_;
1160
    $value = lc clean_scalar($value);
1161
    $value =~ s/[^a-z0-9_.-]+/-/g;
1162
    $value =~ s/^-+|-+$//g;
1163
    return $value;
1164
}
1165

            
1166
sub clean_scalar {
1167
    my ($value) = @_;
1168
    $value = '' unless defined $value;
1169
    $value =~ s/[\r\n\t]+/ /g;
1170
    $value =~ s/^\s+|\s+$//g;
1171
    return $value;
1172
}
1173

            
1174
sub clean_list {
1175
    my ($value) = @_;
1176
    return () unless defined $value;
1177
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1178
    my @clean;
1179
    for my $item (@items) {
1180
        $item = clean_scalar($item);
1181
        push @clean, $item if length $item;
1182
    }
1183
    return @clean;
1184
}
1185

            
1186
sub yq {
1187
    my ($value) = @_;
1188
    $value = '' unless defined $value;
1189
    $value =~ s/\\/\\\\/g;
1190
    $value =~ s/"/\\"/g;
1191
    return qq("$value");
1192
}
1193

            
1194
sub yaml_unquote {
1195
    my ($value) = @_;
1196
    $value = '' unless defined $value;
1197
    $value =~ s/^\s+|\s+$//g;
1198
    if ($value =~ /^"(.*)"$/) {
1199
        $value = $1;
1200
        $value =~ s/\\"/"/g;
1201
        $value =~ s/\\\\/\\/g;
1202
    }
1203
    return $value;
1204
}
1205

            
1206
sub verify_totp {
1207
    my ($secret, $otp) = @_;
1208
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1209
    my $key = eval { base32_decode($secret) };
1210
    return 0 if $@ || !length $key;
1211
    my $counter = int(time() / 30);
1212
    for my $offset (-1, 0, 1) {
1213
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1214
    }
1215
    return 0;
1216
}
1217

            
1218
sub totp_code {
1219
    my ($key, $counter) = @_;
1220
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1221
    my $hash = hmac_sha1($msg, $key);
1222
    my $offset = ord(substr($hash, -1)) & 0x0f;
1223
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1224
    return sprintf('%06d', $bin % 1_000_000);
1225
}
1226

            
1227
sub base32_decode {
1228
    my ($text) = @_;
1229
    $text = uc($text || '');
1230
    $text =~ s/[^A-Z2-7]//g;
1231
    my %map;
1232
    my @chars = ('A'..'Z', '2'..'7');
1233
    @map{@chars} = (0..31);
1234
    my ($bits, $value, $out) = (0, 0, '');
1235
    for my $char (split //, $text) {
1236
        die "Invalid base32\n" unless exists $map{$char};
1237
        $value = ($value << 5) | $map{$char};
1238
        $bits += 5;
1239
        while ($bits >= 8) {
1240
            $bits -= 8;
1241
            $out .= chr(($value >> $bits) & 0xff);
1242
        }
1243
    }
1244
    return $out;
1245
}
1246

            
1247
sub create_session {
1248
    my $nonce = random_hex(24);
1249
    my $expires = int(time() + 8 * 3600);
1250
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1251
    my $token = "$nonce:$expires:$sig";
1252
    $sessions{$token} = $expires;
1253
    return $token;
1254
}
1255

            
1256
sub is_authenticated {
1257
    my ($headers) = @_;
1258
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1259
    return 0 unless $token;
1260
    my ($nonce, $expires, $sig) = split /:/, $token;
1261
    return 0 unless $nonce && $expires && $sig;
1262
    return 0 if $expires < time();
1263
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1264
    return exists $sessions{$token};
1265
}
1266

            
1267
sub expire_session {
1268
    my ($headers) = @_;
1269
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1270
    delete $sessions{$token} if $token;
1271
}
1272

            
1273
sub cookie_value {
1274
    my ($cookie, $name) = @_;
1275
    for my $part (split /;\s*/, $cookie) {
1276
        my ($k, $v) = split /=/, $part, 2;
1277
        return $v if defined $k && $k eq $name;
1278
    }
1279
    return '';
1280
}
1281

            
1282
sub send_json {
1283
    my ($client, $status, $payload, $extra_headers) = @_;
1284
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1285
}
1286

            
Xdev Host Manager authored a week ago
1287
sub send_json_raw {
1288
    my ($client, $status, $json_body, $extra_headers) = @_;
1289
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1290
}
1291

            
Xdev Host Manager authored a week ago
1292
sub send_html {
1293
    my ($client, $status, $html) = @_;
1294
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1295
}
1296

            
1297
sub send_text {
1298
    my ($client, $status, $text) = @_;
1299
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1300
}
1301

            
1302
sub send_download {
1303
    my ($client, $status, $content, $type, $filename) = @_;
1304
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1305
}
1306

            
1307
sub send_file {
1308
    my ($client, $path, $type, $filename) = @_;
1309
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1310
    return send_download($client, 200, read_file($path), $type, $filename);
1311
}
1312

            
1313
sub send_response {
1314
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1315
    my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 409 => 'Conflict', 500 => 'Internal Server Error', 503 => 'Service Unavailable');
Xdev Host Manager authored a week ago
1316
    $body = '' unless defined $body;
1317
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1318
    print $client "Content-Type: $type\r\n";
1319
    print $client "Content-Length: " . length($body) . "\r\n";
1320
    print $client "Cache-Control: no-store\r\n";
1321
    print $client "$_\r\n" for @{ $extra_headers || [] };
1322
    print $client "Connection: close\r\n\r\n";
1323
    print $client $body;
1324
}
1325

            
1326
sub read_file {
1327
    my ($path) = @_;
1328
    open my $fh, '<', $path or die "Cannot read $path: $!";
1329
    local $/;
1330
    return <$fh>;
1331
}
1332

            
1333
sub write_file {
1334
    my ($path, $content) = @_;
1335
    open my $fh, '>', $path or die "Cannot write $path: $!";
1336
    print {$fh} $content;
1337
    close $fh or die "Cannot close $path: $!";
1338
}
1339

            
1340
sub backup_file {
1341
    my ($path) = @_;
1342
    return unless -f $path;
1343
    my $backup_dir = "$project_dir/backups/host-manager";
1344
    make_path($backup_dir) unless -d $backup_dir;
1345
    my $name = $path;
1346
    $name =~ s{.*/}{};
1347
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1348
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1349
}
1350

            
Bogdan Timofte authored 4 days ago
1351
my $db_handle;
Bogdan Timofte authored 4 days ago
1352
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1353

            
1354
sub dbh {
1355
    return $db_handle if $db_handle;
1356
    ensure_parent_dir($opt{db});
1357
    $db_handle = DBI->connect(
1358
        "dbi:SQLite:dbname=$opt{db}",
1359
        '',
1360
        '',
1361
        {
1362
            RaiseError => 1,
1363
            PrintError => 0,
1364
            AutoCommit => 1,
1365
            sqlite_unicode => 1,
1366
        },
1367
    ) or die "Cannot open SQLite database $opt{db}\n";
1368
    $db_handle->do('PRAGMA journal_mode = WAL');
1369
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1370
    create_database_schema($db_handle);
1371
    seed_database($db_handle) unless $db_seeded++;
1372
    return $db_handle;
1373
}
1374

            
1375
sub create_database_schema {
1376
    my ($dbh) = @_;
1377
    $dbh->do(<<'SQL');
1378
CREATE TABLE IF NOT EXISTS schema_meta (
1379
    key TEXT PRIMARY KEY,
1380
    value TEXT NOT NULL,
1381
    updated_at TEXT NOT NULL
1382
)
1383
SQL
1384
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1385
CREATE TABLE IF NOT EXISTS documents (
1386
    name TEXT PRIMARY KEY,
1387
    content TEXT NOT NULL,
1388
    updated_at TEXT NOT NULL
1389
)
1390
SQL
Bogdan Timofte authored 4 days ago
1391
    $dbh->do(
1392
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1393
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1394
        undef, 'schema_version', '2', iso_now()
1395
    );
1396
    $dbh->do(<<'SQL');
1397
CREATE TABLE IF NOT EXISTS hosts (
1398
    fqdn TEXT PRIMARY KEY,
1399
    legacy_id TEXT NOT NULL UNIQUE,
1400
    status TEXT NOT NULL DEFAULT 'active',
1401
    hosts_ip TEXT NOT NULL DEFAULT '',
1402
    dns_ip TEXT NOT NULL DEFAULT '',
1403
    monitoring TEXT NOT NULL DEFAULT 'pending',
1404
    notes TEXT NOT NULL DEFAULT '',
1405
    created_at TEXT NOT NULL,
1406
    updated_at TEXT NOT NULL
1407
)
1408
SQL
1409
    $dbh->do(<<'SQL');
1410
CREATE TABLE IF NOT EXISTS host_aliases (
1411
    alias_name TEXT NOT NULL,
1412
    host_fqdn TEXT NOT NULL,
1413
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1414
    status TEXT NOT NULL DEFAULT 'active',
1415
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1416
    created_at TEXT NOT NULL,
1417
    retired_at TEXT,
1418
    notes TEXT NOT NULL DEFAULT '',
1419
    PRIMARY KEY (alias_name, host_fqdn),
1420
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1421
)
1422
SQL
1423
    $dbh->do(<<'SQL');
1424
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1425
ON host_aliases(alias_name)
1426
WHERE status = 'active'
1427
SQL
1428
    $dbh->do(<<'SQL');
1429
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1430
ON host_aliases(host_fqdn, status)
1431
SQL
1432
    $dbh->do(<<'SQL');
1433
CREATE TABLE IF NOT EXISTS host_roles (
1434
    host_fqdn TEXT NOT NULL,
1435
    role TEXT NOT NULL,
1436
    status TEXT NOT NULL DEFAULT 'active',
1437
    created_at TEXT NOT NULL,
1438
    retired_at TEXT,
1439
    PRIMARY KEY (host_fqdn, role),
1440
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1441
)
1442
SQL
1443
    $dbh->do(<<'SQL');
1444
CREATE TABLE IF NOT EXISTS host_sources (
1445
    host_fqdn TEXT NOT NULL,
1446
    source TEXT NOT NULL,
1447
    status TEXT NOT NULL DEFAULT 'active',
1448
    created_at TEXT NOT NULL,
1449
    retired_at TEXT,
1450
    PRIMARY KEY (host_fqdn, source),
1451
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1452
)
1453
SQL
1454
    $dbh->do(<<'SQL');
1455
CREATE TABLE IF NOT EXISTS host_flags (
1456
    host_fqdn TEXT NOT NULL,
1457
    flag TEXT NOT NULL,
1458
    value TEXT NOT NULL DEFAULT '1',
1459
    created_at TEXT NOT NULL,
1460
    updated_at TEXT NOT NULL,
1461
    PRIMARY KEY (host_fqdn, flag),
1462
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1463
)
1464
SQL
1465
    $dbh->do(<<'SQL');
1466
CREATE TABLE IF NOT EXISTS host_ssh (
1467
    host_fqdn TEXT NOT NULL,
1468
    profile_name TEXT NOT NULL DEFAULT 'default',
1469
    username TEXT NOT NULL DEFAULT '',
1470
    port INTEGER NOT NULL DEFAULT 22,
1471
    identity_file TEXT NOT NULL DEFAULT '',
1472
    address TEXT NOT NULL DEFAULT '',
1473
    local_forward_host TEXT NOT NULL DEFAULT '',
1474
    local_forward_port INTEGER,
1475
    remote_forward_host TEXT NOT NULL DEFAULT '',
1476
    remote_forward_port INTEGER,
1477
    notes TEXT NOT NULL DEFAULT '',
1478
    created_at TEXT NOT NULL,
1479
    updated_at TEXT NOT NULL,
1480
    PRIMARY KEY (host_fqdn, profile_name),
1481
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1482
)
1483
SQL
1484
    $dbh->do(<<'SQL');
1485
CREATE TABLE IF NOT EXISTS certificates (
1486
    certificate_id TEXT PRIMARY KEY,
1487
    host_fqdn TEXT,
1488
    common_name TEXT NOT NULL DEFAULT '',
1489
    subject TEXT NOT NULL DEFAULT '',
1490
    issuer TEXT NOT NULL DEFAULT '',
1491
    serial TEXT UNIQUE,
1492
    status TEXT NOT NULL DEFAULT 'issued',
1493
    not_before TEXT NOT NULL DEFAULT '',
1494
    not_after TEXT NOT NULL DEFAULT '',
1495
    fingerprint_sha256 TEXT UNIQUE,
1496
    cert_path TEXT NOT NULL DEFAULT '',
1497
    csr_path TEXT NOT NULL DEFAULT '',
1498
    created_at TEXT NOT NULL,
1499
    updated_at TEXT NOT NULL,
1500
    notes TEXT NOT NULL DEFAULT '',
1501
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1502
)
1503
SQL
1504
    $dbh->do(<<'SQL');
1505
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1506
    certificate_id TEXT NOT NULL,
1507
    dns_name TEXT NOT NULL,
1508
    PRIMARY KEY (certificate_id, dns_name),
1509
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1510
)
1511
SQL
1512
    $dbh->do(<<'SQL');
1513
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1514
ON certificate_dns_names(dns_name)
1515
SQL
1516
    $dbh->do(<<'SQL');
1517
CREATE TABLE IF NOT EXISTS vhosts (
1518
    vhost_fqdn TEXT PRIMARY KEY,
1519
    host_fqdn TEXT NOT NULL,
1520
    status TEXT NOT NULL DEFAULT 'active',
1521
    service_name TEXT NOT NULL DEFAULT '',
1522
    upstream_url TEXT NOT NULL DEFAULT '',
1523
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1524
    certificate_id TEXT,
1525
    notes TEXT NOT NULL DEFAULT '',
1526
    created_at TEXT NOT NULL,
1527
    updated_at TEXT NOT NULL,
1528
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1529
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1530
)
1531
SQL
1532
    $dbh->do(<<'SQL');
1533
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1534
ON vhosts(host_fqdn, status)
1535
SQL
1536
    $dbh->do(<<'SQL');
1537
CREATE TABLE IF NOT EXISTS data_workers (
1538
    worker_id TEXT PRIMARY KEY,
1539
    worker_type TEXT NOT NULL,
1540
    name TEXT NOT NULL DEFAULT '',
1541
    status TEXT NOT NULL DEFAULT 'active',
1542
    source TEXT NOT NULL DEFAULT '',
1543
    last_run_at TEXT,
1544
    notes TEXT NOT NULL DEFAULT '',
1545
    created_at TEXT NOT NULL,
1546
    updated_at TEXT NOT NULL
1547
)
1548
SQL
1549
    $dbh->do(<<'SQL');
1550
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1551
ON data_workers(worker_type, status)
1552
SQL
1553
    $dbh->do(<<'SQL');
1554
CREATE TABLE IF NOT EXISTS dhcp_leases (
1555
    lease_key TEXT PRIMARY KEY,
1556
    worker_id TEXT NOT NULL,
1557
    host_fqdn TEXT,
1558
    observed_name TEXT NOT NULL DEFAULT '',
1559
    ip_address TEXT NOT NULL,
1560
    mac_address TEXT NOT NULL DEFAULT '',
1561
    lease_state TEXT NOT NULL DEFAULT '',
1562
    first_seen TEXT NOT NULL,
1563
    last_seen TEXT NOT NULL,
1564
    raw TEXT NOT NULL DEFAULT '',
1565
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1566
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1567
)
1568
SQL
1569
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1570
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1571
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1572
    $dbh->do(<<'SQL');
1573
CREATE TABLE IF NOT EXISTS mdns_observations (
1574
    observation_key TEXT PRIMARY KEY,
1575
    worker_id TEXT NOT NULL,
1576
    host_fqdn TEXT,
1577
    observed_name TEXT NOT NULL,
1578
    ip_address TEXT NOT NULL,
1579
    rr_type TEXT NOT NULL DEFAULT 'A',
1580
    ttl INTEGER NOT NULL DEFAULT 0,
1581
    first_seen TEXT NOT NULL,
1582
    last_seen TEXT NOT NULL,
1583
    seen_count INTEGER NOT NULL DEFAULT 1,
1584
    last_peer TEXT NOT NULL DEFAULT '',
1585
    raw TEXT NOT NULL DEFAULT '',
1586
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1587
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1588
)
1589
SQL
1590
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1591
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1592
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1593
    $dbh->do(<<'SQL');
1594
CREATE TABLE IF NOT EXISTS work_orders (
1595
    id TEXT PRIMARY KEY,
1596
    status TEXT NOT NULL DEFAULT 'pending',
1597
    title TEXT NOT NULL DEFAULT '',
1598
    reason TEXT NOT NULL DEFAULT '',
1599
    created_at TEXT NOT NULL,
1600
    confirmed_at TEXT NOT NULL DEFAULT '',
1601
    result TEXT NOT NULL DEFAULT '',
1602
    updated_at TEXT NOT NULL
1603
)
1604
SQL
1605
    $dbh->do(<<'SQL');
1606
CREATE TABLE IF NOT EXISTS work_order_checklist (
1607
    work_order_id TEXT NOT NULL,
1608
    item_id TEXT NOT NULL,
1609
    text TEXT NOT NULL DEFAULT '',
1610
    status TEXT NOT NULL DEFAULT 'pending',
1611
    owner TEXT NOT NULL DEFAULT '',
1612
    notes TEXT NOT NULL DEFAULT '',
1613
    updated_at TEXT NOT NULL DEFAULT '',
1614
    PRIMARY KEY (work_order_id, item_id),
1615
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1616
)
1617
SQL
1618
    $dbh->do(<<'SQL');
1619
CREATE TABLE IF NOT EXISTS work_order_actions (
1620
    work_order_id TEXT NOT NULL,
1621
    position INTEGER NOT NULL,
1622
    type TEXT NOT NULL,
1623
    host_fqdn TEXT,
1624
    host_legacy_id TEXT NOT NULL DEFAULT '',
1625
    name TEXT NOT NULL DEFAULT '',
1626
    payload TEXT NOT NULL DEFAULT '',
1627
    PRIMARY KEY (work_order_id, position),
1628
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1629
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1630
)
1631
SQL
Bogdan Timofte authored 4 days ago
1632
}
1633

            
Bogdan Timofte authored 4 days ago
1634
sub seed_database {
1635
    my ($dbh) = @_;
1636
    seed_default_workers($dbh);
1637

            
1638
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1639
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1640
        normalize_registry_policy($registry);
1641
        with_transaction($dbh, sub {
1642
            import_registry_to_db($dbh, $registry, 0);
1643
        });
1644
    }
1645

            
1646
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1647
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1648
        with_transaction($dbh, sub {
1649
            import_work_orders_to_db($dbh, $orders);
1650
        });
1651
    }
1652

            
1653
    seed_mdns_observations_from_yaml($dbh);
1654
}
1655

            
1656
sub with_transaction {
1657
    my ($dbh, $code) = @_;
1658
    return $code->() unless $dbh->{AutoCommit};
1659
    $dbh->begin_work;
1660
    my $ok = eval {
1661
        $code->();
1662
        1;
1663
    };
1664
    if (!$ok) {
1665
        my $err = $@ || 'transaction failed';
1666
        eval { $dbh->rollback };
1667
        die $err;
1668
    }
1669
    $dbh->commit;
1670
}
1671

            
1672
sub db_scalar {
1673
    my ($dbh, $sql, @bind) = @_;
1674
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1675
    return $value || 0;
1676
}
1677

            
1678
sub legacy_document_text {
1679
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1680
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1681
    return $row->{content} if $row && defined $row->{content};
1682
    return read_file($seed_path) if -f $seed_path;
1683
    return $default_text;
1684
}
1685

            
1686
sub load_registry_from_db {
1687
    my $dbh = dbh();
1688
    my $registry = {
1689
        version => 1,
1690
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1691
        policy => {},
1692
        hosts => [],
1693
    };
Bogdan Timofte authored 4 days ago
1694

            
Bogdan Timofte authored 4 days ago
1695
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1696
    $sth->execute;
1697
    while (my $row = $sth->fetchrow_hashref) {
1698
        my $fqdn = $row->{fqdn};
1699
        push @{ $registry->{hosts} }, {
1700
            id => $row->{legacy_id},
1701
            status => $row->{status},
1702
            hosts_ip => $row->{hosts_ip},
1703
            dns_ip => $row->{dns_ip},
1704
            names => [ active_names_for_host($dbh, $fqdn) ],
1705
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1706
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1707
            monitoring => $row->{monitoring},
1708
            notes => $row->{notes},
1709
        };
1710
    }
1711

            
1712
    return $registry;
Bogdan Timofte authored 4 days ago
1713
}
1714

            
Bogdan Timofte authored 4 days ago
1715
sub save_registry_to_db {
1716
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1717
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1718
    with_transaction($dbh, sub {
1719
        import_registry_to_db($dbh, $registry, 1);
1720
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1721
    });
1722
}
1723

            
1724
sub import_registry_to_db {
1725
    my ($dbh, $registry, $retire_missing) = @_;
1726
    my %seen;
1727
    for my $host (@{ $registry->{hosts} || [] }) {
1728
        my $fqdn = upsert_host_to_db($dbh, $host);
1729
        $seen{$fqdn} = 1 if $fqdn;
1730
    }
1731

            
1732
    return unless $retire_missing;
1733
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1734
    $sth->execute('retired');
1735
    while (my ($fqdn) = $sth->fetchrow_array) {
1736
        next if $seen{$fqdn};
1737
        retire_host_in_db($dbh, $fqdn);
1738
    }
1739
}
1740

            
1741
sub upsert_host_to_db {
1742
    my ($dbh, $host) = @_;
1743
    my $now = iso_now();
1744
    my $fqdn = canonical_host_fqdn($host);
1745
    return '' unless $fqdn;
1746
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1747
    my $status = clean_scalar($host->{status} || 'active');
1748
    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1749
    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1750
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1751
    my $notes = clean_scalar($host->{notes} || '');
1752

            
Bogdan Timofte authored 4 days ago
1753
    $dbh->do(
Bogdan Timofte authored 4 days ago
1754
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1755
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1756
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1757
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1758
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1759
        undef,
Bogdan Timofte authored 4 days ago
1760
        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1761
    );
1762

            
1763
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1764
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1765
    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1766
    return $fqdn;
1767
}
1768

            
1769
sub sync_host_values {
1770
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1771
    my $now = iso_now();
1772
    my %active = map { $_ => 1 } @$values;
1773
    for my $value (@$values) {
1774
        $dbh->do(
1775
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1776
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1777
            undef,
1778
            $fqdn, $value, $now,
1779
        );
1780
    }
1781

            
1782
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1783
    $sth->execute($fqdn);
1784
    while (my ($value) = $sth->fetchrow_array) {
1785
        next if $active{$value};
1786
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1787
    }
1788
}
1789

            
1790
sub sync_host_names {
1791
    my ($dbh, $fqdn, $names) = @_;
1792
    my $now = iso_now();
1793
    my (%aliases, %vhosts);
1794
    if (my $short = short_alias_for_fqdn($fqdn)) {
1795
        $aliases{$short} = 1;
1796
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1797
    }
1798
    for my $name (@$names) {
1799
        $name = normalize_dns_name($name);
1800
        next unless length $name;
1801
        next if $name eq $fqdn;
1802
        if (name_is_vhost($name)) {
1803
            $vhosts{$name} = 1;
1804
            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1805
            if (my $short = short_alias_for_fqdn($name)) {
1806
                $aliases{$short} = 1;
1807
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1808
            }
1809
        } else {
1810
            $aliases{$name} = 1;
1811
            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1812
            if (my $short = short_alias_for_fqdn($name)) {
1813
                $aliases{$short} = 1;
1814
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1815
            }
1816
        }
1817
    }
1818

            
1819
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1820
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1821
}
1822

            
1823
sub upsert_alias_to_db {
1824
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1825
    $dbh->do(
1826
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1827
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1828
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1829
        undef,
1830
        $alias, $fqdn, $kind, $now,
1831
    );
1832
}
1833

            
1834
sub upsert_vhost_to_db {
1835
    my ($dbh, $fqdn, $vhost, $now) = @_;
1836
    my $service = vhost_service_name($vhost);
1837
    $dbh->do(
1838
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
1839
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
1840
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
1841
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
1842
        undef,
1843
        $vhost, $fqdn, $service, $now, $now,
1844
    );
1845
}
1846

            
1847
sub retire_missing_names {
1848
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
1849
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1850
    $sth->execute($fqdn);
1851
    while (my ($name) = $sth->fetchrow_array) {
1852
        next if $active->{$name};
1853
        if ($table eq 'host_aliases') {
1854
            $dbh->do(
1855
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
1856
                undef, $now, $fqdn, $name,
1857
            );
1858
        } else {
1859
            $dbh->do(
1860
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
1861
                undef, $now, $fqdn, $name,
1862
            );
1863
        }
1864
    }
1865
}
1866

            
1867
sub retire_host_in_db {
1868
    my ($dbh, $fqdn) = @_;
1869
    my $now = iso_now();
1870
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
1871
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1872
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1873
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1874
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1875
}
1876

            
1877
sub active_names_for_host {
1878
    my ($dbh, $fqdn) = @_;
1879
    my @names = ($fqdn);
1880
    my $aliases = $dbh->prepare("SELECT alias_name FROM host_aliases WHERE host_fqdn = ? AND status = 'active' AND is_dns_published = 1 AND alias_kind NOT LIKE 'derived%' ORDER BY alias_name");
1881
    $aliases->execute($fqdn);
1882
    while (my ($name) = $aliases->fetchrow_array) {
1883
        push @names, $name;
1884
    }
1885
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1886
    $vhosts->execute($fqdn);
1887
    while (my ($name) = $vhosts->fetchrow_array) {
1888
        push @names, $name;
1889
    }
1890
    return unique_preserve(@names);
1891
}
1892

            
1893
sub active_values_for_host {
1894
    my ($dbh, $table, $column, $fqdn) = @_;
1895
    my @values;
1896
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
1897
    $sth->execute($fqdn);
1898
    while (my ($value) = $sth->fetchrow_array) {
1899
        push @values, $value;
1900
    }
1901
    return @values;
1902
}
1903

            
1904
sub load_work_orders_from_db {
1905
    my $dbh = dbh();
1906
    my $orders = { version => 1, work_orders => [] };
1907
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
1908
    $sth->execute;
1909
    while (my $row = $sth->fetchrow_hashref) {
1910
        my $wo = {
1911
            id => $row->{id},
1912
            status => $row->{status},
1913
            title => $row->{title},
1914
            reason => $row->{reason},
1915
            created_at => $row->{created_at},
1916
            checklist => [],
1917
            actions => [],
1918
        };
1919
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
1920
        $wo->{result} = $row->{result} if length($row->{result} || '');
1921

            
1922
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
1923
        $items->execute($row->{id});
1924
        while (my $item = $items->fetchrow_hashref) {
1925
            my %copy = (
1926
                id => $item->{item_id},
1927
                text => $item->{text},
1928
                status => $item->{status},
1929
            );
1930
            for my $key (qw(owner notes updated_at)) {
1931
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
1932
            }
1933
            push @{ $wo->{checklist} }, \%copy;
1934
        }
1935

            
1936
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
1937
        $actions->execute($row->{id});
1938
        while (my $action = $actions->fetchrow_hashref) {
1939
            my %copy = ( type => $action->{type} );
1940
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
1941
            $copy{name} = $action->{name} if length($action->{name} || '');
1942
            push @{ $wo->{actions} }, \%copy;
1943
        }
1944

            
1945
        push @{ $orders->{work_orders} }, $wo;
1946
    }
1947
    return $orders;
1948
}
1949

            
1950
sub save_work_orders_to_db {
1951
    my ($orders) = @_;
1952
    my $dbh = dbh();
1953
    with_transaction($dbh, sub {
1954
        import_work_orders_to_db($dbh, $orders);
1955
    });
1956
}
1957

            
1958
sub import_work_orders_to_db {
1959
    my ($dbh, $orders) = @_;
1960
    my $now = iso_now();
1961
    my %seen;
1962
    for my $wo (@{ $orders->{work_orders} || [] }) {
1963
        my $id = clean_scalar($wo->{id} || '');
1964
        next unless $id;
1965
        $seen{$id} = 1;
1966
        $dbh->do(
1967
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
1968
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
1969
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
1970
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
1971
            undef,
1972
            $id,
1973
            clean_scalar($wo->{status} || 'pending'),
1974
            clean_scalar($wo->{title} || ''),
1975
            clean_scalar($wo->{reason} || ''),
1976
            clean_scalar($wo->{created_at} || $now),
1977
            clean_scalar($wo->{confirmed_at} || ''),
1978
            clean_scalar($wo->{result} || ''),
1979
            $now,
1980
        );
1981
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
1982
        for my $item (@{ $wo->{checklist} || [] }) {
1983
            $dbh->do(
1984
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1985
                undef,
1986
                $id,
1987
                clean_scalar($item->{id} || ''),
1988
                clean_scalar($item->{text} || ''),
1989
                clean_scalar($item->{status} || 'pending'),
1990
                clean_scalar($item->{owner} || ''),
1991
                clean_scalar($item->{notes} || ''),
1992
                clean_scalar($item->{updated_at} || ''),
1993
            );
1994
        }
1995
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
1996
        my $position = 0;
1997
        for my $action (@{ $wo->{actions} || [] }) {
1998
            my $legacy_id = clean_id($action->{host_id} || '');
1999
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2000
            $dbh->do(
2001
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2002
                undef,
2003
                $id,
2004
                $position++,
2005
                clean_scalar($action->{type} || ''),
2006
                $host_fqdn || undef,
2007
                $legacy_id,
2008
                normalize_dns_name($action->{name} || ''),
2009
                '',
2010
            );
2011
        }
2012
    }
2013
}
2014

            
2015
sub seed_default_workers {
2016
    my ($dbh) = @_;
2017
    my $now = iso_now();
2018
    my @workers = (
2019
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2020
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2021
    );
2022
    for my $worker (@workers) {
2023
        $dbh->do(
2024
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2025
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2026
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2027
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2028
            undef,
2029
            @$worker,
2030
            $now,
2031
            $now,
2032
        );
2033
    }
2034
}
2035

            
2036
sub seed_mdns_observations_from_yaml {
2037
    my ($dbh) = @_;
2038
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2039
    my $path = "$project_dir/var/mdns-observations.yaml";
2040
    return unless -f $path;
2041
    my $db = parse_mdns_observations_yaml(read_file($path));
2042
    with_transaction($dbh, sub {
2043
        for my $observation (@{ $db->{observations} || [] }) {
2044
            $dbh->do(
2045
                'INSERT INTO mdns_observations (observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer, raw) '
2046
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2047
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2048
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2049
                undef,
2050
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2051
                clean_scalar($observation->{name} || ''),
2052
                clean_scalar($observation->{ip} || ''),
2053
                int($observation->{ttl} || 0),
2054
                clean_scalar($observation->{first_seen} || iso_now()),
2055
                clean_scalar($observation->{last_seen} || iso_now()),
2056
                int($observation->{seen_count} || 1),
2057
                clean_scalar($observation->{last_peer} || ''),
2058
            );
2059
        }
2060
    });
2061
}
2062

            
2063
sub parse_mdns_observations_yaml {
2064
    my ($text) = @_;
2065
    my %db = ( observations => [] );
2066
    my ($section, $current);
2067
    for my $line (split /\n/, $text || '') {
2068
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2069
        if ($line =~ /^observations:\s*$/) {
2070
            $section = 'observations';
2071
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2072
            $current = { key => yaml_unquote($1) };
2073
            push @{ $db{observations} }, $current;
2074
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2075
            $current->{$1} = yaml_unquote($2);
2076
        }
2077
    }
2078
    return \%db;
2079
}
2080

            
2081
sub set_schema_meta {
2082
    my ($dbh, $key, $value) = @_;
2083
    $dbh->do(
2084
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2085
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2086
        undef,
2087
        $key,
2088
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2089
        iso_now(),
2090
    );
2091
}
2092

            
Bogdan Timofte authored 4 days ago
2093
sub fqdn_for_legacy_id {
2094
    my ($dbh, $legacy_id) = @_;
2095
    return '' unless length($legacy_id || '');
2096
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2097
    return $fqdn || '';
2098
}
2099

            
2100
sub canonical_host_fqdn {
2101
    my ($host) = @_;
2102
    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
2103
    for my $name (@names) {
2104
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2105
    }
2106
    for my $name (@names) {
2107
        return $name if $name =~ /\./ && !name_is_vhost($name);
2108
    }
2109
    for my $name (@names) {
2110
        return $name if $name =~ /\./;
2111
    }
2112
    my $id = clean_id($host->{id} || '');
2113
    return $id ? "$id.madagascar.xdev.ro" : '';
2114
}
2115

            
2116
sub legacy_id_from_fqdn {
2117
    my ($fqdn) = @_;
2118
    $fqdn = normalize_dns_name($fqdn);
2119
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2120
    $fqdn =~ s/\..*\z//;
2121
    return clean_id($fqdn);
2122
}
2123

            
2124
sub normalize_dns_name {
2125
    my ($name) = @_;
2126
    $name = lc clean_scalar($name || '');
2127
    $name =~ s/\.\z//;
2128
    return $name;
2129
}
2130

            
2131
sub name_is_vhost {
2132
    my ($name) = @_;
2133
    $name = normalize_dns_name($name);
2134
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2135
}
2136

            
2137
sub vhost_service_name {
2138
    my ($name) = @_;
2139
    $name = normalize_dns_name($name);
2140
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2141
    return '';
2142
}
2143

            
2144
sub short_alias_for_fqdn {
2145
    my ($name) = @_;
2146
    $name = normalize_dns_name($name);
2147
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2148
    return '';
2149
}
2150

            
Bogdan Timofte authored 4 days ago
2151
sub normalize_registry_policy {
2152
    my ($registry) = @_;
2153
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2154
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2155
    $registry->{policy}{runtime_database} = $opt{db};
2156
}
2157

            
2158
sub default_hosts_yaml {
2159
    return <<'YAML';
2160
version: 1
2161
updated_at: ""
2162
policy:
Bogdan Timofte authored 4 days ago
2163
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2164
hosts:
2165
YAML
2166
}
2167

            
2168
sub default_work_orders_yaml {
2169
    return <<'YAML';
2170
version: 1
2171
work_orders:
2172
YAML
2173
}
2174

            
2175
sub ensure_parent_dir {
2176
    my ($path) = @_;
2177
    my $dir = dirname($path);
2178
    make_path($dir) unless -d $dir;
2179
}
2180

            
Xdev Host Manager authored a week ago
2181
sub url_decode {
2182
    my ($value) = @_;
2183
    $value = '' unless defined $value;
2184
    $value =~ tr/+/ /;
2185
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2186
    return $value;
2187
}
2188

            
2189
sub random_hex {
2190
    my ($bytes) = @_;
2191
    if (open my $fh, '<:raw', '/dev/urandom') {
2192
        read($fh, my $raw, $bytes);
2193
        close $fh;
2194
        return unpack('H*', $raw);
2195
    }
2196
    return sha256_hex(rand() . time() . $$);
2197
}
2198

            
2199
sub iso_now {
2200
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2201
}
2202

            
Bogdan Timofte authored 6 days ago
2203
sub build_info {
2204
    my %info = (
2205
        revision => '',
2206
        branch => '',
2207
        built_at => '',
2208
        deployed_at => '',
2209
        dirty => '',
2210
    );
2211

            
2212
    if ($ENV{HOST_MANAGER_BUILD}) {
2213
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2214
        return \%info;
2215
    }
2216

            
2217
    my $build_file = "$project_dir/BUILD";
2218
    if (-f $build_file) {
2219
        for my $line (split /\n/, read_file($build_file)) {
2220
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2221
            $info{$1} = clean_scalar($2);
2222
        }
2223
        return \%info if $info{revision} || $info{built_at};
2224
    }
2225

            
2226
    my $revision = git_value('rev-parse --short=12 HEAD');
2227
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2228
    $info{revision} = $revision if $revision;
2229
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2230
    return \%info;
2231
}
2232

            
2233
sub git_value {
2234
    my ($args) = @_;
2235
    return '' unless -d "$project_dir/.git";
2236
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2237
    my $value = <$fh> || '';
2238
    close $fh;
2239
    chomp $value;
2240
    return clean_scalar($value);
2241
}
2242

            
2243
sub build_label {
2244
    my $info = build_info();
2245
    my $revision = $info->{revision} || 'unknown';
2246
    my $branch = $info->{branch} || '';
2247
    $branch = '' if $branch eq 'HEAD';
2248
    my $label = $branch ? "$branch $revision" : $revision;
2249
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2250
    return $label;
2251
}
2252

            
2253
sub build_title {
2254
    my $info = build_info();
2255
    my $label = build_label();
2256
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2257
    return $stamp ? "$label deployed $stamp" : $label;
2258
}
2259

            
Bogdan Timofte authored 4 days ago
2260
sub build_revision {
2261
    my $info = build_info();
2262
    return $info->{revision} || 'unknown';
2263
}
2264

            
2265
sub build_details {
2266
    my $info = build_info();
2267
    my %details = (
2268
        app => 'Madagascar Local Authority',
2269
        revision => $info->{revision} || 'unknown',
2270
        branch => $info->{branch} || '',
2271
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2272
        built_at => $info->{built_at} || '',
2273
        deployed_at => $info->{deployed_at} || '',
2274
        label => build_label(),
2275
        title => build_title(),
2276
    );
2277
    return json_encode(\%details);
2278
}
2279

            
Bogdan Timofte authored 6 days ago
2280
sub html_escape {
2281
    my ($value) = @_;
2282
    $value = '' unless defined $value;
2283
    $value =~ s/&/&amp;/g;
2284
    $value =~ s/</&lt;/g;
2285
    $value =~ s/>/&gt;/g;
2286
    $value =~ s/"/&quot;/g;
2287
    $value =~ s/'/&#039;/g;
2288
    return $value;
2289
}
2290

            
Xdev Host Manager authored a week ago
2291
sub app_html {
Bogdan Timofte authored 4 days ago
2292
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2293
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2294
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2295
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2296
<!doctype html>
2297
<html lang="ro">
2298
<head>
2299
  <meta charset="utf-8">
2300
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2301
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2302
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2303
  <style>
2304
    :root {
2305
      color-scheme: light;
2306
      --ink: #152033;
2307
      --muted: #647084;
2308
      --line: #d8dee8;
2309
      --soft: #f4f6f9;
2310
      --panel: #ffffff;
2311
      --accent: #1267d8;
2312
      --bad: #b42318;
2313
      --warn: #946200;
2314
      --ok: #137333;
2315
    }
2316
    * { box-sizing: border-box; }
2317
    body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #eef2f6; font-size: 14px; }
Xdev Host Manager authored a week ago
2318

            
2319
    /* ── Login screen ── */
2320
    #login-screen {
2321
      display: flex;
Xdev Host Manager authored a week ago
2322
      align-items: flex-start;
Xdev Host Manager authored a week ago
2323
      justify-content: center;
2324
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2325
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2326
      background: #13182a;
Xdev Host Manager authored a week ago
2327
      overflow: auto;
Xdev Host Manager authored a week ago
2328
    }
2329
    .login-card {
Xdev Host Manager authored a week ago
2330
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2331
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2332
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2333
      background: #fff;
2334
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2335
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2336
         below the first box, sits inside the card instead of spilling past it. */
2337
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2338
      width: 100%;
Xdev Host Manager authored a week ago
2339
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2340
      min-height: 360px;
Xdev Host Manager authored a week ago
2341
      display: grid;
Xdev Host Manager authored a week ago
2342
      align-content: start;
2343
      justify-items: center;
2344
      gap: 28px;
Xdev Host Manager authored a week ago
2345
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2346
    }
Xdev Host Manager authored a week ago
2347
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2348
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2349
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2350
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2351
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2352
    }
Xdev Host Manager authored a week ago
2353
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2354
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2355
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2356
    .login-card form {
2357
      display: grid;
2358
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2359
      justify-self: center;
Bogdan Timofte authored a week ago
2360
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2361
    }
Xdev Host Manager authored a week ago
2362
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2363
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2364
       giving the password manager a username anchor and an aggregated OTP target
2365
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2366
    .pm-helper-fields {
2367
      position: absolute;
2368
      left: -10000px;
2369
      top: auto;
2370
      width: 1px;
2371
      height: 1px;
2372
      overflow: hidden;
2373
      opacity: 0.01;
2374
    }
2375
    .pm-helper-fields input {
2376
      width: 1px;
2377
      height: 1px;
2378
      padding: 0;
2379
      border: 0;
2380
    }
Bogdan Timofte authored 4 days ago
2381
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2382
       hint was what made Safari mark the whole group and re-present its OTP
2383
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2384
    .otp-row {
2385
      display: flex;
2386
      gap: var(--otp-gap);
2387
      justify-content: center;
2388
    }
Bogdan Timofte authored 5 days ago
2389
    .otp-row input {
Xdev Host Manager authored a week ago
2390
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
2391
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2392
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2393
      transition: border-color .15s, background .15s;
2394
    }
Bogdan Timofte authored 5 days ago
2395
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2396
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2397
    #login-error {
2398
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2399
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2400
    }
2401
    @media (max-width: 760px) {
2402
      .login-card {
Xdev Host Manager authored a week ago
2403
        max-width: 520px;
Xdev Host Manager authored a week ago
2404
        min-height: 0;
Bogdan Timofte authored 4 days ago
2405
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2406
        gap: 26px;
2407
      }
2408
      .login-card .brand h1 { font-size: 24px; }
2409
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2410
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2411
    }
Xdev Host Manager authored a week ago
2412
    @media (max-width: 430px) {
2413
      #login-screen { padding: 24px 16px 120px; }
2414
      .login-card {
2415
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2416
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2417
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2418
      }
Bogdan Timofte authored 5 days ago
2419
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2420
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2421
    }
2422
    @media (max-height: 720px) {
2423
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2424
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2425
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2426
    }
Xdev Host Manager authored a week ago
2427

            
2428
    /* ── App shell (hidden until authenticated) ── */
2429
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2430
    header { display: grid; grid-template-columns: minmax(180px, auto) 1fr auto; align-items: center; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
Xdev Host Manager authored a week ago
2431
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2432
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2433
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2434
    nav a:hover { color: var(--ink); background: var(--soft); }
2435
    nav a.active { color: var(--accent); background: #e8f0fe; }
2436
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2437
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2438
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2439
    .page { display: grid; gap: 16px; }
2440
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2441
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2442
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2443
    .panel { overflow: hidden; }
2444
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2445
    .panel-head h2 { margin: 0; font-size: 14px; }
2446
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2447
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2448
    button, input, select, textarea { font: inherit; }
2449
    button, .linkbtn { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 6px; padding: 7px 10px; min-height: 34px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
2450
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2451
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2452
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2453
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2454
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2455
    textarea { min-height: 74px; resize: vertical; }
2456
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2457
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2458
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2459
    tr:hover td { background: #f8fafc; }
2460
    .pill { display: inline-block; padding: 2px 6px; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); color: var(--muted); font-size: 12px; margin: 0 4px 4px 0; }
2461
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2462
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2463
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
2464
    .pill.derived { border-style: dashed; }
Xdev Host Manager authored a week ago
2465
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2466
    .span2 { grid-column: 1 / -1; }
2467
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2468
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2469
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2470
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2471
    .ca-fingerprint { overflow-wrap: anywhere; }
2472
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2473
    .build-control {
Bogdan Timofte authored 6 days ago
2474
      position: fixed;
2475
      right: 10px;
2476
      bottom: 8px;
2477
      z-index: 5;
Bogdan Timofte authored 4 days ago
2478
      display: inline-flex;
2479
      align-items: center;
2480
      gap: 4px;
2481
    }
2482
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2483
      color: rgba(255,255,255,.46);
2484
      background: rgba(19,24,42,.28);
2485
      border: 1px solid rgba(255,255,255,.08);
2486
      border-radius: 4px;
2487
      font-size: 10px;
2488
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2489
    }
2490
    .build-badge {
2491
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
2492
      cursor: text;
2493
      user-select: text;
Bogdan Timofte authored 6 days ago
2494
    }
Bogdan Timofte authored 4 days ago
2495
    .build-copy {
2496
      min-height: 0;
2497
      padding: 2px 5px;
2498
      cursor: pointer;
2499
    }
2500
    .build-copy:hover {
2501
      color: rgba(255,255,255,.72);
2502
      border-color: rgba(255,255,255,.24);
2503
    }
2504
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2505
      color: rgba(100,112,132,.58);
2506
      background: rgba(255,255,255,.72);
2507
      border-color: rgba(216,222,232,.72);
2508
    }
Bogdan Timofte authored 4 days ago
2509
    body.is-app .build-copy:hover {
2510
      color: rgba(21,32,51,.78);
2511
      border-color: rgba(100,112,132,.42);
2512
    }
Xdev Host Manager authored a week ago
2513
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2514
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2515
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2516
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2517
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2518
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2519
    .work-order-actions { gap: 4px; }
2520
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2521
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2522
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2523
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
2524
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
2525
    .debug-table-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
Bogdan Timofte authored 4 days ago
2526
    .debug-table-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 58px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
Bogdan Timofte authored 4 days ago
2527
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
2528
    .debug-table-card.active { border-color: var(--accent); background: #e8f0fe; box-shadow: inset 0 0 0 1px var(--accent); }
Bogdan Timofte authored 4 days ago
2529
    .debug-table-card-main { display: grid; align-content: center; justify-items: start; gap: 5px; min-width: 0; min-height: 42px; width: 100%; padding: 4px 6px; border: 0; background: transparent; text-align: left; }
2530
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
2531
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
2532
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
2533
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
2534
    .debug-table-copy::before, .debug-table-copy::after { content: ""; position: absolute; width: 12px; height: 14px; border: 1.6px solid currentColor; border-radius: 2px; box-sizing: border-box; }
2535
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
2536
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
2537
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2538
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
2539
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2540
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2541
    .host-tools input { max-width: 240px; }
2542
    .modal-backdrop {
2543
      position: fixed;
2544
      inset: 0;
2545
      z-index: 10;
2546
      display: grid;
2547
      align-items: start;
2548
      justify-items: center;
2549
      padding: 72px 16px 24px;
2550
      background: rgba(21,32,51,.48);
2551
      overflow: auto;
2552
    }
2553
    .modal-backdrop[hidden] { display: none; }
2554
    .modal {
2555
      width: min(840px, 100%);
2556
      max-height: calc(100dvh - 96px);
2557
      overflow: auto;
2558
      background: var(--panel);
2559
      border: 1px solid var(--line);
2560
      border-radius: 8px;
2561
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2562
    }
2563
    .modal-head {
2564
      position: sticky;
2565
      top: 0;
2566
      z-index: 1;
2567
      display: flex;
2568
      align-items: center;
2569
      justify-content: space-between;
2570
      gap: 12px;
2571
      padding: 12px 14px;
2572
      border-bottom: 1px solid var(--line);
2573
      background: #fafbfc;
2574
    }
2575
    .modal-head h2 { margin: 0; font-size: 14px; }
2576
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2577
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2578
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2579
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2580
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2581
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2582
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2583
      #message { max-width: 100%; }
2584
      .panel-head { align-items: stretch; flex-direction: column; }
2585
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2586
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2587
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2588
      .modal-backdrop { padding-top: 16px; }
2589
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2590
      .grid { grid-template-columns: 1fr; }
2591
      table { min-width: 760px; }
2592
      .table-wrap { overflow-x: auto; }
2593
    }
2594
  </style>
2595
</head>
Bogdan Timofte authored 6 days ago
2596
<body class="is-login">
Xdev Host Manager authored a week ago
2597

            
Xdev Host Manager authored a week ago
2598
  <!-- ── Login screen ── -->
2599
  <div id="login-screen">
2600
    <div class="login-card">
2601
      <div class="brand">
2602
        <div class="icon">
Xdev Host Manager authored a week ago
2603
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2604
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2605
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2606
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2607
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2608
            <path d="M26 20h8M26 32h8M26 44h8"/>
2609
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2610
          </svg>
2611
        </div>
Xdev Host Manager authored a week ago
2612
        <h1>Madagascar Local Authority</h1>
2613
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2614
      </div>
Bogdan Timofte authored 4 days ago
2615
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2616
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2617
        <div class="pm-helper-fields" aria-hidden="true">
2618
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2619
          <input type="hidden" id="otp-hidden" name="otp">
2620
        </div>
Xdev Host Manager authored a week ago
2621
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2622
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2623
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2624
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2625
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2626
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2627
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2628
        </div>
2629
      </form>
2630
    </div>
2631
  </div>
2632

            
2633
  <!-- ── App (shown after login) ── -->
2634
  <div id="app">
2635
    <header>
Xdev Host Manager authored a week ago
2636
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2637
      <nav aria-label="Sections">
2638
        <a href="/overview" data-page-link="overview">Overview</a>
2639
        <a href="/hosts" data-page-link="hosts">Hosts</a>
2640
        <a href="/dns" data-page-link="dns">DNS</a>
2641
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2642
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2643
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2644
      </nav>
Xdev Host Manager authored a week ago
2645
      <div class="header-right">
2646
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2647
        <span id="message" class="muted"></span>
2648
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2649
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2650
      </div>
Xdev Host Manager authored a week ago
2651
    </header>
2652
    <main>
Bogdan Timofte authored 5 days ago
2653
      <section class="page" id="page-overview" data-page="overview">
2654
        <section class="panel">
2655
          <div class="panel-head">
2656
            <h2>Overview</h2>
2657
            <div class="stats" id="stats"></div>
2658
          </div>
2659
          <div class="problems" id="problems"></div>
2660
        </section>
Xdev Host Manager authored a week ago
2661
      </section>
2662

            
Bogdan Timofte authored 5 days ago
2663
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2664
        <section class="panel">
2665
          <div class="panel-head">
2666
            <h2>Hosts</h2>
2667
            <div class="host-tools">
2668
              <input id="filter" placeholder="filter">
2669
              <button type="button" id="new-host">New host</button>
2670
            </div>
2671
          </div>
2672
          <div class="table-wrap">
2673
            <table>
2674
              <thead>
2675
                <tr>
2676
                  <th style="width: 120px">ID</th>
2677
                  <th style="width: 130px">hosts_ip</th>
2678
                  <th style="width: 130px">dns_ip</th>
2679
                  <th>Names</th>
2680
                  <th style="width: 150px">Roles</th>
2681
                  <th style="width: 110px">Monitoring</th>
2682
                  <th style="width: 90px">Status</th>
2683
                </tr>
2684
              </thead>
2685
              <tbody id="hosts"></tbody>
2686
            </table>
2687
          </div>
2688
        </section>
Xdev Host Manager authored a week ago
2689
      </section>
Xdev Host Manager authored a week ago
2690

            
Bogdan Timofte authored 5 days ago
2691
      <section class="page" id="page-dns" data-page="dns" hidden>
2692
        <section class="toolbar">
2693
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2694
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2695
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2696
          <button id="write-tsv">Write local-hosts.tsv</button>
2697
        </section>
Xdev Host Manager authored a week ago
2698
      </section>
2699

            
Bogdan Timofte authored 5 days ago
2700
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2701
        <section class="panel">
2702
          <div class="panel-head">
2703
            <h2>Work Orders</h2>
2704
            <div class="stats" id="wo-stats"></div>
2705
          </div>
2706
          <div class="problems" id="work-orders"></div>
2707
        </section>
Xdev Host Manager authored a week ago
2708
      </section>
2709

            
Bogdan Timofte authored 5 days ago
2710
      <section class="page" id="page-ca" data-page="ca" hidden>
2711
        <section class="panel">
2712
          <div class="panel-head">
2713
            <h2>Local Certificate Authority</h2>
2714
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2715
          </div>
2716
          <div class="problems" id="ca-status"></div>
2717
        </section>
2718
        <section class="panel">
2719
          <div class="panel-head">
2720
            <h2>Issued Certificates</h2>
2721
            <div class="stats" id="ca-certs-summary"></div>
2722
          </div>
2723
          <div class="table-wrap">
2724
            <table>
2725
              <thead>
2726
                <tr>
2727
                  <th style="width: 150px">Name</th>
2728
                  <th>DNS names</th>
2729
                  <th style="width: 210px">Validity</th>
2730
                  <th style="width: 180px">Serial</th>
2731
                  <th>Fingerprint</th>
2732
                  <th style="width: 110px">Download</th>
2733
                </tr>
2734
              </thead>
2735
              <tbody id="ca-certs"></tbody>
2736
            </table>
2737
          </div>
2738
        </section>
Xdev Host Manager authored a week ago
2739
      </section>
Bogdan Timofte authored 4 days ago
2740

            
2741
      <section class="page" id="page-debug" data-page="debug" hidden>
2742
        <section class="panel">
2743
          <div class="panel-head">
2744
            <h2>Database</h2>
2745
            <div class="stats" id="debug-db-stats"></div>
2746
          </div>
2747
          <div class="toolbar">
2748
            <div class="debug-controls">
2749
              <button type="button" id="debug-db-refresh">Refresh</button>
2750
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
2751
            </div>
2752
          </div>
Bogdan Timofte authored 4 days ago
2753
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
2754
        </section>
2755
        <section class="debug-section">
2756
          <section class="panel">
2757
            <div class="panel-head">
2758
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
2759
              <div class="debug-table-head-actions">
2760
                <div class="stats" id="debug-table-stats"></div>
2761
                <div class="debug-table-exports">
2762
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
2763
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
2764
                </div>
2765
              </div>
Bogdan Timofte authored 4 days ago
2766
            </div>
2767
            <div class="table-wrap" id="debug-table-rows"></div>
2768
          </section>
2769
          <section class="panel">
2770
            <div class="panel-head">
2771
              <h2>Columns</h2>
2772
            </div>
2773
            <div class="table-wrap" id="debug-table-columns"></div>
2774
          </section>
2775
          <section class="panel">
2776
            <div class="panel-head">
2777
              <h2>Indexes</h2>
2778
            </div>
2779
            <div class="table-wrap" id="debug-table-indexes"></div>
2780
          </section>
2781
          <section class="panel">
2782
            <div class="panel-head">
2783
              <h2>Foreign Keys</h2>
2784
            </div>
2785
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
2786
          </section>
2787
        </section>
2788
      </section>
Bogdan Timofte authored 5 days ago
2789
    </main>
Xdev Host Manager authored a week ago
2790

            
Bogdan Timofte authored 5 days ago
2791
    <div id="host-modal" class="modal-backdrop" hidden>
2792
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
2793
        <div class="modal-head">
2794
          <h2 id="host-modal-title">Edit host</h2>
2795
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
2796
        </div>
2797
        <form id="host-form" class="grid">
2798
          <label>ID<input name="id" required></label>
2799
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2800
          <label>hosts_ip<input name="hosts_ip" required></label>
2801
          <label>dns_ip<input name="dns_ip" required></label>
2802
          <label class="span2">Names<textarea name="names" required></textarea></label>
2803
          <label>Roles<input name="roles"></label>
2804
          <label>Sources<input name="sources"></label>
2805
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2806
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
2807
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
2808
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
2809
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
2810
            <button class="danger" type="button" id="delete-host">Delete host</button>
2811
          </div>
2812
        </form>
2813
      </section>
Bogdan Timofte authored 5 days ago
2814
    </div>
Xdev Host Manager authored a week ago
2815
  </div>
2816

            
Bogdan Timofte authored 4 days ago
2817
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
2818
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
2819
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
2820
  </div>
Bogdan Timofte authored 6 days ago
2821

            
Xdev Host Manager authored a week ago
2822
  <script>
Bogdan Timofte authored 4 days ago
2823
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
2824
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
2825

            
2826
    const $ = (id) => document.getElementById(id);
2827
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
2828
    const PAGE_PATHS = {
2829
      '/': 'overview',
2830
      '/overview': 'overview',
2831
      '/hosts': 'hosts',
2832
      '/dns': 'dns',
2833
      '/work-orders': 'work-orders',
2834
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
2835
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
2836
    };
Xdev Host Manager authored a week ago
2837

            
Bogdan Timofte authored 4 days ago
2838
    function isAuthLost(error) {
2839
      return !!(error && error.authLost);
2840
    }
2841

            
2842
    function authLostError(message) {
2843
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2844
      error.authLost = true;
2845
      return error;
2846
    }
2847

            
2848
    function handleAuthLost(message) {
2849
      state.authenticated = false;
2850
      msg('');
2851
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2852
    }
2853

            
Xdev Host Manager authored a week ago
2854
    async function api(path, options = {}) {
2855
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
2856
      let body = {};
2857
      try {
2858
        body = await res.json();
2859
      } catch (_) {
2860
        body = {};
2861
      }
2862
      const errorCode = body.error || '';
2863
      if (!res.ok) {
2864
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
2865
          const error = authLostError();
2866
          handleAuthLost(error.message);
2867
          throw error;
2868
        }
2869
        throw new Error(errorCode || res.statusText);
2870
      }
Xdev Host Manager authored a week ago
2871
      return body;
2872
    }
2873

            
Bogdan Timofte authored 5 days ago
2874
    function currentPage() {
2875
      return PAGE_PATHS[window.location.pathname] || 'overview';
2876
    }
2877

            
2878
    function showPage(page, push = false) {
2879
      const target = page || 'overview';
2880
      document.querySelectorAll('[data-page]').forEach(section => {
2881
        section.hidden = section.dataset.page !== target;
2882
      });
2883
      document.querySelectorAll('[data-page-link]').forEach(link => {
2884
        link.classList.toggle('active', link.dataset.pageLink === target);
2885
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
2886
      });
2887
      if (push) {
2888
        const href = target === 'overview' ? '/overview' : '/' + target;
2889
        history.pushState({ page: target }, '', href);
2890
      }
Bogdan Timofte authored 4 days ago
2891
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
2892
        renderDebugDatabase().catch(e => {
2893
          if (!isAuthLost(e)) msg(e.message);
2894
        });
Bogdan Timofte authored 4 days ago
2895
      }
Bogdan Timofte authored 5 days ago
2896
    }
2897

            
Xdev Host Manager authored a week ago
2898
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
2899
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
2900
      document.body.classList.remove('is-app');
2901
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
2902
      $('app').style.display = 'none';
2903
      $('login-screen').style.display = 'flex';
2904
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
2905
      clearOtp();
Xdev Host Manager authored a week ago
2906
    }
2907

            
2908
    function showApp() {
Bogdan Timofte authored 6 days ago
2909
      document.body.classList.remove('is-login');
2910
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
2911
      $('login-screen').style.display = 'none';
2912
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
2913
      showPage(currentPage());
Xdev Host Manager authored a week ago
2914
    }
2915

            
Xdev Host Manager authored a week ago
2916
    async function refresh() {
2917
      const session = await api('/api/session');
2918
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
2919
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
2920
      showApp();
Xdev Host Manager authored a week ago
2921
      const data = await api('/api/hosts');
2922
      state.hosts = data.hosts || [];
2923
      state.problems = data.problems || [];
2924
      render(data);
Xdev Host Manager authored a week ago
2925
      await renderCa();
Xdev Host Manager authored a week ago
2926
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
2927
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
2928
    }
2929

            
2930
    function render(data) {
Xdev Host Manager authored a week ago
2931
      $('app-updated').textContent = data.updated_at ? 'updated ' + data.updated_at : '';
2932

            
Xdev Host Manager authored a week ago
2933
      $('stats').innerHTML = [
2934
        ['hosts', data.counts.hosts],
2935
        ['problems', data.counts.problems],
2936
      ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2937

            
2938
      $('problems').innerHTML = state.problems.length
2939
        ? state.problems.map(p => `<div class="problem"><strong>${escapeHtml(p.host_id)}</strong> ${escapeHtml(p.code)}: ${escapeHtml(p.message)}</div>`).join('')
2940
        : '<div class="muted" style="padding: 8px 0">No registry problems detected.</div>';
2941

            
2942
      renderHosts();
2943
    }
2944

            
Xdev Host Manager authored a week ago
2945
    async function renderCa() {
2946
      try {
2947
        const status = await api('/api/ca/status');
2948
        if (!status.initialized) {
2949
          $('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
Bogdan Timofte authored 5 days ago
2950
          $('ca-certs-summary').innerHTML = '';
2951
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
2952
          return;
2953
        }
2954
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
2955
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
2956
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
2957
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
2958
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
2959
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
2960
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
2961
            <div>
2962
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
2963
              <span>${certs.length} issued certificate(s)</span>
2964
            </div>
Xdev Host Manager authored a week ago
2965
          </div>`;
Bogdan Timofte authored 5 days ago
2966
        $('ca-certs-summary').innerHTML = [
2967
          ['issued', certs.length],
2968
          ['expiring', certs.filter(cert => {
2969
            const days = daysUntil(cert.not_after);
2970
            return days !== null && days >= 0 && days <= 30;
2971
          }).length],
2972
          ['expired', certs.filter(cert => {
2973
            const days = daysUntil(cert.not_after);
2974
            return days !== null && days < 0;
2975
          }).length],
2976
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2977
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
2978
          const days = daysUntil(cert.not_after);
2979
          const dnsNames = cert.dns_names || [];
2980
          const dnsHtml = dnsNames.length
2981
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
2982
            : '<span class="muted">No DNS SANs reported.</span>';
2983
          return `<tr>
2984
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
2985
            <td>${dnsHtml}</td>
2986
            <td>
2987
              <div class="ca-detail">
2988
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
2989
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
2990
              </div>
2991
            </td>
2992
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
2993
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
2994
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
2995
          </tr>`;
2996
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
2997
      } catch (e) {
Bogdan Timofte authored 4 days ago
2998
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
2999
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3000
        $('ca-certs-summary').innerHTML = '';
3001
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3002
      }
3003
    }
3004

            
Bogdan Timofte authored 5 days ago
3005
    function daysUntil(dateText) {
3006
      const time = Date.parse(dateText || '');
3007
      if (!Number.isFinite(time)) return null;
3008
      return Math.ceil((time - Date.now()) / 86400000);
3009
    }
3010

            
3011
    function certStatusClass(days) {
3012
      if (days === null) return '';
3013
      if (days < 0) return 'bad';
3014
      if (days <= 30) return 'warn';
3015
      return 'ok';
3016
    }
3017

            
3018
    function certStatusLabel(days) {
3019
      if (days === null) return 'validity unknown';
3020
      if (days < 0) return 'expired';
3021
      if (days === 0) return 'expires today';
3022
      return `${days}d remaining`;
3023
    }
3024

            
Xdev Host Manager authored a week ago
3025
    async function renderWorkOrders() {
3026
      try {
3027
        const data = await api('/api/work-orders');
3028
        state.workOrders = data.work_orders || [];
3029
        $('wo-stats').innerHTML = [
3030
          ['pending', data.counts.pending],
3031
          ['total', data.counts.work_orders],
3032
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3033

            
3034
        if (!state.workOrders.length) {
3035
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3036
          return;
3037
        }
3038

            
3039
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3040
          const checklist = wo.checklist || [];
3041
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3042
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3043
          const checklistHtml = checklist.map(item => {
3044
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3045
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3046
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3047
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3048
            </label>`;
3049
          }).join('');
Xdev Host Manager authored a week ago
3050
          const actions = (wo.actions || []).map(a => {
3051
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3052
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3053
          }).join('');
3054
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3055
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3056
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3057
            : '';
Bogdan Timofte authored 6 days ago
3058
          return `<div class="problem work-order-card">
3059
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3060
              <div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span> <span class="pill">${doneItems}/${checklist.length} done</span></div>
Xdev Host Manager authored a week ago
3061
              ${button}
3062
            </div>
Bogdan Timofte authored 6 days ago
3063
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3064
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3065
            <div class="work-order-checklist">${checklistHtml}</div>
3066
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3067
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3068
          </div>`;
3069
        }).join('');
Xdev Host Manager authored a week ago
3070
        document.querySelectorAll('[data-wo-checklist]').forEach(input => input.addEventListener('change', () => updateWorkOrderChecklist(input.dataset.woChecklist, input.dataset.itemId, input.checked)));
Xdev Host Manager authored a week ago
3071
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3072
      } catch (e) {
Bogdan Timofte authored 4 days ago
3073
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3074
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3075
      }
3076
    }
3077

            
Bogdan Timofte authored 4 days ago
3078
    async function renderDebugDatabase() {
3079
      if (!state.authenticated) return;
3080
      const data = await api('/api/debug/database/tables');
3081
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3082
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3083
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3084
      $('debug-db-stats').innerHTML = [
3085
        ['tables', data.counts ? data.counts.tables : tables.length],
3086
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3087
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3088
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3089
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3090
      if (selected) {
3091
        await renderDebugTable(selected);
3092
      } else {
3093
        clearDebugTable();
3094
      }
3095
    }
3096

            
Bogdan Timofte authored 4 days ago
3097
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3098
      $('debug-db-tables').innerHTML = tables.length
3099
        ? tables.map(table => {
3100
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3101
            const ref = debugTableReference(database, table.name);
3102
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3103
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3104
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3105
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3106
              </button>
Bogdan Timofte authored 4 days ago
3107
              <button type="button" class="debug-table-copy" data-debug-table-ref="${escapeHtml(ref)}" title="${escapeHtml(ref)}" aria-label="Copy full table reference for ${escapeHtml(table.name)}"></button>
Bogdan Timofte authored 4 days ago
3108
            </div>`;
Bogdan Timofte authored 4 days ago
3109
          }).join('')
3110
        : '<div class="ca-empty muted">No database tables found.</div>';
3111
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3112
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3113
          if (!isAuthLost(e)) msg(e.message);
3114
        }));
3115
      });
Bogdan Timofte authored 4 days ago
3116
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3117
        button.addEventListener('click', async () => {
3118
          try {
3119
            await copyText(button.dataset.debugTableRef || '');
3120
            msg('table reference copied');
3121
          } catch (e) {
3122
            msg('copy failed');
3123
          }
3124
        });
3125
      });
3126
    }
3127

            
3128
    function debugTableReference(database, tableName) {
3129
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3130
    }
3131

            
3132
    async function selectDebugTable(tableName) {
3133
      state.debugTable = tableName || '';
3134
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3135
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3136
        const card = button.closest('.debug-table-card');
3137
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3138
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3139
      });
3140
      if (state.debugTable) await renderDebugTable(state.debugTable);
3141
    }
3142

            
3143
    function clearDebugTable() {
3144
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3145
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3146
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3147
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3148
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3149
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3150
    }
3151

            
3152
    async function renderDebugTable(tableName) {
3153
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3154
      if (data.error) throw new Error(data.error);
3155
      $('debug-table-stats').innerHTML = [
3156
        ['table', data.table || tableName],
3157
        ['rows', data.row_count || 0],
3158
        ['shown', (data.rows || []).length],
3159
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3160
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3161
      renderDebugRows(data);
3162
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3163
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3164
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3165
    }
3166

            
Bogdan Timofte authored 4 days ago
3167
    function updateDebugExportLinks(tableName) {
3168
      const encoded = encodeURIComponent(tableName || '');
3169
      [
3170
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3171
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3172
      ].forEach(([id, href]) => {
3173
        const link = $(id);
3174
        const enabled = !!tableName;
3175
        link.href = enabled ? href : '#';
3176
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3177
      });
3178
    }
3179

            
Bogdan Timofte authored 4 days ago
3180
    function renderDebugRows(data) {
3181
      const rows = data.rows || [];
3182
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3183
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3184
    }
3185

            
3186
    function renderDebugObjectTable(rows, preferredKeys) {
3187
      const keys = preferredKeys && preferredKeys.length
3188
        ? preferredKeys
3189
        : Array.from(rows.reduce((set, row) => {
3190
            Object.keys(row || {}).forEach(key => set.add(key));
3191
            return set;
3192
          }, new Set()));
3193
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3194
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3195
      const body = rows.length
3196
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3197
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3198
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3199
    }
3200

            
3201
    function debugCell(value) {
3202
      if (value === null || value === undefined) return 'NULL';
3203
      if (Array.isArray(value)) return value.join(', ');
3204
      if (typeof value === 'object') return JSON.stringify(value);
3205
      return String(value);
3206
    }
3207

            
Xdev Host Manager authored a week ago
3208
    async function updateWorkOrderChecklist(id, itemId, checked) {
3209
      try {
3210
        await api('/api/work-orders/checklist', {
3211
          method: 'POST',
3212
          headers: { 'Content-Type': 'application/json' },
3213
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3214
        });
3215
        msg('work order updated');
3216
        await refresh();
Bogdan Timofte authored 4 days ago
3217
      } catch (e) {
3218
        if (isAuthLost(e)) return;
3219
        msg(e.message);
3220
        await refresh().catch(refreshError => {
3221
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3222
        });
3223
      }
Xdev Host Manager authored a week ago
3224
    }
3225

            
Xdev Host Manager authored a week ago
3226
    async function confirmWorkOrder(id) {
3227
      const typed = prompt(`Type ${id} to confirm this work order`);
3228
      if (typed !== id) return;
3229
      try {
3230
        await api('/api/work-orders/confirm', {
3231
          method: 'POST',
3232
          headers: { 'Content-Type': 'application/json' },
3233
          body: JSON.stringify({ id, confirm: typed })
3234
        });
3235
        msg('work order confirmed; local-hosts.tsv written');
3236
        await refresh();
Bogdan Timofte authored 4 days ago
3237
      } catch (e) {
3238
        if (isAuthLost(e)) return;
3239
        msg(e.message);
3240
      }
Xdev Host Manager authored a week ago
3241
    }
3242

            
Xdev Host Manager authored a week ago
3243
    function renderHosts() {
3244
      const filter = $('filter').value.toLowerCase();
3245
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
3246
        .slice()
3247
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3248
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3249
        .map(h => {
3250
          const problems = state.problems.filter(p => p.host_id === h.id);
3251
          const cls = problems.length ? 'warn' : 'ok';
3252
          return `<tr data-id="${escapeHtml(h.id)}">
3253
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3254
            <td>${escapeHtml(h.hosts_ip || '')}</td>
3255
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 5 days ago
3256
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3257
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3258
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3259
            <td>${escapeHtml(h.status || '')}</td>
3260
          </tr>`;
3261
        }).join('');
3262
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
3263
    }
3264

            
Bogdan Timofte authored 5 days ago
3265
    function renderNamePills(host) {
3266
      const declared = host.declared_names || host.names || [];
3267
      const derived = host.derived_names || [];
3268
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3269
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
3270
      return declaredHtml + derivedHtml;
3271
    }
3272

            
Xdev Host Manager authored a week ago
3273
    function editHost(id) {
3274
      const host = state.hosts.find(h => h.id === id);
3275
      if (!host) return;
3276
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3277
      clearHostFormMessage();
3278
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 5 days ago
3279
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 5 days ago
3280
      hostField('roles').value = (host.roles || []).join(' ');
3281
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3282
      openHostModal('Edit host');
3283
    }
3284

            
3285
    function newHost() {
3286
      const form = $('host-form');
3287
      form.reset();
Bogdan Timofte authored 5 days ago
3288
      clearHostFormMessage();
3289
      hostField('status').value = 'active';
3290
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3291
      openHostModal('New host');
3292
    }
3293

            
3294
    function openHostModal(title) {
3295
      $('host-modal-title').textContent = title || 'Edit host';
3296
      $('host-modal').hidden = false;
3297
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3298
      hostFormSnapshot = hostFormState();
3299
      hostField('id').focus();
3300
    }
3301

            
3302
    function requestCloseHostModal() {
3303
      if ($('save-host').disabled) return;
3304
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3305
      closeHostModal();
Bogdan Timofte authored 5 days ago
3306
    }
3307

            
3308
    function closeHostModal() {
3309
      $('host-modal').hidden = true;
3310
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3311
      setHostFormBusy(false);
3312
      clearHostFormMessage();
3313
      hostFormSnapshot = '';
3314
    }
3315

            
3316
    function hostField(name) {
3317
      return $('host-form').elements.namedItem(name);
3318
    }
3319

            
3320
    function hostFormState() {
3321
      return JSON.stringify(formObject($('host-form')));
3322
    }
3323

            
3324
    function hostFormDirty() {
3325
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3326
    }
3327

            
3328
    function setHostFormBusy(busy) {
3329
      $('save-host').disabled = busy;
3330
      $('delete-host').disabled = busy;
3331
      $('close-host-modal').disabled = busy;
3332
    }
3333

            
3334
    function setHostFormMessage(text, isError = false) {
3335
      const message = $('host-form-message');
3336
      message.textContent = text || '';
3337
      message.classList.toggle('error', !!isError);
3338
    }
3339

            
3340
    function clearHostFormMessage() {
3341
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3342
    }
3343

            
3344
    function formObject(form) {
3345
      return Object.fromEntries(new FormData(form).entries());
3346
    }
3347

            
3348
    function escapeHtml(value) {
Bogdan Timofte authored 5 days ago
3349
      value = value == null ? '' : String(value);
Xdev Host Manager authored a week ago
3350
      return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
3351
    }
3352

            
Bogdan Timofte authored 6 days ago
3353
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3354

            
Xdev Host Manager authored a week ago
3355
    // OTP digit boxes — auto-advance, backspace, paste
3356
    const otpDigits = Array.from(document.querySelectorAll('.otp-digit'));
Bogdan Timofte authored 6 days ago
3357
    const otpHidden = $('otp-hidden');
3358
    const loginAccount = $('login-account');
3359

            
3360
    if (loginAccount) {
3361
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3362
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3363
      loginAccount.addEventListener('input', () => {
3364
        const value = (loginAccount.value || '').trim();
3365
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3366
      });
3367
    }
3368

            
Xdev Host Manager authored a week ago
3369
    function setOtpDigit(idx, value) {
3370
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
3371
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3372
      otpDigits[idx].classList.toggle('filled', !!digit);
3373
    }
3374

            
Bogdan Timofte authored 4 days ago
3375
    // Move focus to the next empty box: forward from idx, then wrapping to the
3376
    // start. This lets out-of-order entry continue (e.g. after the last box,
3377
    // jump back to the first still-empty box). Stays put when all boxes are full.
3378
    function advanceFocus(idx) {
3379
      for (let i = idx + 1; i < otpDigits.length; i++) {
3380
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3381
      }
3382
      for (let i = 0; i <= idx; i++) {
3383
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3384
      }
3385
    }
3386

            
Bogdan Timofte authored 5 days ago
3387
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3388
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3389
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
3390
      const digits = (text || '').replace(/\D/g, '').split('');
3391
      if (!digits.length) return;
3392
      let last = startIdx;
3393
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3394
        last = startIdx + i;
3395
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3396
      }
Bogdan Timofte authored 5 days ago
3397
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3398
      advanceFocus(last);
Xdev Host Manager authored a week ago
3399
      maybeSubmitOtp();
3400
    }
3401

            
Bogdan Timofte authored 5 days ago
3402
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3403
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3404
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3405
    function maybeSubmitOtp() {
3406
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3407
    }
3408
    function clearOtp() {
Bogdan Timofte authored 5 days ago
3409
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3410
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3411
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3412
      // an unknown operator, so Safari's autofill anchor on the username stays.
3413
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3414
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3415
    }
3416

            
Bogdan Timofte authored 5 days ago
3417
    otpDigits.forEach((input, idx) => {
3418
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3419
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3420
        // A single box may receive several digits at once (autofill / typing fast).
3421
        if (input.value.replace(/\D/g, '').length > 1) {
3422
          fillOtp(input.value, idx);
3423
          return;
3424
        }
3425
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
3426
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3427
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
3428
        maybeSubmitOtp();
3429
      });
Bogdan Timofte authored 5 days ago
3430

            
3431
      input.addEventListener('paste', (e) => {
3432
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3433
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3434
        const text = (e.clipboardData || window.clipboardData).getData('text');
3435
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
3436
      });
Bogdan Timofte authored 5 days ago
3437

            
3438
      input.addEventListener('keydown', (e) => {
3439
        if (e.key === 'Backspace') {
3440
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3441
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3442
          if (input.value) { setOtpDigit(idx, ''); }
3443
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3444
          syncOtpFields();
3445
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3446
          e.preventDefault();
3447
          otpDigits[idx - 1].focus();
3448
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3449
          e.preventDefault();
3450
          otpDigits[idx + 1].focus();
3451
        }
3452
      });
3453
    });
3454

            
Bogdan Timofte authored 4 days ago
3455
    // Focus the first OTP box only for a returning operator (username known).
3456
    // For an unknown operator, leave focus on the username field so Safari can
3457
    // present its OTP autofill anchored there without being dismissed by a focus
3458
    // change (pbx-admin pattern).
3459
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3460
    else if (loginAccount) loginAccount.focus();
3461
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3462

            
Bogdan Timofte authored 5 days ago
3463
    document.querySelectorAll('[data-page-link]').forEach(link => {
3464
      link.addEventListener('click', (event) => {
3465
        event.preventDefault();
3466
        showPage(link.dataset.pageLink, true);
3467
      });
3468
    });
3469

            
3470
    window.addEventListener('popstate', () => showPage(currentPage()));
3471

            
Bogdan Timofte authored 4 days ago
3472
    async function copyText(text) {
3473
      if (navigator.clipboard && window.isSecureContext) {
3474
        await navigator.clipboard.writeText(text);
3475
        return;
3476
      }
3477
      const input = document.createElement('textarea');
3478
      input.value = text;
3479
      input.setAttribute('readonly', '');
3480
      input.style.position = 'fixed';
3481
      input.style.left = '-10000px';
3482
      document.body.appendChild(input);
3483
      input.select();
3484
      document.execCommand('copy');
3485
      document.body.removeChild(input);
3486
    }
3487

            
3488
    $('copy-build').addEventListener('click', async () => {
3489
      try {
3490
        await copyText($('copy-build').dataset.buildDetails || '');
3491
        if (state.authenticated) msg('build details copied');
3492
      } catch (e) {
3493
        if (state.authenticated) msg('copy failed');
3494
      }
3495
    });
3496

            
Xdev Host Manager authored a week ago
3497
    $('login-form').addEventListener('submit', async (event) => {
3498
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3499
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3500
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3501
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3502
      try {
Xdev Host Manager authored a week ago
3503
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3504
        await refresh();
Xdev Host Manager authored a week ago
3505
      } catch (e) {
3506
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3507
      } finally {
Xdev Host Manager authored a week ago
3508
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3509
      }
Xdev Host Manager authored a week ago
3510
    });
3511

            
3512
    $('logout').addEventListener('click', async () => {
3513
      await api('/api/logout', { method: 'POST' }).catch(() => {});
Bogdan Timofte authored 5 days ago
3514
      window.location.replace('/?logged_out=' + Date.now());
Xdev Host Manager authored a week ago
3515
    });
3516

            
Bogdan Timofte authored 4 days ago
3517
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3518
      if (!isAuthLost(e)) msg(e.message);
3519
    }));
Xdev Host Manager authored a week ago
3520
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 5 days ago
3521
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 4 days ago
3522
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3523
      if (!isAuthLost(e)) msg(e.message);
3524
    }));
Bogdan Timofte authored 5 days ago
3525
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3526
    $('host-modal').addEventListener('click', (event) => {
3527
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3528
    });
Bogdan Timofte authored 5 days ago
3529
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3530
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3531
    });
Xdev Host Manager authored a week ago
3532

            
Xdev Host Manager authored a week ago
3533
    $('host-form').addEventListener('submit', async (event) => {
3534
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3535
      setHostFormBusy(true);
3536
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3537
      try {
3538
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3539
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3540
        closeHostModal();
Xdev Host Manager authored a week ago
3541
        msg('host saved');
3542
        await refresh();
Bogdan Timofte authored 5 days ago
3543
      } catch (e) {
Bogdan Timofte authored 4 days ago
3544
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3545
        setHostFormMessage(e.message, true);
3546
        msg(e.message);
3547
      } finally {
3548
        setHostFormBusy(false);
3549
      }
3550
    });
3551

            
3552
    $('host-form').addEventListener('invalid', (event) => {
3553
      setHostFormMessage('Complete the required host fields before saving.', true);
3554
    }, true);
3555

            
3556
    $('host-form').addEventListener('input', () => {
3557
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3558
    });
3559

            
3560
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3561
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3562
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3563
      setHostFormBusy(true);
3564
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3565
      try {
3566
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3567
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3568
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3569
        closeHostModal();
Xdev Host Manager authored a week ago
3570
        msg('host deleted');
3571
        await refresh();
Bogdan Timofte authored 5 days ago
3572
      } catch (e) {
Bogdan Timofte authored 4 days ago
3573
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3574
        setHostFormMessage(e.message, true);
3575
        msg(e.message);
3576
      } finally {
3577
        setHostFormBusy(false);
3578
      }
Xdev Host Manager authored a week ago
3579
    });
3580

            
3581
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3582
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3583
      try {
3584
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3585
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
3586
      } catch (e) {
3587
        if (!isAuthLost(e)) msg(e.message);
3588
      }
Xdev Host Manager authored a week ago
3589
    });
3590

            
Bogdan Timofte authored 4 days ago
3591
    refresh().catch(e => {
3592
      if (!isAuthLost(e)) showLogin(e.message);
3593
    });
Xdev Host Manager authored a week ago
3594
  </script>
3595
</body>
3596
</html>
3597
HTML
Bogdan Timofte authored 6 days ago
3598
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3599
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3600
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3601
    return $html;
Xdev Host Manager authored a week ago
3602
}