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

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

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

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

            
Bogdan Timofte authored 5 days ago
2618
      <section class="page" id="page-dns" data-page="dns" hidden>
2619
        <section class="toolbar">
2620
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2621
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2622
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2623
          <button id="write-tsv">Write local-hosts.tsv</button>
2624
        </section>
Xdev Host Manager authored a week ago
2625
      </section>
2626

            
Bogdan Timofte authored 5 days ago
2627
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2628
        <section class="panel">
2629
          <div class="panel-head">
2630
            <h2>Work Orders</h2>
2631
            <div class="stats" id="wo-stats"></div>
2632
          </div>
2633
          <div class="problems" id="work-orders"></div>
2634
        </section>
Xdev Host Manager authored a week ago
2635
      </section>
2636

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

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

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

            
Bogdan Timofte authored 4 days ago
2738
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
2739
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
2740
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
2741
  </div>
Bogdan Timofte authored 6 days ago
2742

            
Xdev Host Manager authored a week ago
2743
  <script>
Xdev Host Manager authored a week ago
2744
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
Bogdan Timofte authored 5 days ago
2745
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
2746

            
2747
    const $ = (id) => document.getElementById(id);
2748
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
2749
    const PAGE_PATHS = {
2750
      '/': 'overview',
2751
      '/overview': 'overview',
2752
      '/hosts': 'hosts',
2753
      '/dns': 'dns',
2754
      '/work-orders': 'work-orders',
2755
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
2756
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
2757
    };
Xdev Host Manager authored a week ago
2758

            
Bogdan Timofte authored 4 days ago
2759
    function isAuthLost(error) {
2760
      return !!(error && error.authLost);
2761
    }
2762

            
2763
    function authLostError(message) {
2764
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2765
      error.authLost = true;
2766
      return error;
2767
    }
2768

            
2769
    function handleAuthLost(message) {
2770
      state.authenticated = false;
2771
      msg('');
2772
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
2773
    }
2774

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

            
Bogdan Timofte authored 5 days ago
2795
    function currentPage() {
2796
      return PAGE_PATHS[window.location.pathname] || 'overview';
2797
    }
2798

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

            
Xdev Host Manager authored a week ago
2819
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
2820
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
2821
      document.body.classList.remove('is-app');
2822
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
2823
      $('app').style.display = 'none';
2824
      $('login-screen').style.display = 'flex';
2825
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
2826
      clearOtp();
Xdev Host Manager authored a week ago
2827
    }
2828

            
2829
    function showApp() {
Bogdan Timofte authored 6 days ago
2830
      document.body.classList.remove('is-login');
2831
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
2832
      $('login-screen').style.display = 'none';
2833
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
2834
      showPage(currentPage());
Xdev Host Manager authored a week ago
2835
    }
2836

            
Xdev Host Manager authored a week ago
2837
    async function refresh() {
2838
      const session = await api('/api/session');
2839
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
2840
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
2841
      showApp();
Xdev Host Manager authored a week ago
2842
      const data = await api('/api/hosts');
2843
      state.hosts = data.hosts || [];
2844
      state.problems = data.problems || [];
2845
      render(data);
Xdev Host Manager authored a week ago
2846
      await renderCa();
Xdev Host Manager authored a week ago
2847
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
2848
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
2849
    }
2850

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

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

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

            
2863
      renderHosts();
2864
    }
2865

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

            
Bogdan Timofte authored 5 days ago
2926
    function daysUntil(dateText) {
2927
      const time = Date.parse(dateText || '');
2928
      if (!Number.isFinite(time)) return null;
2929
      return Math.ceil((time - Date.now()) / 86400000);
2930
    }
2931

            
2932
    function certStatusClass(days) {
2933
      if (days === null) return '';
2934
      if (days < 0) return 'bad';
2935
      if (days <= 30) return 'warn';
2936
      return 'ok';
2937
    }
