LocalAuthority / scripts / host_manager.pl
Newer Older
3519 lines | 134.63kb
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
    }
Xdev Host Manager authored a week ago
160
    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
Bogdan Timofte authored 4 days ago
161
        my $registry = load_registry();
162
        return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
Xdev Host Manager authored a week ago
163
    }
164
    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
165
        my $registry = load_registry();
166
        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
167
    }
168
    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
169
        my $registry = load_registry();
170
        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
171
    }
Xdev Host Manager authored a week ago
172
    if ($method eq 'GET' && $path eq '/api/ca/status') {
173
        return send_json_raw($client, 200, ca_manager_json('status-json'));
174
    }
175
    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
176
        return send_json_raw($client, 200, ca_manager_json('list-json'));
177
    }
178
    if ($method eq 'GET' && $path eq '/download/ca.crt') {
179
        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
180
    }
Bogdan Timofte authored 5 days ago
181
    if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
182
        my $name = $1;
183
        return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
184
    }
Xdev Host Manager authored a week ago
185

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

            
212
    return send_json($client, 404, { error => 'not_found' });
213
}
214

            
Bogdan Timofte authored 5 days ago
215
sub app_page_path {
216
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
217
    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
218
}
219

            
Xdev Host Manager authored a week ago
220
sub load_registry {
Bogdan Timofte authored 4 days ago
221
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
222
    normalize_registry_policy($registry);
223
    return $registry;
Xdev Host Manager authored a week ago
224
}
225

            
226
sub save_registry {
227
    my ($registry) = @_;
228
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
229
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
230
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
231
}
232

            
Xdev Host Manager authored a week ago
233
sub load_work_orders {
Bogdan Timofte authored 4 days ago
234
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
235
}
236

            
237
sub save_work_orders {
238
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
239
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
240
}
241

            
242
sub work_orders_payload {
243
    my ($orders) = @_;
244
    my $pending = 0;
245
    for my $wo (@{ $orders->{work_orders} || [] }) {
246
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
247
    }
248
    return {
249
        version => $orders->{version},
250
        work_orders => $orders->{work_orders} || [],
251
        counts => {
252
            work_orders => scalar @{ $orders->{work_orders} || [] },
253
            pending => $pending,
254
        },
255
    };
256
}
257

            
258
sub confirm_work_order {
259
    my ($client, $payload) = @_;
260
    my $id = clean_scalar($payload->{id} || '');
261
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
262
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
263

            
264
    my $orders = load_work_orders();
265
    my $work_order;
266
    for my $wo (@{ $orders->{work_orders} || [] }) {
267
        if (($wo->{id} || '') eq $id) {
268
            $work_order = $wo;
269
            last;
270
        }
271
    }
272
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
273
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
274
    my $incomplete = incomplete_work_order_items($work_order);
275
    return send_json($client, 409, {
276
        error => 'work_order_incomplete',
277
        incomplete => $incomplete,
278
    }) if @$incomplete;
Xdev Host Manager authored a week ago
279

            
280
    my $registry = load_registry();
281
    my $results = apply_work_order($registry, $work_order);
282
    $work_order->{status} = 'confirmed';
283
    $work_order->{confirmed_at} = iso_now();
284
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
285

            
286
    save_registry($registry);
287
    save_work_orders($orders);
288
    backup_file($opt{local_hosts_tsv});
289
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
290

            
291
    return send_json($client, 200, {
292
        ok => json_bool(1),
293
        work_order => $work_order,
294
        results => $results,
295
        local_hosts_tsv => $opt{local_hosts_tsv},
296
    });
297
}
298

            
Xdev Host Manager authored a week ago
299
sub update_work_order_checklist {
300
    my ($client, $payload) = @_;
301
    my $id = clean_scalar($payload->{id} || '');
302
    my $item_id = clean_scalar($payload->{item_id} || '');
303
    my $status = clean_scalar($payload->{status} || '');
304
    my $notes = clean_scalar($payload->{notes} || '');
305
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
306
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
307
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
308

            
309
    my $orders = load_work_orders();
310
    my $work_order;
311
    for my $wo (@{ $orders->{work_orders} || [] }) {
312
        if (($wo->{id} || '') eq $id) {
313
            $work_order = $wo;
314
            last;
315
        }
316
    }
317
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
318
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
319

            
320
    my $item;
321
    for my $candidate (@{ $work_order->{checklist} || [] }) {
322
        if (($candidate->{id} || '') eq $item_id) {
323
            $item = $candidate;
324
            last;
325
        }
326
    }
327
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
328

            
329
    $item->{status} = $status;
330
    $item->{updated_at} = iso_now();
331
    $item->{notes} = $notes if length $notes;
332
    save_work_orders($orders);
333
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
334
}
335

            
336
sub incomplete_work_order_items {
337
    my ($work_order) = @_;
338
    my @incomplete;
339
    for my $item (@{ $work_order->{checklist} || [] }) {
340
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
341
    }
342
    return \@incomplete;
343
}
344

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

            
Xdev Host Manager authored a week ago
374
sub registry_payload {
375
    my ($registry) = @_;
376
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
377
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Xdev Host Manager authored a week ago
378
    return {
379
        version => $registry->{version},
380
        updated_at => $registry->{updated_at},
381
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
382
        hosts => \@hosts,
Xdev Host Manager authored a week ago
383
        problems => $problems,
384
        counts => {
385
            hosts => scalar @{ $registry->{hosts} },
386
            problems => scalar @$problems,
387
        },
388
    };
389
}
390

            
391
sub upsert_host {
392
    my ($client, $payload) = @_;
393
    my $id = clean_id($payload->{id} || '');
394
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
395

            
396
    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
397
    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
398
    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
399

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

            
403
    my $registry = load_registry();
404
    my %host = (
405
        id => $id,
406
        status => clean_scalar($payload->{status} || 'active'),
407
        hosts_ip => $hosts_ip,
408
        dns_ip => $dns_ip,
409
        names => \@names,
410
        roles => [ clean_list($payload->{roles}) ],
411
        sources => [ clean_list($payload->{sources}) ],
412
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
413
        notes => clean_scalar($payload->{notes} || ''),
414
    );
415

            
416
    my $replaced = 0;
417
    for my $i (0 .. $#{ $registry->{hosts} }) {
418
        if ($registry->{hosts}->[$i]{id} eq $id) {
419
            $registry->{hosts}->[$i] = \%host;
420
            $replaced = 1;
421
            last;
422
        }
423
    }
424
    push @{ $registry->{hosts} }, \%host unless $replaced;
425
    save_registry($registry);
426
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
427
}
428

            
429
sub delete_host {
430
    my ($client, $id) = @_;
431
    $id = clean_id($id);
432
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
433

            
434
    my $registry = load_registry();
435
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
436
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
437
    $registry->{hosts} = \@kept;
438
    save_registry($registry);
439
    return send_json($client, 200, { ok => json_bool(1) });
440
}
441

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

            
Xdev Host Manager authored a week ago
469
sub host_payload {
470
    my ($host) = @_;
471
    my %copy = %$host;
472
    $copy{names} = [ effective_names($host) ];
473
    $copy{declared_names} = [ @{ $host->{names} || [] } ];
474
    $copy{derived_names} = [ derived_names($host) ];
475
    return \%copy;
476
}
477

            
478
sub effective_names {
479
    my ($host) = @_;
480
    my @names = @{ $host->{names} || [] };
481
    push @names, derived_names($host);
482
    return unique_preserve(@names);
483
}
484

            
485
sub derived_names {
486
    my ($host) = @_;
487
    my @derived;
488
    for my $name (@{ $host->{names} || [] }) {
489
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
490
        push @derived, $1 if length $1;
491
    }
492
    return unique_preserve(@derived);
493
}
494

            
495
sub remove_derived_names {
496
    my @names = @_;
497
    my %derived;
498
    for my $name (@names) {
499
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
500
        $derived{$1} = 1;
501
    }
502
    return grep { !$derived{$_} } @names;
503
}
504

            
505
sub unique_preserve {
506
    my @values = @_;
507
    my %seen;
508
    return grep { !$seen{$_}++ } @values;
509
}
510

            
Xdev Host Manager authored a week ago
511
sub problem {
512
    my ($host, $code, $message) = @_;
513
    return { host_id => $host->{id}, code => $code, message => $message };
514
}
515

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

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

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

            
588
sub debug_database_table_payload {
589
    my ($table, $limit) = @_;
590
    my $dbh = dbh();
591
    $table = clean_scalar($table);
592
    return { error => 'missing_table' } unless length $table;
593
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
594
    $limit = int($limit || 100);
595
    $limit = 1 if $limit < 1;
596
    $limit = 500 if $limit > 500;
597

            
598
    my $quoted = $dbh->quote_identifier($table);
599
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
600
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
601
    my @index_details;
602
    for my $index (@$indexes) {
603
        my $index_name = $index->{name} || '';
604
        next unless length $index_name;
605
        my $quoted_index = $dbh->quote_identifier($index_name);
606
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
607
        push @index_details, {
608
            name => $index_name,
609
            unique => int($index->{unique} || 0),
610
            origin => $index->{origin} || '',
611
            partial => int($index->{partial} || 0),
612
            columns => [ map { $_->{name} || '' } @$index_columns ],
613
        };
614
    }
615
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
616
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
617
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
618

            
619
    return {
620
        database => $opt{db},
621
        table => $table,
622
        generated_at => iso_now(),
623
        limit => $limit,
624
        row_count => int($row_count || 0),
625
        columns => $columns,
626
        indexes => \@index_details,
627
        foreign_keys => $foreign_keys,
628
        rows => $rows,
629
    };
630
}
631

            
632
sub debug_table_exists {
633
    my ($dbh, $table) = @_;
634
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
635
    my ($exists) = $dbh->selectrow_array(
636
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
637
        undef,
638
        $table,
639
    );
640
    return $exists ? 1 : 0;
641
}
642

            
643
sub sum {
644
    my $total = 0;
645
    $total += $_ || 0 for @_;
646
    return $total;
647
}
648

            
Xdev Host Manager authored a week ago
649
sub ca_script_path {
650
    return "$project_dir/scripts/ca_manager.sh";
651
}
652

            
653
sub ca_dir {
654
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
655
}
656

            
657
sub ca_cert_path {
658
    return ca_dir() . "/certs/ca.cert.pem";
659
}
660

            
Bogdan Timofte authored 5 days ago
661
sub ca_issued_cert_path {
662
    my ($name) = @_;
663
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
664
    return ca_dir() . "/issued/$name.cert.pem";
665
}
666

            
Xdev Host Manager authored a week ago
667
sub ca_manager_json {
668
    my ($command) = @_;
669
    my $script = ca_script_path();
670
    die "CA manager script is missing\n" unless -x $script;
671
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
672
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
673
    local $/;
674
    my $out = <$fh>;
675
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
676
    $out ||= $command eq 'list-json' ? '[]' : '{}';
677
    sync_certificates_from_json($out) if $command eq 'list-json';
678
    return $out;
679
}
680

            
681
sub sync_certificates_from_json {
682
    my ($json) = @_;
683
    my $certs = eval { json_decode($json || '[]') };
684
    return if $@ || ref($certs) ne 'ARRAY';
685
    my $dbh = dbh();
686
    my $now = iso_now();
687
    with_transaction($dbh, sub {
688
        for my $cert (@$certs) {
689
            next unless ref($cert) eq 'HASH';
690
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
691
            next unless $name;
692
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
693
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
694
            my $cert_path = ca_issued_cert_path($name);
695
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
696
            my $serial = clean_scalar($cert->{serial} || '');
697
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
698
            $dbh->do(
699
                '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) '
700
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
701
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
702
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
703
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
704
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
705
                undef,
706
                $name,
707
                $host_fqdn || undef,
708
                $dns_names[0] || '',
709
                clean_scalar($cert->{subject} || ''),
710
                clean_scalar($cert->{issuer} || ''),
711
                length($serial) ? $serial : undef,
712
                clean_scalar($cert->{not_before} || ''),
713
                clean_scalar($cert->{not_after} || ''),
714
                length($fingerprint) ? $fingerprint : undef,
715
                $cert_path,
716
                $csr_path,
717
                $now,
718
                $now,
719
            );
720
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
721
            for my $dns_name (@dns_names) {
722
                next unless length $dns_name;
723
                $dbh->do(
724
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
725
                    undef,
726
                    $name,
727
                    $dns_name,
728
                );
729
            }
730
        }
731
    });
732
}
733

            
734
sub infer_certificate_host_fqdn {
735
    my ($dbh, $dns_names) = @_;
736
    for my $name (@$dns_names) {
737
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
738
        return $fqdn if $fqdn;
739
    }
740
    for my $name (@$dns_names) {
741
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
742
        return $fqdn if $fqdn;
743
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
744
        return $fqdn if $fqdn;
745
    }
746
    return '';
Xdev Host Manager authored a week ago
747
}
748

            
Xdev Host Manager authored a week ago
749
sub parse_hosts_yaml {
750
    my ($text) = @_;
751
    my %registry = (
752
        version => 1,
753
        updated_at => '',
754
        policy => {},
755
        hosts => [],
756
    );
757
    my ($section, $current, $list_key);
758
    for my $line (split /\n/, $text) {
759
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
760
        if ($line =~ /^version:\s*(\d+)/) {
761
            $registry{version} = int($1);
762
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
763
            $registry{updated_at} = yaml_unquote($1);
764
        } elsif ($line =~ /^policy:\s*$/) {
765
            $section = 'policy';
766
        } elsif ($line =~ /^hosts:\s*$/) {
767
            $section = 'hosts';
768
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
769
            $registry{policy}{$1} = yaml_unquote($2);
770
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
771
            $current = {
772
                id => yaml_unquote($1),
773
                status => 'active',
774
                hosts_ip => '',
775
                dns_ip => '',
776
                names => [],
777
                roles => [],
778
                sources => [],
779
                monitoring => 'pending',
780
                notes => '',
781
            };
782
            push @{ $registry{hosts} }, $current;
783
            $list_key = undef;
784
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
785
            $list_key = $1;
786
            $current->{$list_key} ||= [];
787
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
788
            push @{ $current->{$list_key} }, yaml_unquote($1);
789
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
790
            $current->{$1} = yaml_unquote($2);
791
            $list_key = undef;
792
        }
793
    }
794
    return \%registry;
795
}
796

            
797
sub render_hosts_yaml {
798
    my ($registry) = @_;
799
    my $out = "version: " . int($registry->{version} || 1) . "\n";
800
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
801
    $out .= "policy:\n";
802
    for my $key (sort keys %{ $registry->{policy} || {} }) {
803
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
804
    }
805
    $out .= "hosts:\n";
806
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
807
        $out .= "  - id: " . yq($host->{id}) . "\n";
808
        for my $key (qw(status hosts_ip dns_ip)) {
809
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
810
        }
811
        for my $key (qw(names roles sources)) {
812
            $out .= "    $key:\n";
813
            for my $value (@{ $host->{$key} || [] }) {
814
                $out .= "      - " . yq($value) . "\n";
815
            }
816
        }
817
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
818
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
819
    }
