LocalAuthority / scripts / host_manager.pl
Newer Older
3516 lines | 134.236kb
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 { min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); }
Bogdan Timofte authored 4 days ago
2474
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2475
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2476
    .host-tools input { max-width: 240px; }
2477
    .modal-backdrop {
2478
      position: fixed;
2479
      inset: 0;
2480
      z-index: 10;
2481
      display: grid;
2482
      align-items: start;
2483
      justify-items: center;
2484
      padding: 72px 16px 24px;
2485
      background: rgba(21,32,51,.48);
2486
      overflow: auto;
2487
    }
2488
    .modal-backdrop[hidden] { display: none; }
2489
    .modal {
2490
      width: min(840px, 100%);
2491
      max-height: calc(100dvh - 96px);
2492
      overflow: auto;
2493
      background: var(--panel);
2494
      border: 1px solid var(--line);
2495
      border-radius: 8px;
2496
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2497
    }
2498
    .modal-head {
2499
      position: sticky;
2500
      top: 0;
2501
      z-index: 1;
2502
      display: flex;
2503
      align-items: center;
2504
      justify-content: space-between;
2505
      gap: 12px;
2506
      padding: 12px 14px;
2507
      border-bottom: 1px solid var(--line);
2508
      background: #fafbfc;
2509
    }
2510
    .modal-head h2 { margin: 0; font-size: 14px; }
2511
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2512
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2513
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2514
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2515
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2516
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2517
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2518
      #message { max-width: 100%; }
2519
      .panel-head { align-items: stretch; flex-direction: column; }
2520
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2521
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2522
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2523
      .modal-backdrop { padding-top: 16px; }
2524
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2525
      .grid { grid-template-columns: 1fr; }
2526
      table { min-width: 760px; }
2527
      .table-wrap { overflow-x: auto; }
2528
    }
2529
  </style>
2530
</head>
Bogdan Timofte authored 6 days ago
2531
<body class="is-login">
Xdev Host Manager authored a week ago
2532

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
2767
    function isAuthLost(error) {
2768
      return !!(error && error.authLost);
2769
    }
2770

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

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

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

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

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

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

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

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

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

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

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

            
2871
      renderHosts();
2872
    }
2873

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

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

            
2940
    function certStatusClass(days) {
2941
      if (days === null) return '';
2942
      if (days < 0) return 'bad';
2943
      if (days <= 30) return 'warn';
2944
      return 'ok';
2945
    }
2946

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
3230
    function hostField(name) {
3231
      return $('host-form').elements.namedItem(name);
3232
    }
3233

            
3234
    function hostFormState() {
3235
      return JSON.stringify(formObject($('host-form')));
3236
    }
3237

            
3238
    function hostFormDirty() {
3239
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3240
    }
3241

            
3242
    function setHostFormBusy(busy) {
3243
      $('save-host').disabled = busy;
3244
      $('delete-host').disabled = busy;
3245
      $('close-host-modal').disabled = busy;
3246
    }
3247

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

            
3254
    function clearHostFormMessage() {
3255
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3256
    }
3257

            
3258
    function formObject(form) {
3259
      return Object.fromEntries(new FormData(form).entries());
3260
    }
3261

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

            
Bogdan Timofte authored 6 days ago
3267
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3268

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

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

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

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

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

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

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

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

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

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

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

            
3384
    window.addEventListener('popstate', () => showPage(currentPage()));
3385

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

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

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

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

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

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

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

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

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

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

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