2938

            
2939
    function certStatusLabel(days) {
2940
      if (days === null) return 'validity unknown';
2941
      if (days < 0) return 'expired';
2942
      if (days === 0) return 'expires today';
2943
      return `${days}d remaining`;
2944
    }
2945

            
Xdev Host Manager authored a week ago
2946
    async function renderWorkOrders() {
2947
      try {
2948
        const data = await api('/api/work-orders');
2949
        state.workOrders = data.work_orders || [];
2950
        $('wo-stats').innerHTML = [
2951
          ['pending', data.counts.pending],
2952
          ['total', data.counts.work_orders],
2953
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2954

            
2955
        if (!state.workOrders.length) {
2956
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
2957
          return;
2958
        }
2959

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

            
Bogdan Timofte authored 4 days ago
2999
    async function renderDebugDatabase() {
3000
      if (!state.authenticated) return;
3001
      const data = await api('/api/debug/database/tables');
3002
      const tableSelect = $('debug-db-table');
3003
      const current = tableSelect.value;
3004
      const tables = data.tables || [];
3005
      tableSelect.innerHTML = tables.map(table => `<option value="${escapeHtml(table.name)}">${escapeHtml(table.name)} (${escapeHtml(String(table.rows))})</option>`).join('');
3006
      const selected = tables.some(table => table.name === current) ? current : (tables[0] ? tables[0].name : '');
3007
      tableSelect.value = selected;
3008
      $('debug-db-stats').innerHTML = [
3009
        ['tables', data.counts ? data.counts.tables : tables.length],
3010
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3011
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3012
      $('debug-db-meta').textContent = data.database || '';
3013
      if (selected) await renderDebugTable(selected);
3014
    }
3015

            
3016
    async function renderDebugTable(tableName) {
3017
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3018
      if (data.error) throw new Error(data.error);
3019
      $('debug-table-stats').innerHTML = [
3020
        ['table', data.table || tableName],
3021
        ['rows', data.row_count || 0],
3022
        ['shown', (data.rows || []).length],
3023
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3024
      renderDebugRows(data);
3025
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3026
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3027
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3028
    }
3029

            
3030
    function renderDebugRows(data) {
3031
      const rows = data.rows || [];
3032
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3033
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3034
    }
3035

            
3036
    function renderDebugObjectTable(rows, preferredKeys) {
3037
      const keys = preferredKeys && preferredKeys.length
3038
        ? preferredKeys
3039
        : Array.from(rows.reduce((set, row) => {
3040
            Object.keys(row || {}).forEach(key => set.add(key));
3041
            return set;
3042
          }, new Set()));
3043
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3044
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3045
      const body = rows.length
3046
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3047
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3048
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3049
    }
3050

            
3051
    function debugCell(value) {
3052
      if (value === null || value === undefined) return 'NULL';
3053
      if (Array.isArray(value)) return value.join(', ');
3054
      if (typeof value === 'object') return JSON.stringify(value);
3055
      return String(value);
3056
    }
3057

            
Xdev Host Manager authored a week ago
3058
    async function updateWorkOrderChecklist(id, itemId, checked) {
3059
      try {
3060
        await api('/api/work-orders/checklist', {
3061
          method: 'POST',
3062
          headers: { 'Content-Type': 'application/json' },
3063
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3064
        });
3065
        msg('work order updated');
3066
        await refresh();
Bogdan Timofte authored 4 days ago
3067
      } catch (e) {
3068
        if (isAuthLost(e)) return;
3069
        msg(e.message);
3070
        await refresh().catch(refreshError => {
3071
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3072
        });
3073
      }
Xdev Host Manager authored a week ago
3074
    }
3075

            
Xdev Host Manager authored a week ago
3076
    async function confirmWorkOrder(id) {
3077
      const typed = prompt(`Type ${id} to confirm this work order`);
3078
      if (typed !== id) return;
3079
      try {
3080
        await api('/api/work-orders/confirm', {
3081
          method: 'POST',
3082
          headers: { 'Content-Type': 'application/json' },
3083
          body: JSON.stringify({ id, confirm: typed })
3084
        });
3085
        msg('work order confirmed; local-hosts.tsv written');
3086
        await refresh();
Bogdan Timofte authored 4 days ago
3087
      } catch (e) {
3088
        if (isAuthLost(e)) return;
3089
        msg(e.message);
3090
      }
Xdev Host Manager authored a week ago
3091
    }
3092

            
Xdev Host Manager authored a week ago
3093
    function renderHosts() {
3094
      const filter = $('filter').value.toLowerCase();
3095
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3096
        .slice()
3097
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3098
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3099
        .map(h => {
3100
          const problems = state.problems.filter(p => p.host_id === h.id);
3101
          const cls = problems.length ? 'warn' : 'ok';
3102
          return `<tr data-id="${escapeHtml(h.id)}">
3103
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3104
            <td>${escapeHtml(h.hosts_ip || '')}</td>
3105
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 4 days ago
3106
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3107
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3108
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3109
            <td>${escapeHtml(h.status || '')}</td>
3110
          </tr>`;
3111
        }).join('');
3112
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
3113
    }
3114

            
Bogdan Timofte authored 4 days ago
3115
    function renderNamePills(host) {
3116
      const declared = host.declared_names || host.names || [];
3117
      const derived = host.derived_names || [];
3118
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3119
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
3120
      return declaredHtml + derivedHtml;
3121
    }
3122

            
Xdev Host Manager authored a week ago
3123
    function editHost(id) {
3124
      const host = state.hosts.find(h => h.id === id);
3125
      if (!host) return;
3126
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3127
      clearHostFormMessage();
3128
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 4 days ago
3129
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 5 days ago
3130
      hostField('roles').value = (host.roles || []).join(' ');
3131
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3132
      openHostModal('Edit host');
3133
    }
3134

            
3135
    function newHost() {
3136
      const form = $('host-form');
3137
      form.reset();
Bogdan Timofte authored 5 days ago
3138
      clearHostFormMessage();
3139
      hostField('status').value = 'active';
3140
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3141
      openHostModal('New host');
3142
    }
3143

            
3144
    function openHostModal(title) {
3145
      $('host-modal-title').textContent = title || 'Edit host';
3146
      $('host-modal').hidden = false;
3147
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3148
      hostFormSnapshot = hostFormState();
3149
      hostField('id').focus();
3150
    }
3151

            
3152
    function requestCloseHostModal() {
3153
      if ($('save-host').disabled) return;
3154
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3155
      closeHostModal();
Bogdan Timofte authored 5 days ago
3156
    }
3157

            
3158
    function closeHostModal() {
3159
      $('host-modal').hidden = true;
3160
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3161
      setHostFormBusy(false);
3162
      clearHostFormMessage();
3163
      hostFormSnapshot = '';
3164
    }
3165

            
3166
    function hostField(name) {
3167
      return $('host-form').elements.namedItem(name);
3168
    }
3169

            
3170
    function hostFormState() {
3171
      return JSON.stringify(formObject($('host-form')));
3172
    }
3173

            
3174
    function hostFormDirty() {
3175
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3176
    }
3177

            
3178
    function setHostFormBusy(busy) {
3179
      $('save-host').disabled = busy;
3180
      $('delete-host').disabled = busy;
3181
      $('close-host-modal').disabled = busy;
3182
    }
3183

            
3184
    function setHostFormMessage(text, isError = false) {
3185
      const message = $('host-form-message');
3186
      message.textContent = text || '';
3187
      message.classList.toggle('error', !!isError);
3188
    }
3189

            
3190
    function clearHostFormMessage() {
3191
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3192
    }
3193

            
3194
    function formObject(form) {
3195
      return Object.fromEntries(new FormData(form).entries());
3196
    }
3197

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

            
Bogdan Timofte authored 6 days ago
3203
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3204

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

            
3210
    if (loginAccount) {
3211
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3212
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3213
      loginAccount.addEventListener('input', () => {
3214
        const value = (loginAccount.value || '').trim();
3215
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3216
      });
3217
    }
3218

            
Xdev Host Manager authored a week ago
3219
    function setOtpDigit(idx, value) {
3220
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3221
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3222
      otpDigits[idx].classList.toggle('filled', !!digit);
3223
    }
3224

            
Bogdan Timofte authored 4 days ago
3225
    // Move focus to the next empty box: forward from idx, then wrapping to the
3226
    // start. This lets out-of-order entry continue (e.g. after the last box,
3227
    // jump back to the first still-empty box). Stays put when all boxes are full.
3228
    function advanceFocus(idx) {
3229
      for (let i = idx + 1; i < otpDigits.length; i++) {
3230
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3231
      }
3232
      for (let i = 0; i <= idx; i++) {
3233
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3234
      }
3235
    }
3236

            
Bogdan Timofte authored 4 days ago
3237
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3238
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3239
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
3240
      const digits = (text || '').replace(/\D/g, '').split('');
3241
      if (!digits.length) return;
3242
      let last = startIdx;
3243
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3244
        last = startIdx + i;
3245
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3246
      }
Bogdan Timofte authored 4 days ago
3247
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3248
      advanceFocus(last);
Xdev Host Manager authored a week ago
3249
      maybeSubmitOtp();
3250
    }
3251

            
Bogdan Timofte authored 4 days ago
3252
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3253
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3254
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3255
    function maybeSubmitOtp() {
3256
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3257
    }
3258
    function clearOtp() {
Bogdan Timofte authored 4 days ago
3259
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3260
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3261
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3262
      // an unknown operator, so Safari's autofill anchor on the username stays.
3263
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3264
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3265
    }
3266

            
Bogdan Timofte authored 4 days ago
3267
    otpDigits.forEach((input, idx) => {
3268
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3269
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3270
        // A single box may receive several digits at once (autofill / typing fast).
3271
        if (input.value.replace(/\D/g, '').length > 1) {
3272
          fillOtp(input.value, idx);
3273
          return;
3274
        }
3275
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
3276
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3277
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
3278
        maybeSubmitOtp();
3279
      });
Bogdan Timofte authored 4 days ago
3280

            
3281
      input.addEventListener('paste', (e) => {
3282
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3283
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3284
        const text = (e.clipboardData || window.clipboardData).getData('text');
3285
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3286
      });
Bogdan Timofte authored 4 days ago
3287

            
3288
      input.addEventListener('keydown', (e) => {
3289
        if (e.key === 'Backspace') {
3290
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3291
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3292
          if (input.value) { setOtpDigit(idx, ''); }
3293
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3294
          syncOtpFields();
3295
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3296
          e.preventDefault();
3297
          otpDigits[idx - 1].focus();
3298
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3299
          e.preventDefault();
3300
          otpDigits[idx + 1].focus();
3301
        }
3302
      });
3303
    });
3304

            
Bogdan Timofte authored 4 days ago
3305
    // Focus the first OTP box only for a returning operator (username known).
3306
    // For an unknown operator, leave focus on the username field so Safari can
3307
    // present its OTP autofill anchored there without being dismissed by a focus
3308
    // change (pbx-admin pattern).
3309
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3310
    else if (loginAccount) loginAccount.focus();
3311
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3312

            
Bogdan Timofte authored 5 days ago
3313
    document.querySelectorAll('[data-page-link]').forEach(link => {
3314
      link.addEventListener('click', (event) => {
3315
        event.preventDefault();
3316
        showPage(link.dataset.pageLink, true);
3317
      });
3318
    });
3319

            
3320
    window.addEventListener('popstate', () => showPage(currentPage()));
3321

            
Bogdan Timofte authored 4 days ago
3322
    async function copyText(text) {
3323
      if (navigator.clipboard && window.isSecureContext) {
3324
        await navigator.clipboard.writeText(text);
3325
        return;
3326
      }
3327
      const input = document.createElement('textarea');
3328
      input.value = text;
3329
      input.setAttribute('readonly', '');
3330
      input.style.position = 'fixed';
3331
      input.style.left = '-10000px';
3332
      document.body.appendChild(input);
3333
      input.select();
3334
      document.execCommand('copy');
3335
      document.body.removeChild(input);
3336
    }
3337

            
3338
    $('copy-build').addEventListener('click', async () => {
3339
      try {
3340
        await copyText($('copy-build').dataset.buildDetails || '');
3341
        if (state.authenticated) msg('build details copied');
3342
      } catch (e) {
3343
        if (state.authenticated) msg('copy failed');
3344
      }
3345
    });
3346

            
Xdev Host Manager authored a week ago
3347
    $('login-form').addEventListener('submit', async (event) => {
3348
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3349
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3350
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3351
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3352
      try {
Xdev Host Manager authored a week ago
3353
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3354
        await refresh();
Xdev Host Manager authored a week ago
3355
      } catch (e) {
3356
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3357
      } finally {
Xdev Host Manager authored a week ago
3358
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3359
      }
Xdev Host Manager authored a week ago
3360
    });
3361

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

            
Bogdan Timofte authored 4 days ago
3367
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3368
      if (!isAuthLost(e)) msg(e.message);
3369
    }));