820
    return $out;
821
}
822

            
Xdev Host Manager authored a week ago
823
sub parse_work_orders_yaml {
824
    my ($text) = @_;
825
    my %orders = (
826
        version => 1,
827
        work_orders => [],
828
    );
Xdev Host Manager authored a week ago
829
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
830
    for my $line (split /\n/, $text) {
831
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
832
        if ($line =~ /^version:\s*(\d+)/) {
833
            $orders{version} = int($1);
834
        } elsif ($line =~ /^work_orders:\s*$/) {
835
            $section = 'work_orders';
836
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
837
            $current = {
838
                id => yaml_unquote($1),
839
                status => 'pending',
Xdev Host Manager authored a week ago
840
                checklist => [],
Xdev Host Manager authored a week ago
841
                actions => [],
842
            };
843
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
844
            $list_section = '';
Xdev Host Manager authored a week ago
845
            $current_action = undef;
Xdev Host Manager authored a week ago
846
            $current_item = undef;
847
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
848
            $list_section = 'checklist';
849
            $current->{checklist} ||= [];
850
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
851
            $current_item = { id => yaml_unquote($1), status => 'pending' };
852
            push @{ $current->{checklist} }, $current_item;
853
            $current_action = undef;
854
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
855
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
856
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
857
            $list_section = 'actions';
Xdev Host Manager authored a week ago
858
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
859
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
860
            $current_action = { type => yaml_unquote($1) };
861
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
862
            $current_item = undef;
863
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
864
            $current_action->{$1} = yaml_unquote($2);
865
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
866
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
867
            $list_section = '';
Xdev Host Manager authored a week ago
868
            $current_action = undef;
Xdev Host Manager authored a week ago
869
            $current_item = undef;
Xdev Host Manager authored a week ago
870
        }
871
    }
872
    return \%orders;
873
}
874

            
875
sub render_work_orders_yaml {
876
    my ($orders) = @_;
877
    my $out = "version: " . int($orders->{version} || 1) . "\n";
878
    $out .= "work_orders:\n";
879
    for my $wo (@{ $orders->{work_orders} || [] }) {
880
        $out .= "  - id: " . yq($wo->{id}) . "\n";
881
        for my $key (qw(status title reason created_at confirmed_at result)) {
882
            next unless exists $wo->{$key} && length($wo->{$key} || '');
883
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
884
        }
Xdev Host Manager authored a week ago
885
        $out .= "    checklist:\n";
886
        for my $item (@{ $wo->{checklist} || [] }) {
887
            $out .= "      - id: " . yq($item->{id}) . "\n";
888
            for my $key (qw(text status owner notes updated_at)) {
889
                next unless exists $item->{$key} && length($item->{$key} || '');
890
                $out .= "        $key: " . yq($item->{$key}) . "\n";
891
            }
892
        }
Xdev Host Manager authored a week ago
893
        $out .= "    actions:\n";
894
        for my $action (@{ $wo->{actions} || [] }) {
895
            $out .= "      - type: " . yq($action->{type}) . "\n";
896
            for my $key (qw(host_id name)) {
897
                next unless exists $action->{$key} && length($action->{$key} || '');
898
                $out .= "        $key: " . yq($action->{$key}) . "\n";
899
            }
900
        }
901
    }
902
    return $out;
903
}
904

            
Xdev Host Manager authored a week ago
905
sub request_payload {
906
    my ($headers, $body) = @_;
907
    my $type = $headers->{'content-type'} || '';
908
    if ($type =~ m{application/json}) {
909
        return json_decode($body || '{}');
910
    }
911
    return { parse_params($body || '') };
912
}
913

            
914
sub json_bool {
915
    my ($value) = @_;
916
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
917
}
918

            
919
sub json_encode {
920
    my ($value) = @_;
921
    if (!defined $value) {
922
        return 'null';
923
    }
924
    my $ref = ref($value);
925
    if (!$ref) {
926
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
927
        return json_string($value);
928
    }
929
    if ($ref eq 'HostManager::JSONBool') {
930
        return $$value ? 'true' : 'false';
931
    }
932
    if ($ref eq 'ARRAY') {
933
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
934
    }
935
    if ($ref eq 'HASH') {
936
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
937
    }
938
    return json_string("$value");
939
}
940

            
941
sub json_string {
942
    my ($value) = @_;
943
    $value = '' unless defined $value;
944
    $value =~ s/\\/\\\\/g;
945
    $value =~ s/"/\\"/g;
946
    $value =~ s/\n/\\n/g;
947
    $value =~ s/\r/\\r/g;
948
    $value =~ s/\t/\\t/g;
949
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
950
    return qq("$value");
951
}
952

            
953
sub json_decode {
954
    my ($text) = @_;
955
    my $i = 0;
956
    my $len = length($text);
957
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
958

            
959
    $skip_ws = sub {
960
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
961
    };
962

            
963
    $parse_string = sub {
964
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
965
        $i++;
966
        my $out = '';
967
        while ($i < $len) {
968
            my $ch = substr($text, $i++, 1);
969
            return $out if $ch eq '"';
970
            if ($ch eq "\\") {
971
                die "Bad JSON escape\n" if $i >= $len;
972
                my $esc = substr($text, $i++, 1);
973
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
974
                    $out .= $esc;
975
                } elsif ($esc eq 'b') {
976
                    $out .= "\b";
977
                } elsif ($esc eq 'f') {
978
                    $out .= "\f";
979
                } elsif ($esc eq 'n') {
980
                    $out .= "\n";
981
                } elsif ($esc eq 'r') {
982
                    $out .= "\r";
983
                } elsif ($esc eq 't') {
984
                    $out .= "\t";
985
                } elsif ($esc eq 'u') {
986
                    my $hex = substr($text, $i, 4);
987
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
988
                    $out .= chr(hex($hex));
989
                    $i += 4;
990
                } else {
991
                    die "Bad JSON escape\n";
992
                }
993
            } else {
994
                $out .= $ch;
995
            }
996
        }
997
        die "Unterminated JSON string\n";
998
    };
999

            
1000
    $parse_number = sub {
1001
        my $start = $i;
1002
        $i++ if substr($text, $i, 1) eq '-';
1003
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1004
        if ($i < $len && substr($text, $i, 1) eq '.') {
1005
            $i++;
1006
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1007
        }
1008
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1009
            $i++;
1010
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1011
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1012
        }
1013
        return 0 + substr($text, $start, $i - $start);
1014
    };
1015

            
1016
    $parse_array = sub {
1017
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1018
        $i++;
1019
        my @out;
1020
        $skip_ws->();
1021
        if ($i < $len && substr($text, $i, 1) eq ']') {
1022
            $i++;
1023
            return \@out;
1024
        }
1025
        while (1) {
1026
            push @out, $parse_value->();
1027
            $skip_ws->();
1028
            my $ch = substr($text, $i++, 1);
1029
            last if $ch eq ']';
1030
            die "Expected JSON array comma\n" unless $ch eq ',';
1031
        }
1032
        return \@out;
1033
    };
1034

            
1035
    $parse_object = sub {
1036
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1037
        $i++;
1038
        my %out;
1039
        $skip_ws->();
1040
        if ($i < $len && substr($text, $i, 1) eq '}') {
1041
            $i++;
1042
            return \%out;
1043
        }
1044
        while (1) {
1045
            $skip_ws->();
1046
            my $key = $parse_string->();
1047
            $skip_ws->();
1048
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1049
            $out{$key} = $parse_value->();
1050
            $skip_ws->();
1051
            my $ch = substr($text, $i++, 1);
1052
            last if $ch eq '}';
1053
            die "Expected JSON object comma\n" unless $ch eq ',';
1054
        }
1055
        return \%out;
1056
    };
1057

            
1058
    $parse_value = sub {
1059
        $skip_ws->();
1060
        die "Unexpected end of JSON\n" if $i >= $len;
1061
        my $ch = substr($text, $i, 1);
1062
        return $parse_string->() if $ch eq '"';
1063
        return $parse_object->() if $ch eq '{';
1064
        return $parse_array->() if $ch eq '[';
1065
        if (substr($text, $i, 4) eq 'true') {
1066
            $i += 4;
1067
            return json_bool(1);
1068
        }
1069
        if (substr($text, $i, 5) eq 'false') {
1070
            $i += 5;
1071
            return json_bool(0);
1072
        }
1073
        if (substr($text, $i, 4) eq 'null') {
1074
            $i += 4;
1075
            return undef;
1076
        }
1077
        return $parse_number->() if $ch =~ /[-0-9]/;
1078
        die "Unexpected JSON token\n";
1079
    };
1080

            
1081
    my $value = $parse_value->();
1082
    $skip_ws->();
1083
    die "Trailing JSON content\n" if $i != $len;
1084
    return $value;