Xdev Host Manager authored a week ago
3370
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 5 days ago
3371
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 4 days ago
3372
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3373
      if (!isAuthLost(e)) msg(e.message);
3374
    }));
3375
    $('debug-db-table').addEventListener('change', () => renderDebugTable($('debug-db-table').value).catch(e => {
3376
      if (!isAuthLost(e)) msg(e.message);
3377
    }));
Bogdan Timofte authored 5 days ago
3378
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3379
    $('host-modal').addEventListener('click', (event) => {
3380
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3381
    });
Bogdan Timofte authored 5 days ago
3382
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3383
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3384
    });
Xdev Host Manager authored a week ago
3385

            
Xdev Host Manager authored a week ago
3386
    $('host-form').addEventListener('submit', async (event) => {
3387
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3388
      setHostFormBusy(true);
3389
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3390
      try {
3391
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3392
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3393
        closeHostModal();
Xdev Host Manager authored a week ago
3394
        msg('host saved');
3395
        await refresh();
Bogdan Timofte authored 5 days ago
3396
      } catch (e) {
Bogdan Timofte authored 4 days ago
3397
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3398
        setHostFormMessage(e.message, true);
3399
        msg(e.message);
3400
      } finally {
3401
        setHostFormBusy(false);
3402
      }
3403
    });