1085
}
1086

            
1087
sub parse_params {
1088
    my ($text) = @_;
1089
    my %out;
1090
    for my $pair (split /&/, $text) {
1091
        next unless length $pair;
1092
        my ($k, $v) = split /=/, $pair, 2;
1093
        $out{url_decode($k)} = url_decode($v || '');
1094
    }
1095
    return %out;
1096
}
1097

            
1098
sub clean_id {
1099
    my ($value) = @_;
1100
    $value = lc clean_scalar($value);
1101
    $value =~ s/[^a-z0-9_.-]+/-/g;
1102
    $value =~ s/^-+|-+$//g;
1103
    return $value;
1104
}
1105

            
1106
sub clean_scalar {
1107
    my ($value) = @_;
1108
    $value = '' unless defined $value;
1109
    $value =~ s/[\r\n\t]+/ /g;
1110
    $value =~ s/^\s+|\s+$//g;
1111
    return $value;
1112
}
1113

            
1114
sub clean_list {
1115
    my ($value) = @_;
1116
    return () unless defined $value;
1117
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1118
    my @clean;
1119
    for my $item (@items) {
1120
        $item = clean_scalar($item);
1121
        push @clean, $item if length $item;
1122
    }
1123
    return @clean;
1124
}
1125

            
1126
sub yq {
1127
    my ($value) = @_;
1128
    $value = '' unless defined $value;
1129
    $value =~ s/\\/\\\\/g;
1130
    $value =~ s/"/\\"/g;
1131
    return qq("$value");
1132
}
1133

            
1134
sub yaml_unquote {
1135
    my ($value) = @_;
1136
    $value = '' unless defined $value;
1137
    $value =~ s/^\s+|\s+$//g;
1138
    if ($value =~ /^"(.*)"$/) {
1139
        $value = $1;
1140
        $value =~ s/\\"/"/g;
1141
        $value =~ s/\\\\/\\/g;
1142
    }
1143
    return $value;
1144
}
1145

            
1146
sub verify_totp {
1147
    my ($secret, $otp) = @_;
1148
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1149
    my $key = eval { base32_decode($secret) };
1150
    return 0 if $@ || !length $key;
1151
    my $counter = int(time() / 30);
1152
    for my $offset (-1, 0, 1) {
1153
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1154
    }
1155
    return 0;
1156
}
1157

            
1158
sub totp_code {
1159
    my ($key, $counter) = @_;
1160
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1161
    my $hash = hmac_sha1($msg, $key);
1162
    my $offset = ord(substr($hash, -1)) & 0x0f;
1163
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1164
    return sprintf('%06d', $bin % 1_000_000);
1165
}
1166

            
1167
sub base32_decode {
1168
    my ($text) = @_;
1169
    $text = uc($text || '');
1170
    $text =~ s/[^A-Z2-7]//g;
1171
    my %map;
1172
    my @chars = ('A'..'Z', '2'..'7');
1173
    @map{@chars} = (0..31);
1174
    my ($bits, $value, $out) = (0, 0, '');
1175
    for my $char (split //, $text) {
1176
        die "Invalid base32\n" unless exists $map{$char};
1177
        $value = ($value << 5) | $map{$char};
1178
        $bits += 5;
1179
        while ($bits >= 8) {
1180
            $bits -= 8;
1181
            $out .= chr(($value >> $bits) & 0xff);
1182
        }
1183
    }
1184
    return $out;
1185
}
1186

            
1187
sub create_session {
1188
    my $nonce = random_hex(24);
1189
    my $expires = int(time() + 8 * 3600);
1190
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1191
    my $token = "$nonce:$expires:$sig";
1192
    $sessions{$token} = $expires;
1193
    return $token;
1194
}
1195

            
1196
sub is_authenticated {
1197
    my ($headers) = @_;
1198
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1199
    return 0 unless $token;
1200
    my ($nonce, $expires, $sig) = split /:/, $token;
1201
    return 0 unless $nonce && $expires && $sig;
1202
    return 0 if $expires < time();
1203
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1204
    return exists $sessions{$token};
1205
}
1206

            
1207
sub expire_session {
1208
    my ($headers) = @_;
1209
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1210
    delete $sessions{$token} if $token;
1211
}
1212

            
1213
sub cookie_value {
1214
    my ($cookie, $name) = @_;
1215
    for my $part (split /;\s*/, $cookie) {
1216
        my ($k, $v) = split /=/, $part, 2;
1217
        return $v if defined $k && $k eq $name;
1218
    }
1219
    return '';
1220
}
1221

            
1222
sub send_json {
1223
    my ($client, $status, $payload, $extra_headers) = @_;
1224
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1225
}
1226

            
Xdev Host Manager authored a week ago
1227
sub send_json_raw {
1228
    my ($client, $status, $json_body, $extra_headers) = @_;
1229
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1230
}
1231

            
Xdev Host Manager authored a week ago
1232
sub send_html {
1233
    my ($client, $status, $html) = @_;
1234
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1235
}
1236

            
1237
sub send_text {
1238
    my ($client, $status, $text) = @_;
1239
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1240
}
1241

            
1242
sub send_download {
1243
    my ($client, $status, $content, $type, $filename) = @_;
1244
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1245
}
1246

            
1247
sub send_file {
1248
    my ($client, $path, $type, $filename) = @_;
1249
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1250
    return send_download($client, 200, read_file($path), $type, $filename);
1251
}
1252

            
1253
sub send_response {
1254
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1255
    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
1256
    $body = '' unless defined $body;
1257
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1258
    print $client "Content-Type: $type\r\n";
1259
    print $client "Content-Length: " . length($body) . "\r\n";
1260
    print $client "Cache-Control: no-store\r\n";
1261
    print $client "$_\r\n" for @{ $extra_headers || [] };
1262
    print $client "Connection: close\r\n\r\n";
1263
    print $client $body;
1264
}
1265

            
1266
sub read_file {
1267
    my ($path) = @_;
1268
    open my $fh, '<', $path or die "Cannot read $path: $!";
1269
    local $/;
1270
    return <$fh>;
1271
}
1272

            
1273
sub write_file {
1274
    my ($path, $content) = @_;
1275
    open my $fh, '>', $path or die "Cannot write $path: $!";
1276
    print {$fh} $content;
1277
    close $fh or die "Cannot close $path: $!";
1278
}
1279

            
1280
sub backup_file {
1281
    my ($path) = @_;
1282
    return unless -f $path;
1283
    my $backup_dir = "$project_dir/backups/host-manager";
1284
    make_path($backup_dir) unless -d $backup_dir;
1285
    my $name = $path;
1286
    $name =~ s{.*/}{};
1287
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1288
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1289
}
1290

            
Bogdan Timofte authored 4 days ago
1291
my $db_handle;
Bogdan Timofte authored 4 days ago
1292
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1293

            
1294
sub dbh {
1295
    return $db_handle if $db_handle;
1296
    ensure_parent_dir($opt{db});
1297
    $db_handle = DBI->connect(
1298
        "dbi:SQLite:dbname=$opt{db}",
1299
        '',
1300
        '',
1301
        {
1302
            RaiseError => 1,
1303
            PrintError => 0,
1304
            AutoCommit => 1,
1305
            sqlite_unicode => 1,
1306
        },
1307
    ) or die "Cannot open SQLite database $opt{db}\n";
1308
    $db_handle->do('PRAGMA journal_mode = WAL');
1309
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1310
    create_database_schema($db_handle);
1311
    seed_database($db_handle) unless $db_seeded++;
1312
    return $db_handle;
1313
}
1314

            
1315
sub create_database_schema {
1316
    my ($dbh) = @_;
1317
    $dbh->do(<<'SQL');
1318
CREATE TABLE IF NOT EXISTS schema_meta (
1319
    key TEXT PRIMARY KEY,
1320
    value TEXT NOT NULL,
1321
    updated_at TEXT NOT NULL
1322
)
1323
SQL
1324
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1325
CREATE TABLE IF NOT EXISTS documents (
1326
    name TEXT PRIMARY KEY,
1327
    content TEXT NOT NULL,
1328
    updated_at TEXT NOT NULL
1329
)
1330
SQL
Bogdan Timofte authored 4 days ago
1331
    $dbh->do(
1332
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1333
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1334
        undef, 'schema_version', '2', iso_now()
1335
    );
1336
    $dbh->do(<<'SQL');
1337
CREATE TABLE IF NOT EXISTS hosts (
1338
    fqdn TEXT PRIMARY KEY,
1339
    legacy_id TEXT NOT NULL UNIQUE,
1340
    status TEXT NOT NULL DEFAULT 'active',
1341
    hosts_ip TEXT NOT NULL DEFAULT '',
1342
    dns_ip TEXT NOT NULL DEFAULT '',
1343
    monitoring TEXT NOT NULL DEFAULT 'pending',
1344
    notes TEXT NOT NULL DEFAULT '',
1345
    created_at TEXT NOT NULL,
1346
    updated_at TEXT NOT NULL
1347
)
1348
SQL
1349
    $dbh->do(<<'SQL');
1350
CREATE TABLE IF NOT EXISTS host_aliases (
1351
    alias_name TEXT NOT NULL,
1352
    host_fqdn TEXT NOT NULL,
1353
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1354
    status TEXT NOT NULL DEFAULT 'active',
1355
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1356
    created_at TEXT NOT NULL,
1357
    retired_at TEXT,
1358
    notes TEXT NOT NULL DEFAULT '',
1359
    PRIMARY KEY (alias_name, host_fqdn),
1360
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1361
)
1362
SQL
1363
    $dbh->do(<<'SQL');
1364
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1365
ON host_aliases(alias_name)
1366
WHERE status = 'active'
1367
SQL
1368
    $dbh->do(<<'SQL');
1369
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1370
ON host_aliases(host_fqdn, status)
1371
SQL
1372
    $dbh->do(<<'SQL');
1373
CREATE TABLE IF NOT EXISTS host_roles (
1374
    host_fqdn TEXT NOT NULL,
1375
    role TEXT NOT NULL,
1376
    status TEXT NOT NULL DEFAULT 'active',
1377
    created_at TEXT NOT NULL,
1378
    retired_at TEXT,
1379
    PRIMARY KEY (host_fqdn, role),
1380
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1381
)
1382
SQL
1383
    $dbh->do(<<'SQL');
1384
CREATE TABLE IF NOT EXISTS host_sources (
1385
    host_fqdn TEXT NOT NULL,
1386
    source TEXT NOT NULL,
1387
    status TEXT NOT NULL DEFAULT 'active',
1388
    created_at TEXT NOT NULL,
1389
    retired_at TEXT,
1390
    PRIMARY KEY (host_fqdn, source),
1391
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1392
)
1393
SQL
1394
    $dbh->do(<<'SQL');
1395
CREATE TABLE IF NOT EXISTS host_flags (
1396
    host_fqdn TEXT NOT NULL,
1397
    flag TEXT NOT NULL,
1398
    value TEXT NOT NULL DEFAULT '1',
1399
    created_at TEXT NOT NULL,
1400
    updated_at TEXT NOT NULL,
1401
    PRIMARY KEY (host_fqdn, flag),
1402
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1403
)
1404
SQL
1405
    $dbh->do(<<'SQL');
1406
CREATE TABLE IF NOT EXISTS host_ssh (
1407
    host_fqdn TEXT NOT NULL,
1408
    profile_name TEXT NOT NULL DEFAULT 'default',
1409
    username TEXT NOT NULL DEFAULT '',
1410
    port INTEGER NOT NULL DEFAULT 22,
1411
    identity_file TEXT NOT NULL DEFAULT '',
1412
    address TEXT NOT NULL DEFAULT '',
1413
    local_forward_host TEXT NOT NULL DEFAULT '',
1414
    local_forward_port INTEGER,
1415
    remote_forward_host TEXT NOT NULL DEFAULT '',
1416
    remote_forward_port INTEGER,
1417
    notes TEXT NOT NULL DEFAULT '',
1418
    created_at TEXT NOT NULL,
1419
    updated_at TEXT NOT NULL,
1420
    PRIMARY KEY (host_fqdn, profile_name),
1421
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1422
)
1423
SQL
1424
    $dbh->do(<<'SQL');
1425
CREATE TABLE IF NOT EXISTS certificates (
1426
    certificate_id TEXT PRIMARY KEY,
1427
    host_fqdn TEXT,
1428
    common_name TEXT NOT NULL DEFAULT '',
1429
    subject TEXT NOT NULL DEFAULT '',
1430
    issuer TEXT NOT NULL DEFAULT '',
1431
    serial TEXT UNIQUE,
1432
    status TEXT NOT NULL DEFAULT 'issued',
1433
    not_before TEXT NOT NULL DEFAULT '',
1434
    not_after TEXT NOT NULL DEFAULT '',
1435
    fingerprint_sha256 TEXT UNIQUE,
1436
    cert_path TEXT NOT NULL DEFAULT '',
1437
    csr_path TEXT NOT NULL DEFAULT '',
1438
    created_at TEXT NOT NULL,
1439
    updated_at TEXT NOT NULL,
1440
    notes TEXT NOT NULL DEFAULT '',
1441
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1442
)
1443
SQL
1444
    $dbh->do(<<'SQL');
1445
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1446
    certificate_id TEXT NOT NULL,
1447
    dns_name TEXT NOT NULL,
1448
    PRIMARY KEY (certificate_id, dns_name),
1449
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1450
)
1451
SQL
1452
    $dbh->do(<<'SQL');
1453
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1454
ON certificate_dns_names(dns_name)
1455
SQL
1456
    $dbh->do(<<'SQL');
1457
CREATE TABLE IF NOT EXISTS vhosts (
1458
    vhost_fqdn TEXT PRIMARY KEY,
1459
    host_fqdn TEXT NOT NULL,
1460
    status TEXT NOT NULL DEFAULT 'active',
1461
    service_name TEXT NOT NULL DEFAULT '',
1462
    upstream_url TEXT NOT NULL DEFAULT '',
1463
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1464
    certificate_id TEXT,
1465
    notes TEXT NOT NULL DEFAULT '',
1466
    created_at TEXT NOT NULL,
1467
    updated_at TEXT NOT NULL,
1468
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1469
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1470
)
1471
SQL
1472
    $dbh->do(<<'SQL');
1473
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1474
ON vhosts(host_fqdn, status)
1475
SQL
1476
    $dbh->do(<<'SQL');
1477
CREATE TABLE IF NOT EXISTS data_workers (
1478
    worker_id TEXT PRIMARY KEY,
1479
    worker_type TEXT NOT NULL,
1480
    name TEXT NOT NULL DEFAULT '',
1481
    status TEXT NOT NULL DEFAULT 'active',
1482
    source TEXT NOT NULL DEFAULT '',
1483
    last_run_at TEXT,
1484
    notes TEXT NOT NULL DEFAULT '',
1485
    created_at TEXT NOT NULL,
1486
    updated_at TEXT NOT NULL
1487
)
1488
SQL
1489
    $dbh->do(<<'SQL');
1490
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1491
ON data_workers(worker_type, status)
1492
SQL
1493
    $dbh->do(<<'SQL');
1494
CREATE TABLE IF NOT EXISTS dhcp_leases (
1495
    lease_key TEXT PRIMARY KEY,
1496
    worker_id TEXT NOT NULL,
1497
    host_fqdn TEXT,
1498
    observed_name TEXT NOT NULL DEFAULT '',
1499
    ip_address TEXT NOT NULL,
1500
    mac_address TEXT NOT NULL DEFAULT '',
1501
    lease_state TEXT NOT NULL DEFAULT '',
1502
    first_seen TEXT NOT NULL,
1503
    last_seen TEXT NOT NULL,
1504
    raw TEXT NOT NULL DEFAULT '',
1505
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1506
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1507
)
1508
SQL
1509
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1510
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1511
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1512
    $dbh->do(<<'SQL');
1513
CREATE TABLE IF NOT EXISTS mdns_observations (
1514
    observation_key TEXT PRIMARY KEY,
1515
    worker_id TEXT NOT NULL,
1516
    host_fqdn TEXT,
1517
    observed_name TEXT NOT NULL,
1518
    ip_address TEXT NOT NULL,
1519
    rr_type TEXT NOT NULL DEFAULT 'A',
1520
    ttl INTEGER NOT NULL DEFAULT 0,
1521
    first_seen TEXT NOT NULL,
1522
    last_seen TEXT NOT NULL,
1523
    seen_count INTEGER NOT NULL DEFAULT 1,
1524
    last_peer TEXT NOT NULL DEFAULT '',
1525
    raw TEXT NOT NULL DEFAULT '',
1526
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1527
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1528
)
1529
SQL
1530
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1531
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1532
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1533
    $dbh->do(<<'SQL');
1534
CREATE TABLE IF NOT EXISTS work_orders (
1535
    id TEXT PRIMARY KEY,
1536
    status TEXT NOT NULL DEFAULT 'pending',
1537
    title TEXT NOT NULL DEFAULT '',
1538
    reason TEXT NOT NULL DEFAULT '',
1539
    created_at TEXT NOT NULL,
1540
    confirmed_at TEXT NOT NULL DEFAULT '',
1541
    result TEXT NOT NULL DEFAULT '',
1542
    updated_at TEXT NOT NULL
1543
)
1544
SQL
1545
    $dbh->do(<<'SQL');
1546
CREATE TABLE IF NOT EXISTS work_order_checklist (
1547
    work_order_id TEXT NOT NULL,
1548
    item_id TEXT NOT NULL,
1549
    text TEXT NOT NULL DEFAULT '',
1550
    status TEXT NOT NULL DEFAULT 'pending',
1551
    owner TEXT NOT NULL DEFAULT '',
1552
    notes TEXT NOT NULL DEFAULT '',
1553
    updated_at TEXT NOT NULL DEFAULT '',
1554
    PRIMARY KEY (work_order_id, item_id),
1555
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1556
)
1557
SQL
1558
    $dbh->do(<<'SQL');
1559
CREATE TABLE IF NOT EXISTS work_order_actions (
1560
    work_order_id TEXT NOT NULL,
1561
    position INTEGER NOT NULL,
1562
    type TEXT NOT NULL,
1563
    host_fqdn TEXT,
1564
    host_legacy_id TEXT NOT NULL DEFAULT '',
1565
    name TEXT NOT NULL DEFAULT '',
1566
    payload TEXT NOT NULL DEFAULT '',
1567
    PRIMARY KEY (work_order_id, position),
1568
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1569
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1570
)
1571
SQL
Bogdan Timofte authored 4 days ago
1572
}
1573

            
Bogdan Timofte authored 4 days ago
1574
sub seed_database {
1575
    my ($dbh) = @_;
1576
    seed_default_workers($dbh);
1577

            
1578
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1579
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1580
        normalize_registry_policy($registry);
1581
        with_transaction($dbh, sub {
1582
            import_registry_to_db($dbh, $registry, 0);
1583
        });
1584
    }
1585

            
1586
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1587
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1588
        with_transaction($dbh, sub {
1589
            import_work_orders_to_db($dbh, $orders);
1590
        });
1591
    }
1592

            
1593
    seed_mdns_observations_from_yaml($dbh);