3404

            
3405
    $('host-form').addEventListener('invalid', (event) => {
3406
      setHostFormMessage('Complete the required host fields before saving.', true);
3407
    }, true);
3408

            
3409
    $('host-form').addEventListener('input', () => {
3410
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3411
    });
3412

            
3413
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3414
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3415
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3416
      setHostFormBusy(true);
3417
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3418
      try {
3419
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3420
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3421
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3422
        closeHostModal();
Xdev Host Manager authored a week ago
3423
        msg('host deleted');
3424
        await refresh();
Bogdan Timofte authored 5 days ago
3425
      } catch (e) {
Bogdan Timofte authored 4 days ago
3426
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3427
        setHostFormMessage(e.message, true);
3428
        msg(e.message);
3429
      } finally {
3430
        setHostFormBusy(false);
3431
      }
Xdev Host Manager authored a week ago
3432
    });
3433

            
3434
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3435
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3436
      try {
3437
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3438
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
3439
      } catch (e) {
3440
        if (!isAuthLost(e)) msg(e.message);
3441
      }
Xdev Host Manager authored a week ago
3442
    });
3443

            
Bogdan Timofte authored 4 days ago
3444
    refresh().catch(e => {
3445
      if (!isAuthLost(e)) showLogin(e.message);
3446
    });
Xdev Host Manager authored a week ago
3447
  </script>
3448
</body>
3449
</html>
3450
HTML
Bogdan Timofte authored 6 days ago
3451
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3452
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3453
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3454
    return $html;
Xdev Host Manager authored a week ago
3455
}