1594
}
1595

            
1596
sub with_transaction {
1597
    my ($dbh, $code) = @_;
1598
    return $code->() unless $dbh->{AutoCommit};
1599
    $dbh->begin_work;
1600
    my $ok = eval {
1601
        $code->();
1602
        1;
1603
    };
1604
    if (!$ok) {
1605
        my $err = $@ || 'transaction failed';
1606
        eval { $dbh->rollback };
1607
        die $err;
1608
    }
1609
    $dbh->commit;
1610
}
1611

            
1612
sub db_scalar {
1613
    my ($dbh, $sql, @bind) = @_;
1614
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1615
    return $value || 0;
1616
}
1617

            
1618
sub legacy_document_text {
1619
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1620
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1621
    return $row->{content} if $row && defined $row->{content};
1622
    return read_file($seed_path) if -f $seed_path;
1623
    return $default_text;
1624
}
1625

            
1626
sub load_registry_from_db {
1627
    my $dbh = dbh();
1628
    my $registry = {
1629
        version => 1,
1630
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1631
        policy => {},
1632
        hosts => [],
1633
    };
Bogdan Timofte authored 4 days ago
1634

            
Bogdan Timofte authored 4 days ago
1635
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1636
    $sth->execute;
1637
    while (my $row = $sth->fetchrow_hashref) {
1638
        my $fqdn = $row->{fqdn};
1639
        push @{ $registry->{hosts} }, {
1640
            id => $row->{legacy_id},
1641
            status => $row->{status},
1642
            hosts_ip => $row->{hosts_ip},
1643
            dns_ip => $row->{dns_ip},
1644
            names => [ active_names_for_host($dbh, $fqdn) ],
1645
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1646
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1647
            monitoring => $row->{monitoring},
1648
            notes => $row->{notes},
1649
        };
1650
    }
1651

            
1652
    return $registry;
Bogdan Timofte authored 4 days ago
1653
}
1654

            
Bogdan Timofte authored 4 days ago
1655
sub save_registry_to_db {
1656
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1657
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1658
    with_transaction($dbh, sub {
1659
        import_registry_to_db($dbh, $registry, 1);
1660
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1661
    });
1662
}
1663

            
1664
sub import_registry_to_db {
1665
    my ($dbh, $registry, $retire_missing) = @_;
1666
    my %seen;
1667
    for my $host (@{ $registry->{hosts} || [] }) {
1668
        my $fqdn = upsert_host_to_db($dbh, $host);
1669
        $seen{$fqdn} = 1 if $fqdn;
1670
    }
1671

            
1672
    return unless $retire_missing;
1673
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1674
    $sth->execute('retired');
1675
    while (my ($fqdn) = $sth->fetchrow_array) {
1676
        next if $seen{$fqdn};
1677
        retire_host_in_db($dbh, $fqdn);
1678
    }
1679
}
1680

            
1681
sub upsert_host_to_db {
1682
    my ($dbh, $host) = @_;
1683
    my $now = iso_now();
1684
    my $fqdn = canonical_host_fqdn($host);
1685
    return '' unless $fqdn;
1686
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1687
    my $status = clean_scalar($host->{status} || 'active');
1688
    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1689
    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1690
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1691
    my $notes = clean_scalar($host->{notes} || '');
1692

            
Bogdan Timofte authored 4 days ago
1693
    $dbh->do(
Bogdan Timofte authored 4 days ago
1694
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1695
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1696
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1697
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1698
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1699
        undef,
Bogdan Timofte authored 4 days ago
1700
        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1701
    );
1702

            
1703
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1704
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1705
    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1706
    return $fqdn;
1707
}
1708

            
1709
sub sync_host_values {
1710
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1711
    my $now = iso_now();
1712
    my %active = map { $_ => 1 } @$values;
1713
    for my $value (@$values) {
1714
        $dbh->do(
1715
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1716
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1717
            undef,
1718
            $fqdn, $value, $now,
1719
        );
1720
    }
1721

            
1722
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1723
    $sth->execute($fqdn);
1724
    while (my ($value) = $sth->fetchrow_array) {
1725
        next if $active{$value};
1726
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1727
    }
1728
}
1729

            
1730
sub sync_host_names {
1731
    my ($dbh, $fqdn, $names) = @_;
1732
    my $now = iso_now();
1733
    my (%aliases, %vhosts);
1734
    if (my $short = short_alias_for_fqdn($fqdn)) {
1735
        $aliases{$short} = 1;
1736
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1737
    }
1738
    for my $name (@$names) {
1739
        $name = normalize_dns_name($name);
1740
        next unless length $name;
1741
        next if $name eq $fqdn;
1742
        if (name_is_vhost($name)) {
1743
            $vhosts{$name} = 1;
1744
            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1745
            if (my $short = short_alias_for_fqdn($name)) {
1746
                $aliases{$short} = 1;
1747
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1748
            }
1749
        } else {
1750
            $aliases{$name} = 1;
1751
            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1752
            if (my $short = short_alias_for_fqdn($name)) {
1753
                $aliases{$short} = 1;
1754
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1755
            }
1756
        }
1757
    }
1758

            
1759
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1760
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1761
}
1762

            
1763
sub upsert_alias_to_db {
1764
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1765
    $dbh->do(
1766
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1767
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1768
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1769
        undef,
1770
        $alias, $fqdn, $kind, $now,
1771
    );
1772
}
1773

            
1774
sub upsert_vhost_to_db {
1775
    my ($dbh, $fqdn, $vhost, $now) = @_;
1776
    my $service = vhost_service_name($vhost);
1777
    $dbh->do(
1778
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
1779
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
1780
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
1781
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
1782
        undef,
1783
        $vhost, $fqdn, $service, $now, $now,
1784
    );
1785
}
1786

            
1787
sub retire_missing_names {
1788
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
1789
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1790
    $sth->execute($fqdn);
1791
    while (my ($name) = $sth->fetchrow_array) {
1792
        next if $active->{$name};
1793
        if ($table eq 'host_aliases') {
1794
            $dbh->do(
1795
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
1796
                undef, $now, $fqdn, $name,
1797
            );
1798
        } else {
1799
            $dbh->do(
1800
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
1801
                undef, $now, $fqdn, $name,
1802
            );
1803
        }
1804
    }
1805
}
1806

            
1807
sub retire_host_in_db {
1808
    my ($dbh, $fqdn) = @_;
1809
    my $now = iso_now();
1810
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
1811
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1812
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1813
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1814
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1815
}
1816

            
1817
sub active_names_for_host {
1818
    my ($dbh, $fqdn) = @_;
1819
    my @names = ($fqdn);
1820
    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");
1821
    $aliases->execute($fqdn);
1822
    while (my ($name) = $aliases->fetchrow_array) {
1823
        push @names, $name;
1824
    }
1825
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1826
    $vhosts->execute($fqdn);
1827
    while (my ($name) = $vhosts->fetchrow_array) {
1828
        push @names, $name;
1829
    }
1830
    return unique_preserve(@names);
1831
}
1832

            
1833
sub active_values_for_host {
1834
    my ($dbh, $table, $column, $fqdn) = @_;
1835
    my @values;
1836
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
1837
    $sth->execute($fqdn);
1838
    while (my ($value) = $sth->fetchrow_array) {
1839
        push @values, $value;
1840
    }
1841
    return @values;
1842
}
1843

            
1844
sub load_work_orders_from_db {
1845
    my $dbh = dbh();
1846
    my $orders = { version => 1, work_orders => [] };
1847
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
1848
    $sth->execute;
1849
    while (my $row = $sth->fetchrow_hashref) {
1850
        my $wo = {
1851
            id => $row->{id},
1852
            status => $row->{status},
1853
            title => $row->{title},
1854
            reason => $row->{reason},
1855
            created_at => $row->{created_at},
1856
            checklist => [],
1857
            actions => [],
1858
        };
1859
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
1860
        $wo->{result} = $row->{result} if length($row->{result} || '');
1861

            
1862
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
1863
        $items->execute($row->{id});
1864
        while (my $item = $items->fetchrow_hashref) {
1865
            my %copy = (
1866
                id => $item->{item_id},
1867
                text => $item->{text},
1868
                status => $item->{status},
1869
            );
1870
            for my $key (qw(owner notes updated_at)) {
1871
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
1872
            }
1873
            push @{ $wo->{checklist} }, \%copy;
1874
        }
1875

            
1876
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
1877
        $actions->execute($row->{id});
1878
        while (my $action = $actions->fetchrow_hashref) {
1879
            my %copy = ( type => $action->{type} );
1880
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
1881
            $copy{name} = $action->{name} if length($action->{name} || '');
1882
            push @{ $wo->{actions} }, \%copy;
1883
        }
1884

            
1885
        push @{ $orders->{work_orders} }, $wo;
1886
    }
1887
    return $orders;
1888
}
1889

            
1890
sub save_work_orders_to_db {
1891
    my ($orders) = @_;
1892
    my $dbh = dbh();
1893
    with_transaction($dbh, sub {
1894
        import_work_orders_to_db($dbh, $orders);
1895
    });
1896
}
1897

            
1898
sub import_work_orders_to_db {
1899
    my ($dbh, $orders) = @_;
1900
    my $now = iso_now();
1901
    my %seen;
1902
    for my $wo (@{ $orders->{work_orders} || [] }) {
1903
        my $id = clean_scalar($wo->{id} || '');
1904
        next unless $id;
1905
        $seen{$id} = 1;
1906
        $dbh->do(
1907
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
1908
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
1909
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
1910
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
1911
            undef,
1912
            $id,
1913
            clean_scalar($wo->{status} || 'pending'),
1914
            clean_scalar($wo->{title} || ''),
1915
            clean_scalar($wo->{reason} || ''),
1916
            clean_scalar($wo->{created_at} || $now),
1917
            clean_scalar($wo->{confirmed_at} || ''),
1918
            clean_scalar($wo->{result} || ''),
1919
            $now,
1920
        );
1921
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
1922
        for my $item (@{ $wo->{checklist} || [] }) {
1923
            $dbh->do(
1924
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1925
                undef,
1926
                $id,
1927
                clean_scalar($item->{id} || ''),
1928
                clean_scalar($item->{text} || ''),
1929
                clean_scalar($item->{status} || 'pending'),
1930
                clean_scalar($item->{owner} || ''),
1931
                clean_scalar($item->{notes} || ''),
1932
                clean_scalar($item->{updated_at} || ''),
1933
            );
1934
        }
1935
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
1936
        my $position = 0;
1937
        for my $action (@{ $wo->{actions} || [] }) {
1938
            my $legacy_id = clean_id($action->{host_id} || '');
1939
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
1940
            $dbh->do(
1941
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
1942
                undef,
1943
                $id,
1944
                $position++,
1945
                clean_scalar($action->{type} || ''),
1946
                $host_fqdn || undef,
1947
                $legacy_id,
1948
                normalize_dns_name($action->{name} || ''),
1949
                '',
1950
            );
1951
        }
1952
    }
1953
}
1954

            
1955
sub seed_default_workers {
1956
    my ($dbh) = @_;
1957
    my $now = iso_now();
1958
    my @workers = (
1959
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
1960
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
1961
    );
1962
    for my $worker (@workers) {
1963
        $dbh->do(
1964
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
1965
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
1966
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
1967
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
1968
            undef,
1969
            @$worker,
1970
            $now,
1971
            $now,
1972
        );
1973
    }
1974
}
1975

            
1976
sub seed_mdns_observations_from_yaml {
1977
    my ($dbh) = @_;
1978
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
1979
    my $path = "$project_dir/var/mdns-observations.yaml";
1980
    return unless -f $path;
1981
    my $db = parse_mdns_observations_yaml(read_file($path));
1982
    with_transaction($dbh, sub {
1983
        for my $observation (@{ $db->{observations} || [] }) {
1984
            $dbh->do(
1985
                '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) '
1986
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
1987
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
1988
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
1989
                undef,
1990
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
1991
                clean_scalar($observation->{name} || ''),
1992
                clean_scalar($observation->{ip} || ''),
1993
                int($observation->{ttl} || 0),
1994
                clean_scalar($observation->{first_seen} || iso_now()),
1995
                clean_scalar($observation->{last_seen} || iso_now()),
1996
                int($observation->{seen_count} || 1),
1997
                clean_scalar($observation->{last_peer} || ''),
1998
            );
1999
        }
2000
    });
2001
}
2002

            
2003
sub parse_mdns_observations_yaml {
2004
    my ($text) = @_;
2005
    my %db = ( observations => [] );
2006
    my ($section, $current);
2007
    for my $line (split /\n/, $text || '') {
2008
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2009
        if ($line =~ /^observations:\s*$/) {
2010
            $section = 'observations';
2011
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2012
            $current = { key => yaml_unquote($1) };
2013
            push @{ $db{observations} }, $current;
2014
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2015
            $current->{$1} = yaml_unquote($2);
2016
        }
2017
    }
2018
    return \%db;
2019
}
2020

            
2021
sub set_schema_meta {
2022
    my ($dbh, $key, $value) = @_;
2023
    $dbh->do(
2024
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2025
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2026
        undef,
2027
        $key,
2028
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2029
        iso_now(),
2030
    );
2031
}
2032

            
Bogdan Timofte authored 4 days ago
2033
sub fqdn_for_legacy_id {
2034
    my ($dbh, $legacy_id) = @_;
2035
    return '' unless length($legacy_id || '');
2036
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2037
    return $fqdn || '';
2038
}
2039

            
2040
sub canonical_host_fqdn {
2041
    my ($host) = @_;
2042
    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
2043
    for my $name (@names) {
2044
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2045
    }
2046
    for my $name (@names) {
2047
        return $name if $name =~ /\./ && !name_is_vhost($name);
2048
    }
2049
    for my $name (@names) {
2050
        return $name if $name =~ /\./;
2051
    }
2052
    my $id = clean_id($host->{id} || '');
2053
    return $id ? "$id.madagascar.xdev.ro" : '';
2054
}
2055

            
2056
sub legacy_id_from_fqdn {
2057
    my ($fqdn) = @_;
2058
    $fqdn = normalize_dns_name($fqdn);
2059
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2060
    $fqdn =~ s/\..*\z//;
2061
    return clean_id($fqdn);
2062
}
2063

            
2064
sub normalize_dns_name {
2065
    my ($name) = @_;
2066
    $name = lc clean_scalar($name || '');
2067
    $name =~ s/\.\z//;
2068
    return $name;
2069
}
2070

            
2071
sub name_is_vhost {
2072
    my ($name) = @_;
2073
    $name = normalize_dns_name($name);
2074
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2075
}
2076

            
2077
sub vhost_service_name {
2078
    my ($name) = @_;
2079
    $name = normalize_dns_name($name);
2080
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2081
    return '';
2082
}
2083

            
2084
sub short_alias_for_fqdn {
2085
    my ($name) = @_;
2086
    $name = normalize_dns_name($name);
2087
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2088
    return '';
2089
}
2090

            
Bogdan Timofte authored 4 days ago
2091
sub normalize_registry_policy {
2092
    my ($registry) = @_;
2093
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2094
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2095
    $registry->{policy}{runtime_database} = $opt{db};
2096
}
2097

            
2098
sub default_hosts_yaml {
2099
    return <<'YAML';
2100
version: 1
2101
updated_at: ""
2102
policy:
Bogdan Timofte authored 4 days ago
2103
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2104
hosts:
2105
YAML
2106
}
2107

            
2108
sub default_work_orders_yaml {
2109
    return <<'YAML';
2110
version: 1
2111
work_orders:
2112
YAML
2113
}
2114

            
2115
sub ensure_parent_dir {
2116
    my ($path) = @_;
2117
    my $dir = dirname($path);
2118
    make_path($dir) unless -d $dir;
2119
}
2120

            
Xdev Host Manager authored a week ago
2121
sub url_decode {
2122
    my ($value) = @_;
2123
    $value = '' unless defined $value;
2124
    $value =~ tr/+/ /;
2125
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2126
    return $value;
2127
}
2128

            
2129
sub random_hex {
2130
    my ($bytes) = @_;
2131
    if (open my $fh, '<:raw', '/dev/urandom') {
2132
        read($fh, my $raw, $bytes);
2133
        close $fh;
2134
        return unpack('H*', $raw);
2135
    }
2136
    return sha256_hex(rand() . time() . $$);
2137
}
2138

            
2139
sub iso_now {
2140
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2141
}
2142

            
Bogdan Timofte authored 6 days ago
2143
sub build_info {
2144
    my %info = (
2145
        revision => '',
2146
        branch => '',
2147
        built_at => '',
2148
        deployed_at => '',
2149
        dirty => '',
2150
    );
2151

            
2152
    if ($ENV{HOST_MANAGER_BUILD}) {
2153
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2154
        return \%info;
2155
    }
2156

            
2157
    my $build_file = "$project_dir/BUILD";
2158
    if (-f $build_file) {
2159
        for my $line (split /\n/, read_file($build_file)) {
2160
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2161
            $info{$1} = clean_scalar($2);
2162
        }
2163
        return \%info if $info{revision} || $info{built_at};
2164
    }
2165

            
2166
    my $revision = git_value('rev-parse --short=12 HEAD');
2167
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2168
    $info{revision} = $revision if $revision;
2169
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2170
    return \%info;
2171
}
2172

            
2173
sub git_value {
2174
    my ($args) = @_;
2175
    return '' unless -d "$project_dir/.git";
2176
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2177
    my $value = <$fh> || '';
2178
    close $fh;
2179
    chomp $value;
2180
    return clean_scalar($value);
2181
}
2182

            
2183
sub build_label {
2184
    my $info = build_info();
2185
    my $revision = $info->{revision} || 'unknown';
2186
    my $branch = $info->{branch} || '';
2187
    $branch = '' if $branch eq 'HEAD';
2188
    my $label = $branch ? "$branch $revision" : $revision;
2189
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2190
    return $label;
2191
}
2192

            
2193
sub build_title {
2194
    my $info = build_info();
2195
    my $label = build_label();
2196
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2197
    return $stamp ? "$label deployed $stamp" : $label;
2198
}
2199

            
Bogdan Timofte authored 4 days ago
2200
sub build_revision {
2201
    my $info = build_info();
2202
    return $info->{revision} || 'unknown';
2203
}
2204

            
2205
sub build_details {
2206
    my $info = build_info();
2207
    my %details = (
2208
        app => 'Madagascar Local Authority',
2209
        revision => $info->{revision} || 'unknown',
2210
        branch => $info->{branch} || '',
2211
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2212
        built_at => $info->{built_at} || '',
2213
        deployed_at => $info->{deployed_at} || '',
2214
        label => build_label(),
2215
        title => build_title(),
2216
    );
2217
    return json_encode(\%details);
2218
}
2219

            
Bogdan Timofte authored 6 days ago
2220
sub html_escape {
2221
    my ($value) = @_;
2222
    $value = '' unless defined $value;
2223
    $value =~ s/&/&amp;/g;
2224
    $value =~ s/</&lt;/g;
2225
    $value =~ s/>/&gt;/g;
2226
    $value =~ s/"/&quot;/g;
2227
    $value =~ s/'/&#039;/g;
2228
    return $value;
2229
}
2230

            
Xdev Host Manager authored a week ago
2231
sub app_html {
Bogdan Timofte authored 4 days ago
2232
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2233
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2234
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2235
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2236
<!doctype html>
2237
<html lang="ro">
2238
<head>
2239
  <meta charset="utf-8">
2240
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2241
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2242
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2243
  <style>
2244
    :root {
2245
      color-scheme: light;
2246
      --ink: #152033;
2247
      --muted: #647084;
2248
      --line: #d8dee8;
2249
      --soft: #f4f6f9;
2250
      --panel: #ffffff;
2251
      --accent: #1267d8;
2252
      --bad: #b42318;
2253
      --warn: #946200;
2254
      --ok: #137333;
2255
    }
2256
    * { box-sizing: border-box; }
2257
    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
2258

            
2259
    /* ── Login screen ── */
2260
    #login-screen {
2261
      display: flex;
Xdev Host Manager authored a week ago
2262
      align-items: flex-start;
Xdev Host Manager authored a week ago
2263
      justify-content: center;
2264
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2265
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2266
      background: #13182a;
Xdev Host Manager authored a week ago
2267
      overflow: auto;
Xdev Host Manager authored a week ago
2268
    }
2269
    .login-card {
Xdev Host Manager authored a week ago
2270
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2271
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2272
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2273
      background: #fff;
2274
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2275
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2276
         below the first box, sits inside the card instead of spilling past it. */
2277
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2278
      width: 100%;
Xdev Host Manager authored a week ago
2279
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2280
      min-height: 360px;
Xdev Host Manager authored a week ago
2281
      display: grid;
Xdev Host Manager authored a week ago
2282
      align-content: start;
2283
      justify-items: center;
2284
      gap: 28px;
Xdev Host Manager authored a week ago
2285
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2286
    }
Xdev Host Manager authored a week ago
2287
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2288
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2289
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2290
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2291
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2292
    }
Xdev Host Manager authored a week ago
2293
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2294
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2295
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2296
    .login-card form {
2297
      display: grid;
2298
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2299
      justify-self: center;
Bogdan Timofte authored a week ago
2300
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2301
    }
Xdev Host Manager authored a week ago
2302
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2303
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2304
       giving the password manager a username anchor and an aggregated OTP target
2305
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2306
    .pm-helper-fields {
2307
      position: absolute;
2308
      left: -10000px;
2309
      top: auto;
2310
      width: 1px;
2311
      height: 1px;
2312
      overflow: hidden;
2313
      opacity: 0.01;
2314
    }
2315
    .pm-helper-fields input {
2316
      width: 1px;
2317
      height: 1px;
2318
      padding: 0;
2319
      border: 0;
2320
    }
Bogdan Timofte authored 4 days ago
2321
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2322
       hint was what made Safari mark the whole group and re-present its OTP
2323
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2324
    .otp-row {
2325
      display: flex;
2326
      gap: var(--otp-gap);
2327
      justify-content: center;
2328
    }
Bogdan Timofte authored 4 days ago
2329
    .otp-row input {
Xdev Host Manager authored a week ago
2330
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
2331
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2332
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2333
      transition: border-color .15s, background .15s;
2334
    }
Bogdan Timofte authored 4 days ago
2335
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2336
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2337
    #login-error {
2338
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2339
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2340
    }
2341
    @media (max-width: 760px) {
2342
      .login-card {
Xdev Host Manager authored a week ago
2343
        max-width: 520px;
Xdev Host Manager authored a week ago
2344
        min-height: 0;
Bogdan Timofte authored 4 days ago
2345
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2346
        gap: 26px;
2347
      }
2348
      .login-card .brand h1 { font-size: 24px; }
2349
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2350
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2351
    }
Xdev Host Manager authored a week ago
2352
    @media (max-width: 430px) {
2353
      #login-screen { padding: 24px 16px 120px; }
2354
      .login-card {
2355
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2356
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2357
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2358
      }
Bogdan Timofte authored 4 days ago
2359
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2360
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2361
    }
2362
    @media (max-height: 720px) {
2363
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2364
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2365
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2366
    }
Xdev Host Manager authored a week ago
2367

            
2368
    /* ── App shell (hidden until authenticated) ── */
2369
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2370
    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
2371
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2372
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2373
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2374
    nav a:hover { color: var(--ink); background: var(--soft); }
2375
    nav a.active { color: var(--accent); background: #e8f0fe; }
2376
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2377
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2378
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2379
    .page { display: grid; gap: 16px; }
2380
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2381
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2382
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2383
    .panel { overflow: hidden; }
2384
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2385
    .panel-head h2 { margin: 0; font-size: 14px; }
2386
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2387
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2388
    button, input, select, textarea { font: inherit; }
2389
    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; }
2390
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2391
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2392
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2393
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2394
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2395
    textarea { min-height: 74px; resize: vertical; }
2396
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2397
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2398
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2399
    tr:hover td { background: #f8fafc; }
2400
    .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; }
2401
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2402
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2403
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
2404
    .pill.derived { border-style: dashed; }
Xdev Host Manager authored a week ago
2405
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2406
    .span2 { grid-column: 1 / -1; }
2407
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2408
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2409
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2410
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2411
    .ca-fingerprint { overflow-wrap: anywhere; }
2412
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2413
    .build-control {
Bogdan Timofte authored 6 days ago
2414
      position: fixed;
2415
      right: 10px;
2416
      bottom: 8px;
2417
      z-index: 5;
Bogdan Timofte authored 4 days ago
2418
      display: inline-flex;
2419
      align-items: center;
2420
      gap: 4px;
2421
    }
2422
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2423
      color: rgba(255,255,255,.46);
2424
      background: rgba(19,24,42,.28);
2425
      border: 1px solid rgba(255,255,255,.08);
2426
      border-radius: 4px;
2427
      font-size: 10px;
2428
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2429
    }
2430
    .build-badge {
2431
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
2432
      cursor: text;
2433
      user-select: text;
Bogdan Timofte authored 6 days ago
2434
    }
Bogdan Timofte authored 4 days ago
2435
    .build-copy {
2436
      min-height: 0;
2437
      padding: 2px 5px;
2438
      cursor: pointer;
2439
    }
2440
    .build-copy:hover {
2441
      color: rgba(255,255,255,.72);
2442
      border-color: rgba(255,255,255,.24);
2443
    }
2444
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2445
      color: rgba(100,112,132,.58);
2446
      background: rgba(255,255,255,.72);
2447
      border-color: rgba(216,222,232,.72);
2448
    }
Bogdan Timofte authored 4 days ago
2449
    body.is-app .build-copy:hover {
2450
      color: rgba(21,32,51,.78);
2451
      border-color: rgba(100,112,132,.42);
2452
    }
Xdev Host Manager authored a week ago
2453
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2454
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2455
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2456
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2457
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2458
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2459
    .work-order-actions { gap: 4px; }
2460
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2461
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2462
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2463
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
2464
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
2465
    .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
2466
    .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
2467
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
2468
    .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
2469
    .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; }
2470
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
2471
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
2472
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
2473
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
2474
    .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; }
2475
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
2476
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
2477
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2478
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2479
    .host-tools input { max-width: 240px; }
2480
    .modal-backdrop {
2481
      position: fixed;
2482
      inset: 0;
2483
      z-index: 10;
2484
      display: grid;
2485
      align-items: start;
2486
      justify-items: center;
2487
      padding: 72px 16px 24px;
2488
      background: rgba(21,32,51,.48);
2489
      overflow: auto;
2490
    }
2491
    .modal-backdrop[hidden] { display: none; }
2492
    .modal {
2493
      width: min(840px, 100%);
2494
      max-height: calc(100dvh - 96px);
2495
      overflow: auto;
2496
      background: var(--panel);
2497
      border: 1px solid var(--line);
2498
      border-radius: 8px;
2499
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2500
    }
2501
    .modal-head {
2502
      position: sticky;
2503
      top: 0;
2504
      z-index: 1;
2505
      display: flex;
2506
      align-items: center;
2507
      justify-content: space-between;
2508
      gap: 12px;
2509
      padding: 12px 14px;
2510
      border-bottom: 1px solid var(--line);
2511
      background: #fafbfc;
2512
    }
2513
    .modal-head h2 { margin: 0; font-size: 14px; }
2514
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2515
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2516
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2517
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2518
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2519
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2520
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2521
      #message { max-width: 100%; }
2522
      .panel-head { align-items: stretch; flex-direction: column; }
2523
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2524
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2525
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2526
      .modal-backdrop { padding-top: 16px; }
2527
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2528
      .grid { grid-template-columns: 1fr; }
2529
      table { min-width: 760px; }
2530
      .table-wrap { overflow-x: auto; }
2531
    }
2532
  </style>
2533
</head>
Bogdan Timofte authored 6 days ago
2534
<body class="is-login">
Xdev Host Manager authored a week ago
2535

            
Xdev Host Manager authored a week ago
2536
  <!-- ── Login screen ── -->
2537
  <div id="login-screen">
2538
    <div class="login-card">
2539
      <div class="brand">
2540
        <div class="icon">
Xdev Host Manager authored a week ago
2541
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2542
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2543
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2544
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2545
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2546
            <path d="M26 20h8M26 32h8M26 44h8"/>
2547
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2548
          </svg>
2549
        </div>
Xdev Host Manager authored a week ago
2550
        <h1>Madagascar Local Authority</h1>
2551
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2552
      </div>
Bogdan Timofte authored 4 days ago
2553
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2554
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2555
        <div class="pm-helper-fields" aria-hidden="true">
2556
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2557
          <input type="hidden" id="otp-hidden" name="otp">
2558
        </div>
Xdev Host Manager authored a week ago
2559
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2560
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2561
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2562
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2563
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2564
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2565
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2566
        </div>
2567
      </form>
2568
    </div>
2569
  </div>
2570

            
2571
  <!-- ── App (shown after login) ── -->
2572
  <div id="app">
2573
    <header>
Xdev Host Manager authored a week ago
2574
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2575
      <nav aria-label="Sections">
2576
        <a href="/overview" data-page-link="overview">Overview</a>
2577
        <a href="/hosts" data-page-link="hosts">Hosts</a>
2578
        <a href="/dns" data-page-link="dns">DNS</a>
2579
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2580
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2581
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2582
      </nav>
Xdev Host Manager authored a week ago
2583
      <div class="header-right">
2584
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2585
        <span id="message" class="muted"></span>
2586
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2587
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2588
      </div>
Xdev Host Manager authored a week ago
2589
    </header>
2590
    <main>
Bogdan Timofte authored 5 days ago
2591
      <section class="page" id="page-overview" data-page="overview">
2592
        <section class="panel">
2593
          <div class="panel-head">
2594
            <h2>Overview</h2>
2595
            <div class="stats" id="stats"></div>
2596
          </div>
2597
          <div class="problems" id="problems"></div>
2598
        </section>
Xdev Host Manager authored a week ago
2599
      </section>
2600

            
Bogdan Timofte authored 5 days ago
2601
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2602
        <section class="panel">
2603
          <div class="panel-head">
2604
            <h2>Hosts</h2>
2605
            <div class="host-tools">
2606
              <input id="filter" placeholder="filter">
2607
              <button type="button" id="new-host">New host</button>
2608
            </div>
2609
          </div>
2610
          <div class="table-wrap">
2611
            <table>
2612
              <thead>
2613
                <tr>
2614
                  <th style="width: 120px">ID</th>
2615
                  <th style="width: 130px">hosts_ip</th>
2616
                  <th style="width: 130px">dns_ip</th>
2617
                  <th>Names</th>
2618
                  <th style="width: 150px">Roles</th>
2619
                  <th style="width: 110px">Monitoring</th>
2620
                  <th style="width: 90px">Status</th>
2621
                </tr>
2622
              </thead>
2623
              <tbody id="hosts"></tbody>
2624
            </table>
2625
          </div>
2626
        </section>
Xdev Host Manager authored a week ago
2627
      </section>
Xdev Host Manager authored a week ago
2628

            
Bogdan Timofte authored 5 days ago
2629
      <section class="page" id="page-dns" data-page="dns" hidden>
2630
        <section class="toolbar">
2631
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2632
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2633
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2634
          <button id="write-tsv">Write local-hosts.tsv</button>
2635
        </section>
Xdev Host Manager authored a week ago
2636
      </section>
2637

            
Bogdan Timofte authored 5 days ago
2638
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2639
        <section class="panel">
2640
          <div class="panel-head">
2641
            <h2>Work Orders</h2>
2642
            <div class="stats" id="wo-stats"></div>
2643
          </div>
2644
          <div class="problems" id="work-orders"></div>
2645
        </section>
Xdev Host Manager authored a week ago
2646
      </section>
2647

            
Bogdan Timofte authored 5 days ago
2648
      <section class="page" id="page-ca" data-page="ca" hidden>
2649
        <section class="panel">
2650
          <div class="panel-head">
2651
            <h2>Local Certificate Authority</h2>
2652
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2653
          </div>
2654
          <div class="problems" id="ca-status"></div>
2655
        </section>
2656
        <section class="panel">
2657
          <div class="panel-head">
2658
            <h2>Issued Certificates</h2>
2659
            <div class="stats" id="ca-certs-summary"></div>
2660
          </div>
2661
          <div class="table-wrap">
2662
            <table>
2663
              <thead>
2664
                <tr>
2665
                  <th style="width: 150px">Name</th>
2666
                  <th>DNS names</th>
2667
                  <th style="width: 210px">Validity</th>
2668
                  <th style="width: 180px">Serial</th>
2669
                  <th>Fingerprint</th>
2670
                  <th style="width: 110px">Download</th>
2671
                </tr>
2672
              </thead>
2673
              <tbody id="ca-certs"></tbody>
2674
            </table>
2675
          </div>
2676
        </section>
Xdev Host Manager authored a week ago
2677
      </section>
Bogdan Timofte authored 4 days ago
2678

            
2679
      <section class="page" id="page-debug" data-page="debug" hidden>
2680
        <section class="panel">
2681
          <div class="panel-head">
2682
            <h2>Database</h2>
2683
            <div class="stats" id="debug-db-stats"></div>
2684
          </div>
2685
          <div class="toolbar">
2686
            <div class="debug-controls">
2687
              <button type="button" id="debug-db-refresh">Refresh</button>
2688
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
2689
            </div>
2690
          </div>
Bogdan Timofte authored 4 days ago
2691
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
2692
        </section>
2693
        <section class="debug-section">
2694
          <section class="panel">
2695
            <div class="panel-head">
2696
              <h2>Rows</h2>
2697
              <div class="stats" id="debug-table-stats"></div>
2698
            </div>
2699
            <div class="table-wrap" id="debug-table-rows"></div>
2700
          </section>
2701
          <section class="panel">
2702
            <div class="panel-head">
2703
              <h2>Columns</h2>
2704
            </div>
2705
            <div class="table-wrap" id="debug-table-columns"></div>
2706
          </section>
2707
          <section class="panel">
2708
            <div class="panel-head">
2709
              <h2>Indexes</h2>
2710
            </div>
2711
            <div class="table-wrap" id="debug-table-indexes"></div>
2712
          </section>
2713
          <section class="panel">
2714
            <div class="panel-head">
2715
              <h2>Foreign Keys</h2>
2716
            </div>
2717
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
2718
          </section>
2719
        </section>
2720
      </section>
Bogdan Timofte authored 5 days ago
2721
    </main>
Xdev Host Manager authored a week ago
2722

            
Bogdan Timofte authored 5 days ago
2723
    <div id="host-modal" class="modal-backdrop" hidden>
2724
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
2725
        <div class="modal-head">
2726
          <h2 id="host-modal-title">Edit host</h2>
2727
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
2728
        </div>
2729
        <form id="host-form" class="grid">
2730
          <label>ID<input name="id" required></label>
2731
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2732
          <label>hosts_ip<input name="hosts_ip" required></label>
2733
          <label>dns_ip<input name="dns_ip" required></label>
2734
          <label class="span2">Names<textarea name="names" required></textarea></label>
2735
          <label>Roles<input name="roles"></label>
2736
          <label>Sources<input name="sources"></label>
2737
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2738
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
2739
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
2740
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
2741
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
2742
            <button class="danger" type="button" id="delete-host">Delete host</button>
2743
          </div>
2744
        </form>
2745
      </section>
Bogdan Timofte authored 5 days ago
2746
    </div>
Xdev Host Manager authored a week ago
2747
  </div>
2748

            
Bogdan Timofte authored 4 days ago
2749
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
2750
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
2751
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
2752
  </div>
Bogdan Timofte authored 6 days ago
2753

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

            
2758
    const $ = (id) => document.getElementById(id);
2759
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
2760
    const PAGE_PATHS = {
2761
      '/': 'overview',
2762
      '/overview': 'overview',
2763
      '/hosts': 'hosts',
2764
      '/dns': 'dns',
2765
      '/work-orders': 'work-orders',
2766
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
2767
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
2768
    };
Xdev Host Manager authored a week ago
2769

            
Bogdan Timofte authored 4 days ago
2770
    function isAuthLost(error) {
2771
      return !!(error && error.authLost);
2772
    }
2773

            
2774
    function authLostError(message) {
2775
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2776
      error.authLost = true;
2777
      return error;
2778
    }
2779

            
2780
    function handleAuthLost(message) {
2781
      state.authenticated = false;
2782
      msg('');
2783
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2784
    }
2785

            
Xdev Host Manager authored a week ago
2786
    async function api(path, options = {}) {
2787
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
2788
      let body = {};
2789
      try {
2790
        body = await res.json();
2791
      } catch (_) {
2792
        body = {};
2793
      }
2794
      const errorCode = body.error || '';
2795
      if (!res.ok) {
2796
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
2797
          const error = authLostError();
2798
          handleAuthLost(error.message);
2799
          throw error;
2800
        }
2801
        throw new Error(errorCode || res.statusText);
2802
      }
Xdev Host Manager authored a week ago
2803
      return body;
2804
    }
2805

            
Bogdan Timofte authored 5 days ago
2806
    function currentPage() {
2807
      return PAGE_PATHS[window.location.pathname] || 'overview';
2808
    }
2809

            
2810
    function showPage(page, push = false) {
2811
      const target = page || 'overview';
2812
      document.querySelectorAll('[data-page]').forEach(section => {
2813
        section.hidden = section.dataset.page !== target;
2814
      });
2815
      document.querySelectorAll('[data-page-link]').forEach(link => {
2816
        link.classList.toggle('active', link.dataset.pageLink === target);
2817
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
2818
      });
2819
      if (push) {
2820
        const href = target === 'overview' ? '/overview' : '/' + target;
2821
        history.pushState({ page: target }, '', href);
2822
      }
Bogdan Timofte authored 4 days ago
2823
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
2824
        renderDebugDatabase().catch(e => {
2825
          if (!isAuthLost(e)) msg(e.message);
2826
        });
Bogdan Timofte authored 4 days ago
2827
      }
Bogdan Timofte authored 5 days ago
2828
    }
2829

            
Xdev Host Manager authored a week ago
2830
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
2831
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
2832
      document.body.classList.remove('is-app');
2833
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
2834
      $('app').style.display = 'none';
2835
      $('login-screen').style.display = 'flex';
2836
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
2837
      clearOtp();
Xdev Host Manager authored a week ago
2838
    }
2839

            
2840
    function showApp() {
Bogdan Timofte authored 6 days ago
2841
      document.body.classList.remove('is-login');
2842
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
2843
      $('login-screen').style.display = 'none';
2844
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
2845
      showPage(currentPage());
Xdev Host Manager authored a week ago
2846
    }
2847

            
Xdev Host Manager authored a week ago
2848
    async function refresh() {
2849
      const session = await api('/api/session');
2850
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
2851
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
2852
      showApp();
Xdev Host Manager authored a week ago
2853
      const data = await api('/api/hosts');
2854
      state.hosts = data.hosts || [];
2855
      state.problems = data.problems || [];
2856
      render(data);
Xdev Host Manager authored a week ago
2857
      await renderCa();
Xdev Host Manager authored a week ago
2858
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
2859
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
2860
    }
2861

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

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

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

            
2874
      renderHosts();
2875
    }
2876

            
Xdev Host Manager authored a week ago
2877
    async function renderCa() {
2878
      try {
2879
        const status = await api('/api/ca/status');
2880
        if (!status.initialized) {
2881
          $('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
2882
          $('ca-certs-summary').innerHTML = '';
2883
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
2884
          return;
2885
        }
2886
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
2887
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
2888
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
2889
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
2890
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
2891
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
2892
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
2893
            <div>
2894
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
2895
              <span>${certs.length} issued certificate(s)</span>
2896
            </div>
Xdev Host Manager authored a week ago
2897
          </div>`;
Bogdan Timofte authored 5 days ago
2898
        $('ca-certs-summary').innerHTML = [
2899
          ['issued', certs.length],
2900
          ['expiring', certs.filter(cert => {
2901
            const days = daysUntil(cert.not_after);
2902
            return days !== null && days >= 0 && days <= 30;
2903
          }).length],
2904
          ['expired', certs.filter(cert => {
2905
            const days = daysUntil(cert.not_after);
2906
            return days !== null && days < 0;
2907
          }).length],
2908
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2909
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
2910
          const days = daysUntil(cert.not_after);
2911
          const dnsNames = cert.dns_names || [];
2912
          const dnsHtml = dnsNames.length
2913
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
2914
            : '<span class="muted">No DNS SANs reported.</span>';
2915
          return `<tr>
2916
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
2917
            <td>${dnsHtml}</td>
2918
            <td>
2919
              <div class="ca-detail">
2920
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
2921
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
2922
              </div>
2923
            </td>
2924
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
2925
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
2926
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
2927
          </tr>`;
2928
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
2929
      } catch (e) {
Bogdan Timofte authored 4 days ago
2930
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
2931
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
2932
        $('ca-certs-summary').innerHTML = '';
2933
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
2934
      }
2935
    }
2936

            
Bogdan Timofte authored 5 days ago
2937
    function daysUntil(dateText) {
2938
      const time = Date.parse(dateText || '');
2939
      if (!Number.isFinite(time)) return null;
2940
      return Math.ceil((time - Date.now()) / 86400000);
2941
    }
2942

            
2943
    function certStatusClass(days) {
2944
      if (days === null) return '';
2945
      if (days < 0) return 'bad';
2946
      if (days <= 30) return 'warn';
2947
      return 'ok';
2948
    }
2949

            
2950
    function certStatusLabel(days) {
2951
      if (days === null) return 'validity unknown';
2952
      if (days < 0) return 'expired';
2953
      if (days === 0) return 'expires today';
2954
      return `${days}d remaining`;
2955
    }
2956

            
Xdev Host Manager authored a week ago
2957
    async function renderWorkOrders() {
2958
      try {
2959
        const data = await api('/api/work-orders');
2960
        state.workOrders = data.work_orders || [];
2961
        $('wo-stats').innerHTML = [
2962
          ['pending', data.counts.pending],
2963
          ['total', data.counts.work_orders],
2964
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2965

            
2966
        if (!state.workOrders.length) {
2967
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
2968
          return;
2969
        }
2970

            
2971
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
2972
          const checklist = wo.checklist || [];
2973
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
2974
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
2975
          const checklistHtml = checklist.map(item => {
2976
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
2977
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
2978
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
2979
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
2980
            </label>`;
2981
          }).join('');
Xdev Host Manager authored a week ago
2982
          const actions = (wo.actions || []).map(a => {
2983
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
2984
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
2985
          }).join('');
2986
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
2987
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
2988
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
2989
            : '';
Bogdan Timofte authored 6 days ago
2990
          return `<div class="problem work-order-card">
2991
            <div class="work-order-head">
Xdev Host Manager authored a week ago
2992
              <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
2993
              ${button}
2994
            </div>
Bogdan Timofte authored 6 days ago
2995
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
2996
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
2997
            <div class="work-order-checklist">${checklistHtml}</div>
2998
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
2999
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3000
          </div>`;
3001
        }).join('');
Xdev Host Manager authored a week ago
3002
        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
3003
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3004
      } catch (e) {
Bogdan Timofte authored 4 days ago
3005
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3006
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3007
      }
3008
    }
3009

            
Bogdan Timofte authored 4 days ago
3010
    async function renderDebugDatabase() {
3011
      if (!state.authenticated) return;
3012
      const data = await api('/api/debug/database/tables');
3013
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3014
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3015
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3016
      $('debug-db-stats').innerHTML = [
3017
        ['tables', data.counts ? data.counts.tables : tables.length],
3018
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3019
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3020
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3021
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3022
      if (selected) {
3023
        await renderDebugTable(selected);
3024
      } else {
3025
        clearDebugTable();
3026
      }
3027
    }
3028

            
Bogdan Timofte authored 4 days ago
3029
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3030
      $('debug-db-tables').innerHTML = tables.length
3031
        ? tables.map(table => {
3032
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3033
            const ref = debugTableReference(database, table.name);
3034
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3035
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3036
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3037
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3038
              </button>
Bogdan Timofte authored 4 days ago
3039
              <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
3040
            </div>`;
Bogdan Timofte authored 4 days ago
3041
          }).join('')
3042
        : '<div class="ca-empty muted">No database tables found.</div>';
3043
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3044
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3045
          if (!isAuthLost(e)) msg(e.message);
3046
        }));
3047
      });
Bogdan Timofte authored 4 days ago
3048
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3049
        button.addEventListener('click', async () => {
3050
          try {
3051
            await copyText(button.dataset.debugTableRef || '');
3052
            msg('table reference copied');
3053
          } catch (e) {
3054
            msg('copy failed');
3055
          }
3056
        });
3057
      });
3058
    }
3059

            
3060
    function debugTableReference(database, tableName) {
3061
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3062
    }
3063

            
3064
    async function selectDebugTable(tableName) {
3065
      state.debugTable = tableName || '';
3066
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3067
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3068
        const card = button.closest('.debug-table-card');
3069
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3070
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3071
      });
3072
      if (state.debugTable) await renderDebugTable(state.debugTable);
3073
    }
3074

            
3075
    function clearDebugTable() {
3076
      $('debug-table-stats').innerHTML = '';
3077
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3078
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3079
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3080
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3081
    }
3082

            
3083
    async function renderDebugTable(tableName) {
3084
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3085
      if (data.error) throw new Error(data.error);
3086
      $('debug-table-stats').innerHTML = [
3087
        ['table', data.table || tableName],
3088
        ['rows', data.row_count || 0],
3089
        ['shown', (data.rows || []).length],
3090
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3091
      renderDebugRows(data);
3092
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3093
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3094
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3095
    }
3096

            
3097
    function renderDebugRows(data) {
3098
      const rows = data.rows || [];
3099
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3100
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3101
    }
3102

            
3103
    function renderDebugObjectTable(rows, preferredKeys) {
3104
      const keys = preferredKeys && preferredKeys.length
3105
        ? preferredKeys
3106
        : Array.from(rows.reduce((set, row) => {
3107
            Object.keys(row || {}).forEach(key => set.add(key));
3108
            return set;
3109
          }, new Set()));
3110
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3111
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3112
      const body = rows.length
3113
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3114
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3115
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3116
    }
3117

            
3118
    function debugCell(value) {
3119
      if (value === null || value === undefined) return 'NULL';
3120
      if (Array.isArray(value)) return value.join(', ');
3121
      if (typeof value === 'object') return JSON.stringify(value);
3122
      return String(value);
3123
    }
3124

            
Xdev Host Manager authored a week ago
3125
    async function updateWorkOrderChecklist(id, itemId, checked) {
3126
      try {
3127
        await api('/api/work-orders/checklist', {
3128
          method: 'POST',
3129
          headers: { 'Content-Type': 'application/json' },
3130
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3131
        });
3132
        msg('work order updated');
3133
        await refresh();
Bogdan Timofte authored 4 days ago
3134
      } catch (e) {
3135
        if (isAuthLost(e)) return;
3136
        msg(e.message);
3137
        await refresh().catch(refreshError => {
3138
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3139
        });
3140
      }
Xdev Host Manager authored a week ago
3141
    }
3142

            
Xdev Host Manager authored a week ago
3143
    async function confirmWorkOrder(id) {
3144
      const typed = prompt(`Type ${id} to confirm this work order`);
3145
      if (typed !== id) return;
3146
      try {
3147
        await api('/api/work-orders/confirm', {
3148
          method: 'POST',
3149
          headers: { 'Content-Type': 'application/json' },
3150
          body: JSON.stringify({ id, confirm: typed })
3151
        });
3152
        msg('work order confirmed; local-hosts.tsv written');
3153
        await refresh();
Bogdan Timofte authored 4 days ago
3154
      } catch (e) {
3155
        if (isAuthLost(e)) return;
3156
        msg(e.message);
3157
      }
Xdev Host Manager authored a week ago
3158
    }
3159

            
Xdev Host Manager authored a week ago
3160
    function renderHosts() {
3161
      const filter = $('filter').value.toLowerCase();
3162
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3163
        .slice()
3164
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3165
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3166
        .map(h => {
3167
          const problems = state.problems.filter(p => p.host_id === h.id);
3168
          const cls = problems.length ? 'warn' : 'ok';
3169
          return `<tr data-id="${escapeHtml(h.id)}">
3170
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3171
            <td>${escapeHtml(h.hosts_ip || '')}</td>
3172
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 4 days ago
3173
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3174
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3175
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3176
            <td>${escapeHtml(h.status || '')}</td>
3177
          </tr>`;
3178
        }).join('');
3179
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
3180
    }
3181

            
Bogdan Timofte authored 4 days ago
3182
    function renderNamePills(host) {
3183
      const declared = host.declared_names || host.names || [];
3184
      const derived = host.derived_names || [];
3185
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3186
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
3187
      return declaredHtml + derivedHtml;
3188
    }
3189

            
Xdev Host Manager authored a week ago
3190
    function editHost(id) {
3191
      const host = state.hosts.find(h => h.id === id);
3192
      if (!host) return;
3193
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3194
      clearHostFormMessage();
3195
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 4 days ago
3196
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 5 days ago
3197
      hostField('roles').value = (host.roles || []).join(' ');
3198
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3199
      openHostModal('Edit host');
3200
    }
3201

            
3202
    function newHost() {
3203
      const form = $('host-form');
3204
      form.reset();
Bogdan Timofte authored 5 days ago
3205
      clearHostFormMessage();
3206
      hostField('status').value = 'active';
3207
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3208
      openHostModal('New host');
3209
    }
3210

            
3211
    function openHostModal(title) {
3212
      $('host-modal-title').textContent = title || 'Edit host';
3213
      $('host-modal').hidden = false;
3214
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3215
      hostFormSnapshot = hostFormState();
3216
      hostField('id').focus();
3217
    }
3218

            
3219
    function requestCloseHostModal() {
3220
      if ($('save-host').disabled) return;
3221
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3222
      closeHostModal();
Bogdan Timofte authored 5 days ago
3223
    }
3224

            
3225
    function closeHostModal() {
3226
      $('host-modal').hidden = true;
3227
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3228
      setHostFormBusy(false);
3229
      clearHostFormMessage();
3230
      hostFormSnapshot = '';
3231
    }
3232

            
3233
    function hostField(name) {
3234
      return $('host-form').elements.namedItem(name);
3235
    }
3236

            
3237
    function hostFormState() {
3238
      return JSON.stringify(formObject($('host-form')));
3239
    }
3240

            
3241
    function hostFormDirty() {
3242
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3243
    }
3244

            
3245
    function setHostFormBusy(busy) {
3246
      $('save-host').disabled = busy;
3247
      $('delete-host').disabled = busy;
3248
      $('close-host-modal').disabled = busy;
3249
    }
3250

            
3251
    function setHostFormMessage(text, isError = false) {
3252
      const message = $('host-form-message');
3253
      message.textContent = text || '';
3254
      message.classList.toggle('error', !!isError);
3255
    }
3256

            
3257
    function clearHostFormMessage() {
3258
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3259
    }
3260

            
3261
    function formObject(form) {
3262
      return Object.fromEntries(new FormData(form).entries());
3263
    }
3264

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

            
Bogdan Timofte authored 6 days ago
3270
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3271

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

            
3277
    if (loginAccount) {
3278
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3279
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3280
      loginAccount.addEventListener('input', () => {
3281
        const value = (loginAccount.value || '').trim();
3282
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3283
      });
3284
    }
3285

            
Xdev Host Manager authored a week ago
3286
    function setOtpDigit(idx, value) {
3287
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3288
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3289
      otpDigits[idx].classList.toggle('filled', !!digit);
3290
    }
3291

            
Bogdan Timofte authored 4 days ago
3292
    // Move focus to the next empty box: forward from idx, then wrapping to the
3293
    // start. This lets out-of-order entry continue (e.g. after the last box,
3294
    // jump back to the first still-empty box). Stays put when all boxes are full.
3295
    function advanceFocus(idx) {
3296
      for (let i = idx + 1; i < otpDigits.length; i++) {
3297
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3298
      }
3299
      for (let i = 0; i <= idx; i++) {
3300
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3301
      }
3302
    }
3303

            
Bogdan Timofte authored 4 days ago
3304
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3305
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3306
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
3307
      const digits = (text || '').replace(/\D/g, '').split('');
3308
      if (!digits.length) return;
3309
      let last = startIdx;
3310
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3311
        last = startIdx + i;
3312
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3313
      }
Bogdan Timofte authored 4 days ago
3314
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3315
      advanceFocus(last);
Xdev Host Manager authored a week ago
3316
      maybeSubmitOtp();
3317
    }
3318

            
Bogdan Timofte authored 4 days ago
3319
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3320
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3321
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3322
    function maybeSubmitOtp() {
3323
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3324
    }
3325
    function clearOtp() {
Bogdan Timofte authored 4 days ago
3326
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3327
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3328
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3329
      // an unknown operator, so Safari's autofill anchor on the username stays.
3330
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3331
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3332
    }
3333

            
Bogdan Timofte authored 4 days ago
3334
    otpDigits.forEach((input, idx) => {
3335
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3336
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3337
        // A single box may receive several digits at once (autofill / typing fast).
3338
        if (input.value.replace(/\D/g, '').length > 1) {
3339
          fillOtp(input.value, idx);
3340
          return;
3341
        }
3342
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
3343
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3344
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
3345
        maybeSubmitOtp();
3346
      });
Bogdan Timofte authored 4 days ago
3347

            
3348
      input.addEventListener('paste', (e) => {
3349
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3350
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3351
        const text = (e.clipboardData || window.clipboardData).getData('text');
3352
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3353
      });
Bogdan Timofte authored 4 days ago
3354

            
3355
      input.addEventListener('keydown', (e) => {
3356
        if (e.key === 'Backspace') {
3357
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3358
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3359
          if (input.value) { setOtpDigit(idx, ''); }
3360
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3361
          syncOtpFields();
3362
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3363
          e.preventDefault();
3364
          otpDigits[idx - 1].focus();
3365
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3366
          e.preventDefault();
3367
          otpDigits[idx + 1].focus();
3368
        }
3369
      });
3370
    });
3371

            
Bogdan Timofte authored 4 days ago
3372
    // Focus the first OTP box only for a returning operator (username known).
3373
    // For an unknown operator, leave focus on the username field so Safari can
3374
    // present its OTP autofill anchored there without being dismissed by a focus
3375
    // change (pbx-admin pattern).
3376
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3377
    else if (loginAccount) loginAccount.focus();
3378
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3379

            
Bogdan Timofte authored 5 days ago
3380
    document.querySelectorAll('[data-page-link]').forEach(link => {
3381
      link.addEventListener('click', (event) => {
3382
        event.preventDefault();
3383
        showPage(link.dataset.pageLink, true);
3384
      });
3385
    });
3386

            
3387
    window.addEventListener('popstate', () => showPage(currentPage()));
3388

            
Bogdan Timofte authored 4 days ago
3389
    async function copyText(text) {
3390
      if (navigator.clipboard && window.isSecureContext) {
3391
        await navigator.clipboard.writeText(text);
3392
        return;
3393
      }
3394
      const input = document.createElement('textarea');
3395
      input.value = text;
3396
      input.setAttribute('readonly', '');
3397
      input.style.position = 'fixed';
3398
      input.style.left = '-10000px';
3399
      document.body.appendChild(input);
3400
      input.select();
3401
      document.execCommand('copy');
3402
      document.body.removeChild(input);
3403
    }
3404

            
3405
    $('copy-build').addEventListener('click', async () => {
3406
      try {
3407
        await copyText($('copy-build').dataset.buildDetails || '');
3408
        if (state.authenticated) msg('build details copied');
3409
      } catch (e) {
3410
        if (state.authenticated) msg('copy failed');
3411
      }
3412
    });
3413

            
Xdev Host Manager authored a week ago
3414
    $('login-form').addEventListener('submit', async (event) => {
3415
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3416
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3417
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3418
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3419
      try {
Xdev Host Manager authored a week ago
3420
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3421
        await refresh();
Xdev Host Manager authored a week ago
3422
      } catch (e) {
3423
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3424
      } finally {
Xdev Host Manager authored a week ago
3425
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3426
      }
Xdev Host Manager authored a week ago
3427
    });
3428

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

            
Bogdan Timofte authored 4 days ago
3434
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3435
      if (!isAuthLost(e)) msg(e.message);
3436
    }));
Xdev Host Manager authored a week ago
3437
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 5 days ago
3438
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 4 days ago
3439
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3440
      if (!isAuthLost(e)) msg(e.message);
3441
    }));
Bogdan Timofte authored 5 days ago
3442
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3443
    $('host-modal').addEventListener('click', (event) => {
3444
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3445
    });
Bogdan Timofte authored 5 days ago
3446
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3447
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3448
    });
Xdev Host Manager authored a week ago
3449

            
Xdev Host Manager authored a week ago
3450
    $('host-form').addEventListener('submit', async (event) => {
3451
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3452
      setHostFormBusy(true);
3453
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3454
      try {
3455
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3456
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3457
        closeHostModal();
Xdev Host Manager authored a week ago
3458
        msg('host saved');
3459
        await refresh();
Bogdan Timofte authored 5 days ago
3460
      } catch (e) {
Bogdan Timofte authored 4 days ago
3461
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3462
        setHostFormMessage(e.message, true);
3463
        msg(e.message);
3464
      } finally {
3465
        setHostFormBusy(false);
3466
      }
3467
    });
3468

            
3469
    $('host-form').addEventListener('invalid', (event) => {
3470
      setHostFormMessage('Complete the required host fields before saving.', true);
3471
    }, true);
3472

            
3473
    $('host-form').addEventListener('input', () => {
3474
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3475
    });
3476

            
3477
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3478
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3479
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3480
      setHostFormBusy(true);
3481
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3482
      try {
3483
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3484
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3485
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3486
        closeHostModal();
Xdev Host Manager authored a week ago
3487
        msg('host deleted');
3488
        await refresh();
Bogdan Timofte authored 5 days ago
3489
      } catch (e) {
Bogdan Timofte authored 4 days ago
3490
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3491
        setHostFormMessage(e.message, true);
3492
        msg(e.message);
3493
      } finally {
3494
        setHostFormBusy(false);
3495
      }
Xdev Host Manager authored a week ago
3496
    });
3497

            
3498
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3499
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3500
      try {
3501
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3502
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
3503
      } catch (e) {
3504
        if (!isAuthLost(e)) msg(e.message);
3505
      }
Xdev Host Manager authored a week ago
3506
    });
3507

            
Bogdan Timofte authored 4 days ago
3508
    refresh().catch(e => {
3509
      if (!isAuthLost(e)) showLogin(e.message);
3510
    });
Xdev Host Manager authored a week ago
3511
  </script>
3512
</body>
3513
</html>
3514
HTML
Bogdan Timofte authored 6 days ago
3515
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3516
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3517
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3518
    return $html;
Xdev Host Manager authored a week ago
3519
}