LocalAuthority / scripts / host_manager.pl
Newer Older
4928 lines | 195.715kb
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
);
Bogdan Timofte authored 3 days ago
29
my $print_local_hosts_tsv = 0;
Xdev Host Manager authored a week ago
30

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

            
Bogdan Timofte authored 3 days ago
55
if ($print_local_hosts_tsv) {
56
    print render_local_hosts_tsv(load_registry());
57
    exit 0;
58
}
59

            
Xdev Host Manager authored a week ago
60
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
61
my %sessions;
62

            
63
my $server = IO::Socket::INET->new(
64
    LocalHost => $opt{bind},
65
    LocalPort => $opt{port},
66
    Proto => 'tcp',
67
    Listen => 10,
68
    ReuseAddr => 1,
69
) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
70

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

            
76
while (my $client = $server->accept) {
77
    eval {
78
        $client->autoflush(1);
79
        handle_client($client);
80
    };
81
    if ($@) {
82
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
83
    }
84
    close $client;
85
}
86

            
87
sub usage {
88
    print <<"EOF";
89
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
90

            
91
Environment:
92
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
93
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
Bogdan Timofte authored 4 days ago
94
  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
Xdev Host Manager authored a week ago
95
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
96
  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
Xdev Host Manager authored a week ago
97
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Bogdan Timofte authored 3 days ago
98
  --print-local-hosts-tsv       Print the runtime DNS manifest and exit.
Xdev Host Manager authored a week ago
99

            
Bogdan Timofte authored 4 days ago
100
SQLite is the runtime source of truth. YAML files seed a new database and remain
101
download/export compatibility artifacts. The nginx vhost keeps registry, CA,
102
work order and download endpoints behind OTP.
Xdev Host Manager authored a week ago
103
EOF
104
}
105

            
106
sub handle_client {
107
    my ($client) = @_;
108
    my $request_line = <$client>;
109
    return unless defined $request_line;
110
    $request_line =~ s/\r?\n$//;
111
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
112
    return send_text($client, 400, 'bad request') unless $method && $target;
113

            
114
    my %headers;
115
    while (my $line = <$client>) {
116
        $line =~ s/\r?\n$//;
117
        last if $line eq '';
118
        my ($k, $v) = split /:\s*/, $line, 2;
119
        $headers{lc $k} = $v if defined $k && defined $v;
120
    }
121

            
122
    my $body = '';
123
    if (($headers{'content-length'} || 0) > 0) {
124
        read($client, $body, int($headers{'content-length'}));
125
    }
126

            
127
    my ($path, $query) = split /\?/, $target, 2;
128
    my %query = parse_params($query || '');
129

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

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

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

            
209
    if ($method eq 'POST' && $path =~ m{^/api/}) {
210
        if ($path eq '/api/hosts/upsert') {
211
            my $payload = request_payload(\%headers, $body);
212
            return upsert_host($client, $payload);
213
        }
214
        if ($path eq '/api/hosts/delete') {
215
            my $payload = request_payload(\%headers, $body);
216
            return delete_host($client, $payload->{id} || '');
217
        }
Bogdan Timofte authored 3 days ago
218
        if ($path eq '/api/hosts/certificate') {
219
            my $payload = request_payload(\%headers, $body);
220
            return set_host_certificate($client, $payload);
221
        }
222
        if ($path eq '/api/hosts/issue-certificate') {
223
            my $payload = request_payload(\%headers, $body);
224
            return issue_host_certificate($client, $payload);
225
        }
Bogdan Timofte authored 4 days ago
226
        if ($path eq '/api/vhosts/reassign') {
227
            my $payload = request_payload(\%headers, $body);
228
            return reassign_vhost($client, $payload);
229
        }
Bogdan Timofte authored 4 days ago
230
        if ($path eq '/api/vhosts/upsert') {
231
            my $payload = request_payload(\%headers, $body);
232
            return upsert_vhost($client, $payload);
233
        }
234
        if ($path eq '/api/vhosts/delete') {
235
            my $payload = request_payload(\%headers, $body);
236
            return delete_vhost($client, $payload);
237
        }
Bogdan Timofte authored 3 days ago
238
        if ($path eq '/api/vhosts/certificate') {
239
            my $payload = request_payload(\%headers, $body);
240
            return set_vhost_certificate($client, $payload);
241
        }
242
        if ($path eq '/api/vhosts/issue-certificate') {
243
            my $payload = request_payload(\%headers, $body);
244
            return issue_vhost_certificate($client, $payload);
245
        }
Xdev Host Manager authored a week ago
246
        if ($path eq '/api/work-orders/confirm') {
247
            my $payload = request_payload(\%headers, $body);
248
            return confirm_work_order($client, $payload);
249
        }
Xdev Host Manager authored a week ago
250
        if ($path eq '/api/work-orders/checklist') {
251
            my $payload = request_payload(\%headers, $body);
252
            return update_work_order_checklist($client, $payload);
253
        }
Xdev Host Manager authored a week ago
254
        if ($path eq '/api/render/local-hosts-tsv') {
255
            my $registry = load_registry();
256
            my $content = render_local_hosts_tsv($registry);
257
            backup_file($opt{local_hosts_tsv});
258
            write_file($opt{local_hosts_tsv}, $content);
259
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
260
        }
261
    }
262

            
263
    return send_json($client, 404, { error => 'not_found' });
264
}
265

            
Bogdan Timofte authored 5 days ago
266
sub app_page_path {
267
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
268
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
269
}
270

            
Xdev Host Manager authored a week ago
271
sub load_registry {
Bogdan Timofte authored 4 days ago
272
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
273
    normalize_registry_policy($registry);
274
    return $registry;
Xdev Host Manager authored a week ago
275
}
276

            
277
sub save_registry {
278
    my ($registry) = @_;
279
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
280
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
281
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
282
}
283

            
Xdev Host Manager authored a week ago
284
sub load_work_orders {
Bogdan Timofte authored 4 days ago
285
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
286
}
287

            
288
sub save_work_orders {
289
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
290
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
291
}
292

            
293
sub work_orders_payload {
294
    my ($orders) = @_;
295
    my $pending = 0;
296
    for my $wo (@{ $orders->{work_orders} || [] }) {
297
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
298
    }
299
    return {
300
        version => $orders->{version},
301
        work_orders => $orders->{work_orders} || [],
302
        counts => {
303
            work_orders => scalar @{ $orders->{work_orders} || [] },
304
            pending => $pending,
305
        },
306
    };
307
}
308

            
309
sub confirm_work_order {
310
    my ($client, $payload) = @_;
311
    my $id = clean_scalar($payload->{id} || '');
312
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
313
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
314

            
315
    my $orders = load_work_orders();
316
    my $work_order;
317
    for my $wo (@{ $orders->{work_orders} || [] }) {
318
        if (($wo->{id} || '') eq $id) {
319
            $work_order = $wo;
320
            last;
321
        }
322
    }
323
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
324
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
325
    my $incomplete = incomplete_work_order_items($work_order);
326
    return send_json($client, 409, {
327
        error => 'work_order_incomplete',
328
        incomplete => $incomplete,
329
    }) if @$incomplete;
Xdev Host Manager authored a week ago
330

            
331
    my $registry = load_registry();
332
    my $results = apply_work_order($registry, $work_order);
333
    $work_order->{status} = 'confirmed';
334
    $work_order->{confirmed_at} = iso_now();
335
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
336

            
337
    save_registry($registry);
338
    save_work_orders($orders);
339
    backup_file($opt{local_hosts_tsv});
340
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
341

            
342
    return send_json($client, 200, {
343
        ok => json_bool(1),
344
        work_order => $work_order,
345
        results => $results,
346
        local_hosts_tsv => $opt{local_hosts_tsv},
347
    });
348
}
349

            
Xdev Host Manager authored a week ago
350
sub update_work_order_checklist {
351
    my ($client, $payload) = @_;
352
    my $id = clean_scalar($payload->{id} || '');
353
    my $item_id = clean_scalar($payload->{item_id} || '');
354
    my $status = clean_scalar($payload->{status} || '');
355
    my $notes = clean_scalar($payload->{notes} || '');
356
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
357
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
358
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
359

            
360
    my $orders = load_work_orders();
361
    my $work_order;
362
    for my $wo (@{ $orders->{work_orders} || [] }) {
363
        if (($wo->{id} || '') eq $id) {
364
            $work_order = $wo;
365
            last;
366
        }
367
    }
368
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
369
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
370

            
371
    my $item;
372
    for my $candidate (@{ $work_order->{checklist} || [] }) {
373
        if (($candidate->{id} || '') eq $item_id) {
374
            $item = $candidate;
375
            last;
376
        }
377
    }
378
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
379

            
380
    $item->{status} = $status;
381
    $item->{updated_at} = iso_now();
382
    $item->{notes} = $notes if length $notes;
383
    save_work_orders($orders);
384
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
385
}
386

            
387
sub incomplete_work_order_items {
388
    my ($work_order) = @_;
389
    my @incomplete;
390
    for my $item (@{ $work_order->{checklist} || [] }) {
391
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
392
    }
393
    return \@incomplete;
394
}
395

            
Xdev Host Manager authored a week ago
396
sub apply_work_order {
397
    my ($registry, $work_order) = @_;
398
    my @results;
399
    for my $action (@{ $work_order->{actions} || [] }) {
400
        my $type = $action->{type} || '';
401
        if ($type eq 'remove_name') {
402
            my $host_id = $action->{host_id} || '';
403
            my $name = $action->{name} || '';
404
            my $removed = 0;
405
            for my $host (@{ $registry->{hosts} || [] }) {
406
                next unless ($host->{id} || '') eq $host_id;
Bogdan Timofte authored 4 days ago
407
                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
408
                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
409
                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
410
                $host->{aliases} = \@kept_aliases;
411
                $host->{vhosts} = \@kept_vhosts;
Xdev Host Manager authored a week ago
412
                last;
413
            }
414
            push @results, {
415
                type => $type,
416
                host_id => $host_id,
417
                name => $name,
418
                removed => json_bool($removed),
419
            };
420
        } else {
421
            die "Unsupported work order action: $type\n";
422
        }
423
    }
424
    return \@results;
425
}
426

            
Xdev Host Manager authored a week ago
427
sub registry_payload {
428
    my ($registry) = @_;
429
    my $problems = analyze_hosts($registry->{hosts});
Bogdan Timofte authored 3 days ago
430
    my $dbh = dbh();
Bogdan Timofte authored 3 days ago
431
    my %host_tls = host_tls_payloads($dbh);
432
    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }) } @{ $registry->{hosts} };
Bogdan Timofte authored 3 days ago
433
    my @vhosts = vhost_payloads($dbh);
434
    my @certificates = certificate_payloads($dbh);
Bogdan Timofte authored 4 days ago
435
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
436
    return {
437
        version => $registry->{version},
438
        updated_at => $registry->{updated_at},
439
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
440
        hosts => \@hosts,
Bogdan Timofte authored 3 days ago
441
        vhosts => \@vhosts,
442
        certificates => \@certificates,
Xdev Host Manager authored a week ago
443
        problems => $problems,
444
        counts => {
445
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 3 days ago
446
            vhosts => scalar(@vhosts) || $vhost_count,
Xdev Host Manager authored a week ago
447
            problems => scalar @$problems,
448
        },
449
    };
450
}
451

            
Bogdan Timofte authored 3 days ago
452
sub host_tls_payloads {
453
    my ($dbh) = @_;
454
    my %rows;
455
    my $sth = $dbh->prepare(<<'SQL');
456
SELECT
457
    ht.host_fqdn,
458
    ht.certificate_id,
459
    c.common_name,
460
    c.not_after,
461
    c.fingerprint_sha256,
462
    c.status AS certificate_status
463
FROM host_tls ht
464
LEFT JOIN certificates c ON c.certificate_id = ht.certificate_id
465
ORDER BY ht.host_fqdn
466
SQL
467
    $sth->execute;
468
    while (my $row = $sth->fetchrow_hashref) {
469
        my $host_fqdn = clean_scalar($row->{host_fqdn} || '');
470
        next unless length $host_fqdn;
471
        my $cert_id = clean_scalar($row->{certificate_id} || '');
472
        my %payload = (
473
            certificate_id => $cert_id,
474
        );
475
        if (length $cert_id) {
476
            $payload{certificate} = {
477
                id => $cert_id,
478
                name => $cert_id,
479
                common_name => clean_scalar($row->{common_name} || ''),
480
                status => clean_scalar($row->{certificate_status} || ''),
481
                not_after => clean_scalar($row->{not_after} || ''),
482
                fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
483
                has_private_key => json_bool(ca_private_key_exists($cert_id)),
484
            };
485
        }
486
        $rows{$host_fqdn} = \%payload;
487
    }
488
    return %rows;
489
}
490

            
Bogdan Timofte authored 3 days ago
491
sub vhost_payloads {
492
    my ($dbh) = @_;
493
    my @rows;
494
    my $sth = $dbh->prepare(<<'SQL');
495
SELECT
496
    v.vhost_fqdn,
497
    v.host_fqdn,
498
    v.status AS vhost_status,
499
    v.certificate_id,
500
    h.legacy_id,
501
    h.monitoring,
502
    h.status AS host_status,
503
    c.common_name,
504
    c.not_after,
505
    c.fingerprint_sha256,
506
    c.status AS certificate_status
507
FROM vhosts v
508
JOIN hosts h ON h.fqdn = v.host_fqdn
509
LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
510
WHERE v.status = 'active'
511
ORDER BY v.vhost_fqdn
512
SQL
513
    $sth->execute;
514
    while (my $row = $sth->fetchrow_hashref) {
515
        my $cert_id = clean_scalar($row->{certificate_id} || '');
516
        my %certificate = $cert_id ? (
517
            id => $cert_id,
518
            name => $cert_id,
519
            common_name => clean_scalar($row->{common_name} || ''),
520
            status => clean_scalar($row->{certificate_status} || ''),
521
            not_after => clean_scalar($row->{not_after} || ''),
522
            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
Bogdan Timofte authored 3 days ago
523
            has_private_key => json_bool(ca_private_key_exists($cert_id)),
Bogdan Timofte authored 3 days ago
524
        ) : ();
525
        push @rows, {
526
            vhost => $row->{vhost_fqdn},
527
            vhost_fqdn => $row->{vhost_fqdn},
528
            host_id => $row->{legacy_id} || '',
529
            host_fqdn => $row->{host_fqdn},
530
            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
531
            monitoring => $row->{monitoring} || '',
532
            status => $row->{host_status} || $row->{vhost_status} || '',
533
            vhost_status => $row->{vhost_status} || '',
534
            certificate_id => $cert_id,
535
            certificate => $cert_id ? \%certificate : undef,
536
        };
537
    }
538
    return @rows;
539
}
540

            
541
sub certificate_payloads {
542
    my ($dbh) = @_;
543
    my @certificates;
544
    my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
545
    $sth->execute('retired');
546
    while (my $row = $sth->fetchrow_hashref) {
547
        my $id = clean_scalar($row->{certificate_id} || '');
548
        next unless $id;
549
        push @certificates, {
550
            id => $id,
551
            name => $id,
552
            host_fqdn => $row->{host_fqdn} || '',
553
            common_name => $row->{common_name} || '',
554
            subject => $row->{subject} || '',
555
            issuer => $row->{issuer} || '',
556
            serial => $row->{serial} || '',
557
            status => $row->{status} || '',
558
            not_before => $row->{not_before} || '',
559
            not_after => $row->{not_after} || '',
560
            fingerprint_sha256 => $row->{fingerprint_sha256} || '',
561
            dns_names => [ certificate_dns_names($dbh, $id) ],
Bogdan Timofte authored 3 days ago
562
            has_private_key => json_bool(ca_private_key_exists($id)),
Bogdan Timofte authored 3 days ago
563
        };
564
    }
565
    return @certificates;
566
}
567

            
568
sub certificate_dns_names {
569
    my ($dbh, $certificate_id) = @_;
570
    my @names;
571
    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
572
    $sth->execute($certificate_id);
573
    while (my ($name) = $sth->fetchrow_array) {
574
        push @names, $name;
575
    }
576
    return @names;
577
}
578

            
Xdev Host Manager authored a week ago
579
sub upsert_host {
580
    my ($client, $payload) = @_;
581
    my $id = clean_id($payload->{id} || '');
582
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
583

            
Bogdan Timofte authored 4 days ago
584
    my $ip = canonical_ip($payload);
585
    return send_json($client, 400, { error => 'missing_ip' }) unless $ip;
Xdev Host Manager authored a week ago
586

            
Bogdan Timofte authored 4 days ago
587
    my $fqdn = canonical_host_fqdn($payload);
588
    return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
589
    my @aliases = clean_alias_names($payload);
Xdev Host Manager authored a week ago
590

            
591
    my $registry = load_registry();
Bogdan Timofte authored 4 days ago
592
    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
593
    my @vhosts = defined $payload->{vhosts}
594
        ? clean_vhost_names($payload)
595
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
596
    my %host = (
597
        id => $id,
Bogdan Timofte authored 4 days ago
598
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
599
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
600
        ip => $ip,
601
        aliases => \@aliases,
602
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
603
        roles => [ clean_list($payload->{roles}) ],
604
        sources => [ clean_list($payload->{sources}) ],
605
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
606
        notes => clean_scalar($payload->{notes} || ''),
607
    );
608

            
Bogdan Timofte authored 4 days ago
609
    my $response = eval {
610
        my $replaced = 0;
611
        for my $i (0 .. $#{ $registry->{hosts} }) {
612
            if ($registry->{hosts}->[$i]{id} eq $id) {
613
                $registry->{hosts}->[$i] = \%host;
614
                $replaced = 1;
615
                last;
616
            }
Xdev Host Manager authored a week ago
617
        }
Bogdan Timofte authored 4 days ago
618
        push @{ $registry->{hosts} }, \%host unless $replaced;
619
        save_registry($registry);
620
        1;
621
    };
622
    if (!$response) {
623
        my $err = $@ || 'upsert_failed';
624
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
625
            if $err =~ /alias_conflict:/;
626
        die $err;
Xdev Host Manager authored a week ago
627
    }
628
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
629
}
630

            
631
sub delete_host {
632
    my ($client, $id) = @_;
633
    $id = clean_id($id);
634
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
635

            
636
    my $registry = load_registry();
637
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
638
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
639
    $registry->{hosts} = \@kept;
640
    save_registry($registry);
641
    return send_json($client, 200, { ok => json_bool(1) });
642
}
643

            
Bogdan Timofte authored 4 days ago
644
sub reassign_vhost {
645
    my ($client, $payload) = @_;
646
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
647
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
648
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
649
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
650

            
651
    my $dbh = dbh();
652
    my ($current_fqdn) = $dbh->selectrow_array(
653
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
654
        undef,
655
        $vhost,
656
    );
657
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
658
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
659
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
660

            
661
    my $result = eval {
662
        with_transaction($dbh, sub {
663
            my $now = iso_now();
664
            $dbh->do(
665
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
666
                undef,
667
                $target_fqdn, $now, $vhost,
668
            );
669

            
670
            my $registry = load_registry_from_db();
671
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
672
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
673

            
674
            upsert_host_to_db($dbh, $target_host) if $target_host;
675
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
676
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
677
        });
678
        1;
679
    };
680
    if (!$result) {
681
        my $err = $@ || 'vhost_reassign_failed';
682
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
683
    }
684
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
685
}
686

            
Bogdan Timofte authored 4 days ago
687
sub upsert_vhost {
688
    my ($client, $payload) = @_;
689
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
690
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
691
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
692
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
693

            
694
    my $dbh = dbh();
695
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
696
    my ($current_fqdn) = $dbh->selectrow_array(
697
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
698
        undef,
699
        $vhost,
700
    );
701

            
702
    my $result = eval {
703
        with_transaction($dbh, sub {
704
            my $now = iso_now();
705
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
706

            
707
            my $registry = load_registry_from_db();
708
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
709
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
710

            
711
            upsert_host_to_db($dbh, $target_host) if $target_host;
712
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
713
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
714
        });
715
        1;
716
    };
717
    if (!$result) {
718
        my $err = $@ || 'vhost_upsert_failed';
719
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
720
    }
721
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
722
}
723

            
724
sub delete_vhost {
725
    my ($client, $payload) = @_;
726
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
727
    my $confirm = normalize_dns_name($payload->{confirm} || '');
728
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
729
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
730

            
731
    my $dbh = dbh();
732
    my ($current_fqdn) = $dbh->selectrow_array(
733
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
734
        undef,
735
        $vhost,
736
    );
737
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
738

            
739
    my $result = eval {
740
        with_transaction($dbh, sub {
741
            my $now = iso_now();
742
            $dbh->do(
743
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
744
                undef,
745
                $now, $vhost,
746
            );
747

            
748
            my $registry = load_registry_from_db();
749
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
750
            upsert_host_to_db($dbh, $current_host) if $current_host;
751
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
752
        });
753
        1;
754
    };
755
    if (!$result) {
756
        my $err = $@ || 'vhost_delete_failed';
757
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
758
    }
759
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
760
}
761

            
Bogdan Timofte authored 3 days ago
762
sub set_host_certificate {
763
    my ($client, $payload) = @_;
764
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
765
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
766
    my $certificate_id = clean_certificate_id($raw_certificate_id);
767
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
768
    return send_json($client, 400, { error => 'invalid_certificate' })
769
        if length($raw_certificate_id) && !length($certificate_id);
770

            
771
    my $dbh = dbh();
772
    return send_json($client, 404, { error => 'host_not_found' })
773
        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
774
    if (length $certificate_id) {
775
        return send_json($client, 400, { error => 'invalid_certificate' })
776
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
777
    }
778

            
779
    my $now = iso_now();
780
    with_transaction($dbh, sub {
781
        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
782
        set_schema_meta($dbh, 'registry_updated_at', $now);
783
    });
784
    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
785
}
786

            
787
sub issue_host_certificate {
788
    my ($client, $payload) = @_;
789
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
790
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
791

            
792
    my $registry = load_registry();
793
    my ($host) = grep { canonical_host_fqdn($_) eq $host_fqdn } @{ $registry->{hosts} || [] };
794
    return send_json($client, 404, { error => 'host_not_found' }) unless $host;
795

            
796
    my @dns_names = unique_preserve(grep { length $_ } (
797
        $host_fqdn,
798
        declared_alias_names($host),
799
        derived_alias_names($host),
800
    ));
801
    my $certificate_id = clean_certificate_id($host_fqdn . '-' . strftime('%Y%m%d%H%M%S', localtime));
802
    my $dbh = dbh();
803
    my $issued = eval {
804
        ca_manager_output('issue', $certificate_id, @dns_names);
805
        ca_manager_json('list-json');
806
        with_transaction($dbh, sub {
807
            my $now = iso_now();
808
            upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
809
            set_schema_meta($dbh, 'registry_updated_at', $now);
810
        });
811
        1;
812
    };
813
    if (!$issued) {
814
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
815
    }
816

            
817
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
818
    return send_json($client, 200, {
819
        ok => json_bool(1),
820
        host_fqdn => $host_fqdn,
821
        certificate_id => $certificate_id,
822
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
823
    });
824
}
825

            
Bogdan Timofte authored 3 days ago
826
sub set_vhost_certificate {
827
    my ($client, $payload) = @_;
828
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
829
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
830
    my $certificate_id = clean_certificate_id($raw_certificate_id);
831
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
832
    return send_json($client, 400, { error => 'invalid_certificate' })
833
        if length($raw_certificate_id) && !length($certificate_id);
834

            
835
    my $dbh = dbh();
836
    return send_json($client, 404, { error => 'vhost_not_found' })
837
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
838
    if (length $certificate_id) {
839
        return send_json($client, 400, { error => 'invalid_certificate' })
840
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
841
    }
842

            
843
    my $now = iso_now();
844
    $dbh->do(
845
        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
846
        undef,
847
        length($certificate_id) ? $certificate_id : undef,
848
        length($certificate_id) ? 'local-ca' : 'none',
849
        $now,
850
        $vhost,
851
        'active',
852
    );
853
    set_schema_meta($dbh, 'registry_updated_at', $now);
854
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
855
}
856

            
857
sub issue_vhost_certificate {
858
    my ($client, $payload) = @_;
859
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
860
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
861

            
862
    my $dbh = dbh();
863
    my ($host_fqdn) = $dbh->selectrow_array(
864
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
865
        undef,
866
        $vhost,
867
    );
868
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
869

            
870
    my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
871
    my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
872
    my $issued = eval {
873
        ca_manager_output('issue', $certificate_id, @dns_names);
874
        ca_manager_json('list-json');
875
        with_transaction($dbh, sub {
876
            my $now = iso_now();
877
            $dbh->do(
878
                "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
879
                undef,
880
                $certificate_id,
881
                $now,
882
                $vhost,
883
            );
884
            set_schema_meta($dbh, 'registry_updated_at', $now);
885
        });
886
        1;
887
    };
888
    if (!$issued) {
889
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
890
    }
891

            
892
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
893
    return send_json($client, 200, {
894
        ok => json_bool(1),
895
        vhost_fqdn => $vhost,
896
        host_fqdn => $host_fqdn,
897
        certificate_id => $certificate_id,
898
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
899
    });
900
}
901

            
Xdev Host Manager authored a week ago
902
sub analyze_hosts {
903
    my ($hosts) = @_;
904
    my @problems;
905
    my (%names, %ids);
906
    for my $host (@$hosts) {
907
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
908
        my $fqdn = canonical_host_fqdn($host);
909
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
910
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
911
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
912
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
913
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
914
            if grep { /^(is|vad|b)-/ } @declared;
915
        for my $name (@declared) {
Xdev Host Manager authored a week ago
916
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
917
        }
Bogdan Timofte authored 4 days ago
918
        my %declared = map { $_ => 1 } @declared;
919
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
920
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
921
                if $declared{$derived};
922
        }
Bogdan Timofte authored 4 days ago
923
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
924
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
925
    }
926
    return \@problems;
927
}
928

            
Xdev Host Manager authored a week ago
929
sub host_payload {
Bogdan Timofte authored 3 days ago
930
    my ($host, $tls) = @_;
Xdev Host Manager authored a week ago
931
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
932
    $copy{fqdn} = canonical_host_fqdn($host);
933
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
934
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
935
    $copy{declared_names} = [ declared_dns_names($host) ];
936
    $copy{aliases} = [ declared_alias_names($host) ];
937
    $copy{derived_aliases} = [ derived_alias_names($host) ];
938
    $copy{vhosts} = [ declared_vhost_names($host) ];
939
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Bogdan Timofte authored 3 days ago
940
    $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
941
    $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
Xdev Host Manager authored a week ago
942
    return \%copy;
943
}
944

            
945
sub effective_names {
946
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
947
    my @names = declared_dns_names($host);
948
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
949
    return unique_preserve(@names);
950
}
951

            
Bogdan Timofte authored 3 days ago
952
sub host_dns_names {
953
    my ($host) = @_;
954
    my @names;
955
    my $fqdn = canonical_host_fqdn($host);
956
    push @names, $fqdn if length $fqdn;
957
    push @names, declared_alias_names($host), derived_alias_names($host);
958
    return unique_preserve(@names);
959
}
960

            
961
sub vhost_cname_records {
962
    my ($host) = @_;
963
    my $target = canonical_host_fqdn($host);
964
    return () unless length $target;
965
    my @records;
966
    for my $vhost (declared_vhost_names($host)) {
967
        push @records, [ $vhost, $target ];
968
        if (my $short = short_alias_for_fqdn($vhost)) {
969
            push @records, [ $short, $target ];
970
        }
971
    }
972
    my %seen;
973
    return grep { !$seen{$_->[0]}++ } @records;
974
}
975

            
Bogdan Timofte authored 4 days ago
976
sub declared_dns_names {
977
    my ($host) = @_;
978
    my @names;
979
    my $fqdn = canonical_host_fqdn($host);
980
    push @names, $fqdn if length $fqdn;
981
    push @names, declared_alias_names($host);
982
    push @names, declared_vhost_names($host);
983
    return unique_preserve(@names);
984
}
985

            
986
sub declared_alias_names {
987
    my ($host) = @_;
988
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
989
}
990

            
991
sub declared_vhost_names {
992
    my ($host) = @_;
993
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
994
}
995

            
996
sub declared_dns_names_legacy {
997
    my ($host) = @_;
998
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
999
}
1000

            
1001
sub split_legacy_names {
1002
    my ($id, $names) = @_;
1003
    my $fallback = clean_id($id || '');
1004
    my (%result) = (
1005
        fqdn => '',
1006
        aliases => [],
1007
        vhosts => [],
1008
    );
1009
    for my $name (map { normalize_dns_name($_) } @$names) {
1010
        next unless length $name;
1011
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
1012
            $result{fqdn} = $name;
1013
            next;
1014
        }
1015
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
1016
            $result{fqdn} = $name;
1017
            next;
1018
        }
1019
        if (name_is_vhost($name)) {
1020
            push @{ $result{vhosts} }, $name;
1021
        } else {
1022
            push @{ $result{aliases} }, $name;
1023
        }
1024
    }
1025
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
1026
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
1027
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
1028
    return \%result;
1029
}
1030

            
1031
sub derived_alias_names {
Xdev Host Manager authored a week ago
1032
    my ($host) = @_;
1033
    my @derived;
Bogdan Timofte authored 4 days ago
1034
    my $fqdn = canonical_host_fqdn($host);
1035
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1036
    for my $name (declared_alias_names($host)) {
1037
        push @derived, short_alias_for_fqdn($name);
1038
    }
1039
    return unique_preserve(grep { length $_ } @derived);
1040
}
1041

            
1042
sub derived_vhost_alias_names {
1043
    my ($host) = @_;
1044
    my @derived;
1045
    for my $name (declared_vhost_names($host)) {
1046
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
1047
    }
Bogdan Timofte authored 4 days ago
1048
    return unique_preserve(grep { length $_ } @derived);
1049
}
1050

            
1051
sub clean_alias_names {
1052
    my ($payload) = @_;
1053
    return clean_name_bucket($payload->{aliases})
1054
        if defined $payload->{aliases};
1055
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1056
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1057
}
1058

            
1059
sub clean_vhost_names {
1060
    my ($payload) = @_;
1061
    return clean_name_bucket($payload->{vhosts})
1062
        if defined $payload->{vhosts};
1063
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1064
    return grep { name_is_vhost($_) } @legacy;
1065
}
1066

            
1067
sub clean_name_bucket {
1068
    my ($value) = @_;
1069
    my @names = clean_list($value);
1070
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1071
}
1072

            
1073
sub remove_derived_names {
1074
    my @names = @_;
1075
    my %derived;
1076
    for my $name (@names) {
1077
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1078
        $derived{$1} = 1;
1079
    }
1080
    return grep { !$derived{$_} } @names;
1081
}
1082

            
1083
sub unique_preserve {
1084
    my @values = @_;
1085
    my %seen;
1086
    return grep { !$seen{$_}++ } @values;
1087
}
1088

            
Bogdan Timofte authored 4 days ago
1089
sub canonical_ip {
1090
    my ($host) = @_;
1091
    return '' unless $host && ref($host) eq 'HASH';
1092
    for my $key (qw(ip dns_ip hosts_ip)) {
1093
        my $value = clean_scalar($host->{$key} || '');
1094
        return $value if length $value;
1095
    }
1096
    return '';
1097
}
1098

            
Xdev Host Manager authored a week ago
1099
sub problem {
1100
    my ($host, $code, $message) = @_;
1101
    return { host_id => $host->{id}, code => $code, message => $message };
1102
}
1103

            
1104
sub render_local_hosts_tsv {
1105
    my ($registry) = @_;
1106
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
1107
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
1108
    $out .= "#\n";
1109
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
1110
    $out .= "# ip<TAB>name [aliases...]\n";
Bogdan Timofte authored 3 days ago
1111
    $out .= "# CNAME<TAB>alias<TAB>target\n";
Xdev Host Manager authored a week ago
1112
    $out .= "#\n";
1113
    $out .= "# Priority rule:\n";
1114
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
1115
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
1116
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
1117
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1118
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
1119
        my $ip = canonical_ip($host);
1120
        next unless $ip;
Bogdan Timofte authored 3 days ago
1121
        my @names = host_dns_names($host);
Xdev Host Manager authored a week ago
1122
        next unless @names;
Bogdan Timofte authored 4 days ago
1123
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Bogdan Timofte authored 3 days ago
1124
        for my $record (vhost_cname_records($host)) {
1125
            $out .= join("\t", 'CNAME', @$record) . "\n";
1126
        }
Xdev Host Manager authored a week ago
1127
    }
1128
    return $out;
1129
}
1130

            
1131
sub render_monitoring {
1132
    my ($registry) = @_;
1133
    my @hosts;
1134
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1135
        next unless ($host->{status} || 'active') eq 'active';
1136
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
1137
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
1138
        push @hosts, {
1139
            id => $host->{id},
Xdev Host Manager authored a week ago
1140
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
1141
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
1142
            aliases => \@names,
Bogdan Timofte authored 4 days ago
1143
            fqdn => canonical_host_fqdn($host),
1144
            declared_names => [ declared_dns_names($host) ],
1145
            aliases_declared => [ declared_alias_names($host) ],
1146
            aliases_derived => [ derived_alias_names($host) ],
1147
            vhosts_declared => [ declared_vhost_names($host) ],
1148
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
1149
            roles => [ @{ $host->{roles} || [] } ],
1150
            monitoring => $host->{monitoring} || 'pending',
1151
            notes => $host->{notes} || '',
1152
        };
1153
    }
1154
    return {
1155
        version => $registry->{version},
1156
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
1157
        source => $opt{db},
Xdev Host Manager authored a week ago
1158
        hosts => \@hosts,
1159
    };
1160
}
1161

            
Bogdan Timofte authored 4 days ago
1162
sub debug_database_tables_payload {
1163
    my $dbh = dbh();
1164
    my @tables;
1165
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
1166
    $sth->execute;
1167
    while (my ($name) = $sth->fetchrow_array) {
1168
        my $quoted = $dbh->quote_identifier($name);
1169
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1170
        push @tables, {
1171
            name => $name,
1172
            rows => int($count || 0),
1173
        };
1174
    }
1175
    return {
1176
        database => $opt{db},
1177
        generated_at => iso_now(),
1178
        tables => \@tables,
1179
        counts => {
1180
            tables => scalar @tables,
1181
            rows => sum(map { $_->{rows} } @tables),
1182
        },
1183
    };
1184
}
1185

            
1186
sub debug_database_table_payload {
1187
    my ($table, $limit) = @_;
1188
    my $dbh = dbh();
1189
    $table = clean_scalar($table);
1190
    return { error => 'missing_table' } unless length $table;
1191
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1192
    $limit = int($limit || 100);
1193
    $limit = 1 if $limit < 1;
1194
    $limit = 500 if $limit > 500;
1195

            
1196
    my $quoted = $dbh->quote_identifier($table);
1197
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1198
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1199
    my @index_details;
1200
    for my $index (@$indexes) {
1201
        my $index_name = $index->{name} || '';
1202
        next unless length $index_name;
1203
        my $quoted_index = $dbh->quote_identifier($index_name);
1204
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1205
        push @index_details, {
1206
            name => $index_name,
1207
            unique => int($index->{unique} || 0),
1208
            origin => $index->{origin} || '',
1209
            partial => int($index->{partial} || 0),
1210
            columns => [ map { $_->{name} || '' } @$index_columns ],
1211
        };
1212
    }
1213
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1214
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1215
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1216

            
1217
    return {
1218
        database => $opt{db},
1219
        table => $table,
1220
        generated_at => iso_now(),
1221
        limit => $limit,
1222
        row_count => int($row_count || 0),
1223
        columns => $columns,
1224
        indexes => \@index_details,
1225
        foreign_keys => $foreign_keys,
1226
        rows => $rows,
1227
    };
1228
}
1229

            
Bogdan Timofte authored 4 days ago
1230
sub debug_database_table_export_payload {
1231
    my ($table) = @_;
1232
    my $dbh = dbh();
1233
    $table = clean_scalar($table);
1234
    return { error => 'missing_table' } unless length $table;
1235
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1236

            
1237
    my $quoted = $dbh->quote_identifier($table);
1238
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1239
    my @column_names = map { $_->{name} || '' } @$columns;
1240
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1241
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1242

            
1243
    return {
1244
        database => $opt{db},
1245
        table => $table,
1246
        generated_at => iso_now(),
1247
        row_count => int($row_count || 0),
1248
        columns => \@column_names,
1249
        rows => $rows,
1250
    };
1251
}
1252

            
1253
sub render_debug_table_csv {
1254
    my ($export) = @_;
1255
    my @columns = @{ $export->{columns} || [] };
1256
    my @lines = (join(',', map { csv_cell($_) } @columns));
1257
    for my $row (@{ $export->{rows} || [] }) {
1258
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1259
    }
1260
    return join("\n", @lines) . "\n";
1261
}
1262

            
1263
sub csv_cell {
1264
    my ($value) = @_;
1265
    $value = '' unless defined $value;
1266
    $value = "$value";
1267
    $value =~ s/"/""/g;
1268
    return qq("$value") if $value =~ /[",\r\n]/;
1269
    return $value;
1270
}
1271

            
1272
sub debug_table_export_filename {
1273
    my ($table, $extension) = @_;
1274
    $table = clean_scalar($table || 'table');
1275
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1276
    $table = 'table' unless length $table;
1277
    return "debug-$table.$extension";
1278
}
1279

            
Bogdan Timofte authored 4 days ago
1280
sub debug_table_exists {
1281
    my ($dbh, $table) = @_;
1282
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1283
    my ($exists) = $dbh->selectrow_array(
1284
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1285
        undef,
1286
        $table,
1287
    );
1288
    return $exists ? 1 : 0;
1289
}
1290

            
1291
sub sum {
1292
    my $total = 0;
1293
    $total += $_ || 0 for @_;
1294
    return $total;
1295
}
1296

            
Xdev Host Manager authored a week ago
1297
sub ca_script_path {
1298
    return "$project_dir/scripts/ca_manager.sh";
1299
}
1300

            
1301
sub ca_dir {
1302
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1303
}
1304

            
1305
sub ca_cert_path {
1306
    return ca_dir() . "/certs/ca.cert.pem";
1307
}
1308

            
Bogdan Timofte authored 5 days ago
1309
sub ca_issued_cert_path {
1310
    my ($name) = @_;
1311
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1312
    return ca_dir() . "/issued/$name.cert.pem";
1313
}
1314

            
Bogdan Timofte authored 3 days ago
1315
sub ca_issued_key_path {
1316
    my ($name) = @_;
1317
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1318
    return ca_dir() . "/issued/$name.key.pem";
1319
}
1320

            
Bogdan Timofte authored 3 days ago
1321
sub ca_private_key_exists {
1322
    my ($name) = @_;
1323
    return 0 unless clean_certificate_id($name || '');
1324
    return -f ca_issued_key_path($name) ? 1 : 0;
1325
}
1326

            
Bogdan Timofte authored 3 days ago
1327
sub ca_manager_output {
1328
    my (@args) = @_;
Xdev Host Manager authored a week ago
1329
    my $script = ca_script_path();
1330
    die "CA manager script is missing\n" unless -x $script;
1331
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 3 days ago
1332
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1333
    local $/;
1334
    my $out = <$fh>;
1335
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 3 days ago
1336
    return $out || '';
1337
}
1338

            
1339
sub ca_manager_json {
1340
    my ($command) = @_;
1341
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1342
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1343
    sync_certificates_from_json($out) if $command eq 'list-json';
1344
    return $out;
1345
}
1346

            
1347
sub sync_certificates_from_json {
1348
    my ($json) = @_;
1349
    my $certs = eval { json_decode($json || '[]') };
1350
    return if $@ || ref($certs) ne 'ARRAY';
1351
    my $dbh = dbh();
1352
    my $now = iso_now();
1353
    with_transaction($dbh, sub {
1354
        for my $cert (@$certs) {
1355
            next unless ref($cert) eq 'HASH';
1356
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1357
            next unless $name;
1358
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1359
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1360
            my $cert_path = ca_issued_cert_path($name);
1361
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1362
            my $serial = clean_scalar($cert->{serial} || '');
1363
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1364
            $dbh->do(
1365
                '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) '
1366
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1367
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1368
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1369
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1370
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1371
                undef,
1372
                $name,
1373
                $host_fqdn || undef,
1374
                $dns_names[0] || '',
1375
                clean_scalar($cert->{subject} || ''),
1376
                clean_scalar($cert->{issuer} || ''),
1377
                length($serial) ? $serial : undef,
1378
                clean_scalar($cert->{not_before} || ''),
1379
                clean_scalar($cert->{not_after} || ''),
1380
                length($fingerprint) ? $fingerprint : undef,
1381
                $cert_path,
1382
                $csr_path,
1383
                $now,
1384
                $now,
1385
            );
1386
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1387
            for my $dns_name (@dns_names) {
1388
                next unless length $dns_name;
1389
                $dbh->do(
1390
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1391
                    undef,
1392
                    $name,
1393
                    $dns_name,
1394
                );
1395
            }
1396
        }
1397
    });
1398
}
1399

            
1400
sub infer_certificate_host_fqdn {
1401
    my ($dbh, $dns_names) = @_;
1402
    for my $name (@$dns_names) {
1403
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1404
        return $fqdn if $fqdn;
1405
    }
1406
    for my $name (@$dns_names) {
1407
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1408
        return $fqdn if $fqdn;
1409
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1410
        return $fqdn if $fqdn;
1411
    }
1412
    return '';
Xdev Host Manager authored a week ago
1413
}
1414

            
Xdev Host Manager authored a week ago
1415
sub parse_hosts_yaml {
1416
    my ($text) = @_;
1417
    my %registry = (
1418
        version => 1,
1419
        updated_at => '',
1420
        policy => {},
1421
        hosts => [],
1422
    );
1423
    my ($section, $current, $list_key);
1424
    for my $line (split /\n/, $text) {
1425
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1426
        if ($line =~ /^version:\s*(\d+)/) {
1427
            $registry{version} = int($1);
1428
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1429
            $registry{updated_at} = yaml_unquote($1);
1430
        } elsif ($line =~ /^policy:\s*$/) {
1431
            $section = 'policy';
1432
        } elsif ($line =~ /^hosts:\s*$/) {
1433
            $section = 'hosts';
1434
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1435
            $registry{policy}{$1} = yaml_unquote($2);
1436
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1437
            $current = {
1438
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1439
                fqdn => '',
Xdev Host Manager authored a week ago
1440
                status => 'active',
Bogdan Timofte authored 4 days ago
1441
                ip => '',
1442
                aliases => [],
1443
                vhosts => [],
Xdev Host Manager authored a week ago
1444
                roles => [],
1445
                sources => [],
1446
                monitoring => 'pending',
1447
                notes => '',
1448
            };
1449
            push @{ $registry{hosts} }, $current;
1450
            $list_key = undef;
1451
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1452
            $list_key = $1;
1453
            $current->{$list_key} ||= [];
1454
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1455
            push @{ $current->{$list_key} }, yaml_unquote($1);
1456
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1457
            my $key = $1;
1458
            my $value = yaml_unquote($2);
1459
            if ($key eq 'ip') {
1460
                $current->{ip} = $value;
1461
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1462
                $current->{ip} ||= $value;
1463
            } elsif ($key eq 'fqdn') {
1464
                $current->{fqdn} = normalize_dns_name($value);
1465
            } elsif ($key eq 'names') {
1466
                # ignored here; legacy list is handled after parsing
1467
            } else {
1468
                $current->{$key} = $value;
1469
            }
Xdev Host Manager authored a week ago
1470
            $list_key = undef;
1471
        }
1472
    }
Bogdan Timofte authored 4 days ago
1473
    for my $host (@{ $registry{hosts} }) {
1474
        my @legacy_names = @{ $host->{names} || [] };
1475
        if (@legacy_names) {
1476
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1477
            $host->{fqdn} ||= $legacy->{fqdn};
1478
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1479
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1480
        }
1481
        delete $host->{names};
1482
        $host->{fqdn} ||= canonical_host_fqdn($host);
1483
    }
Xdev Host Manager authored a week ago
1484
    return \%registry;
1485
}
1486

            
1487
sub render_hosts_yaml {
1488
    my ($registry) = @_;
1489
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1490
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1491
    $out .= "policy:\n";
1492
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1493
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1494
    }
1495
    $out .= "hosts:\n";
1496
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1497
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1498
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1499
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1500
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1501
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1502
            $out .= "    $key:\n";
1503
            for my $value (@{ $host->{$key} || [] }) {
1504
                $out .= "      - " . yq($value) . "\n";
1505
            }
1506
        }
1507
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1508
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1509
    }
1510
    return $out;
1511
}
1512

            
Xdev Host Manager authored a week ago
1513
sub parse_work_orders_yaml {
1514
    my ($text) = @_;
1515
    my %orders = (
1516
        version => 1,
1517
        work_orders => [],
1518
    );
Xdev Host Manager authored a week ago
1519
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1520
    for my $line (split /\n/, $text) {
1521
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1522
        if ($line =~ /^version:\s*(\d+)/) {
1523
            $orders{version} = int($1);
1524
        } elsif ($line =~ /^work_orders:\s*$/) {
1525
            $section = 'work_orders';
1526
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1527
            $current = {
1528
                id => yaml_unquote($1),
1529
                status => 'pending',
Xdev Host Manager authored a week ago
1530
                checklist => [],
Xdev Host Manager authored a week ago
1531
                actions => [],
1532
            };
1533
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1534
            $list_section = '';
Xdev Host Manager authored a week ago
1535
            $current_action = undef;
Xdev Host Manager authored a week ago
1536
            $current_item = undef;
1537
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1538
            $list_section = 'checklist';
1539
            $current->{checklist} ||= [];
1540
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1541
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1542
            push @{ $current->{checklist} }, $current_item;
1543
            $current_action = undef;
1544
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1545
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1546
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1547
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1548
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1549
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1550
            $current_action = { type => yaml_unquote($1) };
1551
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1552
            $current_item = undef;
1553
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1554
            $current_action->{$1} = yaml_unquote($2);
1555
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1556
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1557
            $list_section = '';
Xdev Host Manager authored a week ago
1558
            $current_action = undef;
Xdev Host Manager authored a week ago
1559
            $current_item = undef;
Xdev Host Manager authored a week ago
1560
        }
1561
    }
1562
    return \%orders;
1563
}
1564

            
1565
sub render_work_orders_yaml {
1566
    my ($orders) = @_;
1567
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1568
    $out .= "work_orders:\n";
1569
    for my $wo (@{ $orders->{work_orders} || [] }) {
1570
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1571
        for my $key (qw(status title reason created_at confirmed_at result)) {
1572
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1573
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1574
        }
Xdev Host Manager authored a week ago
1575
        $out .= "    checklist:\n";
1576
        for my $item (@{ $wo->{checklist} || [] }) {
1577
            $out .= "      - id: " . yq($item->{id}) . "\n";
1578
            for my $key (qw(text status owner notes updated_at)) {
1579
                next unless exists $item->{$key} && length($item->{$key} || '');
1580
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1581
            }
1582
        }
Xdev Host Manager authored a week ago
1583
        $out .= "    actions:\n";
1584
        for my $action (@{ $wo->{actions} || [] }) {
1585
            $out .= "      - type: " . yq($action->{type}) . "\n";
1586
            for my $key (qw(host_id name)) {
1587
                next unless exists $action->{$key} && length($action->{$key} || '');
1588
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1589
            }
1590
        }
1591
    }
1592
    return $out;
1593
}
1594

            
Xdev Host Manager authored a week ago
1595
sub request_payload {
1596
    my ($headers, $body) = @_;
1597
    my $type = $headers->{'content-type'} || '';
1598
    if ($type =~ m{application/json}) {
1599
        return json_decode($body || '{}');
1600
    }
1601
    return { parse_params($body || '') };
1602
}
1603

            
1604
sub json_bool {
1605
    my ($value) = @_;
1606
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1607
}
1608

            
1609
sub json_encode {
1610
    my ($value) = @_;
1611
    if (!defined $value) {
1612
        return 'null';
1613
    }
1614
    my $ref = ref($value);
1615
    if (!$ref) {
1616
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1617
        return json_string($value);
1618
    }
1619
    if ($ref eq 'HostManager::JSONBool') {
1620
        return $$value ? 'true' : 'false';
1621
    }
1622
    if ($ref eq 'ARRAY') {
1623
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1624
    }
1625
    if ($ref eq 'HASH') {
1626
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1627
    }
1628
    return json_string("$value");
1629
}
1630

            
1631
sub json_string {
1632
    my ($value) = @_;
1633
    $value = '' unless defined $value;
1634
    $value =~ s/\\/\\\\/g;
1635
    $value =~ s/"/\\"/g;
1636
    $value =~ s/\n/\\n/g;
1637
    $value =~ s/\r/\\r/g;
1638
    $value =~ s/\t/\\t/g;
1639
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1640
    return qq("$value");
1641
}
1642

            
1643
sub json_decode {
1644
    my ($text) = @_;
1645
    my $i = 0;
1646
    my $len = length($text);
1647
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1648

            
1649
    $skip_ws = sub {
1650
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1651
    };
1652

            
1653
    $parse_string = sub {
1654
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1655
        $i++;
1656
        my $out = '';
1657
        while ($i < $len) {
1658
            my $ch = substr($text, $i++, 1);
1659
            return $out if $ch eq '"';
1660
            if ($ch eq "\\") {
1661
                die "Bad JSON escape\n" if $i >= $len;
1662
                my $esc = substr($text, $i++, 1);
1663
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1664
                    $out .= $esc;
1665
                } elsif ($esc eq 'b') {
1666
                    $out .= "\b";
1667
                } elsif ($esc eq 'f') {
1668
                    $out .= "\f";
1669
                } elsif ($esc eq 'n') {
1670
                    $out .= "\n";
1671
                } elsif ($esc eq 'r') {
1672
                    $out .= "\r";
1673
                } elsif ($esc eq 't') {
1674
                    $out .= "\t";
1675
                } elsif ($esc eq 'u') {
1676
                    my $hex = substr($text, $i, 4);
1677
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1678
                    $out .= chr(hex($hex));
1679
                    $i += 4;
1680
                } else {
1681
                    die "Bad JSON escape\n";
1682
                }
1683
            } else {
1684
                $out .= $ch;
1685
            }
1686
        }
1687
        die "Unterminated JSON string\n";
1688
    };
1689

            
1690
    $parse_number = sub {
1691
        my $start = $i;
1692
        $i++ if substr($text, $i, 1) eq '-';
1693
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1694
        if ($i < $len && substr($text, $i, 1) eq '.') {
1695
            $i++;
1696
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1697
        }
1698
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1699
            $i++;
1700
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1701
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1702
        }
1703
        return 0 + substr($text, $start, $i - $start);
1704
    };
1705

            
1706
    $parse_array = sub {
1707
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1708
        $i++;
1709
        my @out;
1710
        $skip_ws->();
1711
        if ($i < $len && substr($text, $i, 1) eq ']') {
1712
            $i++;
1713
            return \@out;
1714
        }
1715
        while (1) {
1716
            push @out, $parse_value->();
1717
            $skip_ws->();
1718
            my $ch = substr($text, $i++, 1);
1719
            last if $ch eq ']';
1720
            die "Expected JSON array comma\n" unless $ch eq ',';
1721
        }
1722
        return \@out;
1723
    };
1724

            
1725
    $parse_object = sub {
1726
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1727
        $i++;
1728
        my %out;
1729
        $skip_ws->();
1730
        if ($i < $len && substr($text, $i, 1) eq '}') {
1731
            $i++;
1732
            return \%out;
1733
        }
1734
        while (1) {
1735
            $skip_ws->();
1736
            my $key = $parse_string->();
1737
            $skip_ws->();
1738
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1739
            $out{$key} = $parse_value->();
1740
            $skip_ws->();
1741
            my $ch = substr($text, $i++, 1);
1742
            last if $ch eq '}';
1743
            die "Expected JSON object comma\n" unless $ch eq ',';
1744
        }
1745
        return \%out;
1746
    };
1747

            
1748
    $parse_value = sub {
1749
        $skip_ws->();
1750
        die "Unexpected end of JSON\n" if $i >= $len;
1751
        my $ch = substr($text, $i, 1);
1752
        return $parse_string->() if $ch eq '"';
1753
        return $parse_object->() if $ch eq '{';
1754
        return $parse_array->() if $ch eq '[';
1755
        if (substr($text, $i, 4) eq 'true') {
1756
            $i += 4;
1757
            return json_bool(1);
1758
        }
1759
        if (substr($text, $i, 5) eq 'false') {
1760
            $i += 5;
1761
            return json_bool(0);
1762
        }
1763
        if (substr($text, $i, 4) eq 'null') {
1764
            $i += 4;
1765
            return undef;
1766
        }
1767
        return $parse_number->() if $ch =~ /[-0-9]/;
1768
        die "Unexpected JSON token\n";
1769
    };
1770

            
1771
    my $value = $parse_value->();
1772
    $skip_ws->();
1773
    die "Trailing JSON content\n" if $i != $len;
1774
    return $value;
1775
}
1776

            
1777
sub parse_params {
1778
    my ($text) = @_;
1779
    my %out;
1780
    for my $pair (split /&/, $text) {
1781
        next unless length $pair;
1782
        my ($k, $v) = split /=/, $pair, 2;
1783
        $out{url_decode($k)} = url_decode($v || '');
1784
    }
1785
    return %out;
1786
}
1787

            
1788
sub clean_id {
1789
    my ($value) = @_;
1790
    $value = lc clean_scalar($value);
1791
    $value =~ s/[^a-z0-9_.-]+/-/g;
1792
    $value =~ s/^-+|-+$//g;
1793
    return $value;
1794
}
1795

            
Bogdan Timofte authored 3 days ago
1796
sub clean_certificate_id {
1797
    my ($value) = @_;
1798
    $value = clean_scalar($value);
1799
    return '' unless length $value;
1800
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1801
}
1802

            
Xdev Host Manager authored a week ago
1803
sub clean_scalar {
1804
    my ($value) = @_;
1805
    $value = '' unless defined $value;
1806
    $value =~ s/[\r\n\t]+/ /g;
1807
    $value =~ s/^\s+|\s+$//g;
1808
    return $value;
1809
}
1810

            
1811
sub clean_list {
1812
    my ($value) = @_;
1813
    return () unless defined $value;
1814
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1815
    my @clean;
1816
    for my $item (@items) {
1817
        $item = clean_scalar($item);
1818
        push @clean, $item if length $item;
1819
    }
1820
    return @clean;
1821
}
1822

            
1823
sub yq {
1824
    my ($value) = @_;
1825
    $value = '' unless defined $value;
1826
    $value =~ s/\\/\\\\/g;
1827
    $value =~ s/"/\\"/g;
1828
    return qq("$value");
1829
}
1830

            
1831
sub yaml_unquote {
1832
    my ($value) = @_;
1833
    $value = '' unless defined $value;
1834
    $value =~ s/^\s+|\s+$//g;
1835
    if ($value =~ /^"(.*)"$/) {
1836
        $value = $1;
1837
        $value =~ s/\\"/"/g;
1838
        $value =~ s/\\\\/\\/g;
1839
    }
1840
    return $value;
1841
}
1842

            
1843
sub verify_totp {
1844
    my ($secret, $otp) = @_;
1845
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1846
    my $key = eval { base32_decode($secret) };
1847
    return 0 if $@ || !length $key;
1848
    my $counter = int(time() / 30);
1849
    for my $offset (-1, 0, 1) {
1850
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1851
    }
1852
    return 0;
1853
}
1854

            
1855
sub totp_code {
1856
    my ($key, $counter) = @_;
1857
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1858
    my $hash = hmac_sha1($msg, $key);
1859
    my $offset = ord(substr($hash, -1)) & 0x0f;
1860
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1861
    return sprintf('%06d', $bin % 1_000_000);
1862
}
1863

            
1864
sub base32_decode {
1865
    my ($text) = @_;
1866
    $text = uc($text || '');
1867
    $text =~ s/[^A-Z2-7]//g;
1868
    my %map;
1869
    my @chars = ('A'..'Z', '2'..'7');
1870
    @map{@chars} = (0..31);
1871
    my ($bits, $value, $out) = (0, 0, '');
1872
    for my $char (split //, $text) {
1873
        die "Invalid base32\n" unless exists $map{$char};
1874
        $value = ($value << 5) | $map{$char};
1875
        $bits += 5;
1876
        while ($bits >= 8) {
1877
            $bits -= 8;
1878
            $out .= chr(($value >> $bits) & 0xff);
1879
        }
1880
    }
1881
    return $out;
1882
}
1883

            
1884
sub create_session {
1885
    my $nonce = random_hex(24);
1886
    my $expires = int(time() + 8 * 3600);
1887
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1888
    my $token = "$nonce:$expires:$sig";
1889
    $sessions{$token} = $expires;
1890
    return $token;
1891
}
1892

            
1893
sub is_authenticated {
1894
    my ($headers) = @_;
1895
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1896
    return 0 unless $token;
1897
    my ($nonce, $expires, $sig) = split /:/, $token;
1898
    return 0 unless $nonce && $expires && $sig;
1899
    return 0 if $expires < time();
1900
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1901
    return exists $sessions{$token};
1902
}
1903

            
1904
sub expire_session {
1905
    my ($headers) = @_;
1906
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1907
    delete $sessions{$token} if $token;
1908
}
1909

            
1910
sub cookie_value {
1911
    my ($cookie, $name) = @_;
1912
    for my $part (split /;\s*/, $cookie) {
1913
        my ($k, $v) = split /=/, $part, 2;
1914
        return $v if defined $k && $k eq $name;
1915
    }
1916
    return '';
1917
}
1918

            
1919
sub send_json {
1920
    my ($client, $status, $payload, $extra_headers) = @_;
1921
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1922
}
1923

            
Xdev Host Manager authored a week ago
1924
sub send_json_raw {
1925
    my ($client, $status, $json_body, $extra_headers) = @_;
1926
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1927
}
1928

            
Xdev Host Manager authored a week ago
1929
sub send_html {
1930
    my ($client, $status, $html) = @_;
1931
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1932
}
1933

            
1934
sub send_text {
1935
    my ($client, $status, $text) = @_;
1936
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1937
}
1938

            
1939
sub send_download {
1940
    my ($client, $status, $content, $type, $filename) = @_;
1941
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1942
}
1943

            
1944
sub send_file {
1945
    my ($client, $path, $type, $filename) = @_;
1946
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1947
    return send_download($client, 200, read_file($path), $type, $filename);
1948
}
1949

            
1950
sub send_response {
1951
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1952
    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
1953
    $body = '' unless defined $body;
1954
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1955
    print $client "Content-Type: $type\r\n";
1956
    print $client "Content-Length: " . length($body) . "\r\n";
1957
    print $client "Cache-Control: no-store\r\n";
1958
    print $client "$_\r\n" for @{ $extra_headers || [] };
1959
    print $client "Connection: close\r\n\r\n";
1960
    print $client $body;
1961
}
1962

            
1963
sub read_file {
1964
    my ($path) = @_;
1965
    open my $fh, '<', $path or die "Cannot read $path: $!";
1966
    local $/;
1967
    return <$fh>;
1968
}
1969

            
1970
sub write_file {
1971
    my ($path, $content) = @_;
1972
    open my $fh, '>', $path or die "Cannot write $path: $!";
1973
    print {$fh} $content;
1974
    close $fh or die "Cannot close $path: $!";
1975
}
1976

            
1977
sub backup_file {
1978
    my ($path) = @_;
1979
    return unless -f $path;
1980
    my $backup_dir = "$project_dir/backups/host-manager";
1981
    make_path($backup_dir) unless -d $backup_dir;
1982
    my $name = $path;
1983
    $name =~ s{.*/}{};
1984
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1985
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1986
}
1987

            
Bogdan Timofte authored 4 days ago
1988
my $db_handle;
Bogdan Timofte authored 4 days ago
1989
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1990

            
1991
sub dbh {
1992
    return $db_handle if $db_handle;
1993
    ensure_parent_dir($opt{db});
1994
    $db_handle = DBI->connect(
1995
        "dbi:SQLite:dbname=$opt{db}",
1996
        '',
1997
        '',
1998
        {
1999
            RaiseError => 1,
2000
            PrintError => 0,
2001
            AutoCommit => 1,
2002
            sqlite_unicode => 1,
2003
        },
2004
    ) or die "Cannot open SQLite database $opt{db}\n";
2005
    $db_handle->do('PRAGMA journal_mode = WAL');
2006
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
2007
    create_database_schema($db_handle);
2008
    seed_database($db_handle) unless $db_seeded++;
2009
    return $db_handle;
2010
}
2011

            
2012
sub create_database_schema {
2013
    my ($dbh) = @_;
2014
    $dbh->do(<<'SQL');
2015
CREATE TABLE IF NOT EXISTS schema_meta (
2016
    key TEXT PRIMARY KEY,
2017
    value TEXT NOT NULL,
2018
    updated_at TEXT NOT NULL
2019
)
2020
SQL
2021
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
2022
CREATE TABLE IF NOT EXISTS documents (
2023
    name TEXT PRIMARY KEY,
2024
    content TEXT NOT NULL,
2025
    updated_at TEXT NOT NULL
2026
)
2027
SQL
Bogdan Timofte authored 4 days ago
2028
    $dbh->do(
2029
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2030
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2031
        undef, 'schema_version', '2', iso_now()
2032
    );
2033
    $dbh->do(<<'SQL');
2034
CREATE TABLE IF NOT EXISTS hosts (
2035
    fqdn TEXT PRIMARY KEY,
2036
    legacy_id TEXT NOT NULL UNIQUE,
2037
    status TEXT NOT NULL DEFAULT 'active',
2038
    hosts_ip TEXT NOT NULL DEFAULT '',
2039
    dns_ip TEXT NOT NULL DEFAULT '',
2040
    monitoring TEXT NOT NULL DEFAULT 'pending',
2041
    notes TEXT NOT NULL DEFAULT '',
2042
    created_at TEXT NOT NULL,
2043
    updated_at TEXT NOT NULL
2044
)
2045
SQL
2046
    $dbh->do(<<'SQL');
2047
CREATE TABLE IF NOT EXISTS host_aliases (
2048
    alias_name TEXT NOT NULL,
2049
    host_fqdn TEXT NOT NULL,
2050
    alias_kind TEXT NOT NULL DEFAULT 'declared',
2051
    status TEXT NOT NULL DEFAULT 'active',
2052
    is_dns_published INTEGER NOT NULL DEFAULT 1,
2053
    created_at TEXT NOT NULL,
2054
    retired_at TEXT,
2055
    notes TEXT NOT NULL DEFAULT '',
2056
    PRIMARY KEY (alias_name, host_fqdn),
2057
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2058
)
2059
SQL
2060
    $dbh->do(<<'SQL');
2061
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
2062
ON host_aliases(alias_name)
2063
WHERE status = 'active'
2064
SQL
2065
    $dbh->do(<<'SQL');
2066
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
2067
ON host_aliases(host_fqdn, status)
2068
SQL
2069
    $dbh->do(<<'SQL');
2070
CREATE TABLE IF NOT EXISTS host_roles (
2071
    host_fqdn TEXT NOT NULL,
2072
    role TEXT NOT NULL,
2073
    status TEXT NOT NULL DEFAULT 'active',
2074
    created_at TEXT NOT NULL,
2075
    retired_at TEXT,
2076
    PRIMARY KEY (host_fqdn, role),
2077
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2078
)
2079
SQL
2080
    $dbh->do(<<'SQL');
2081
CREATE TABLE IF NOT EXISTS host_sources (
2082
    host_fqdn TEXT NOT NULL,
2083
    source TEXT NOT NULL,
2084
    status TEXT NOT NULL DEFAULT 'active',
2085
    created_at TEXT NOT NULL,
2086
    retired_at TEXT,
2087
    PRIMARY KEY (host_fqdn, source),
2088
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2089
)
2090
SQL
2091
    $dbh->do(<<'SQL');
2092
CREATE TABLE IF NOT EXISTS host_flags (
2093
    host_fqdn TEXT NOT NULL,
2094
    flag TEXT NOT NULL,
2095
    value TEXT NOT NULL DEFAULT '1',
2096
    created_at TEXT NOT NULL,
2097
    updated_at TEXT NOT NULL,
2098
    PRIMARY KEY (host_fqdn, flag),
2099
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2100
)
2101
SQL
2102
    $dbh->do(<<'SQL');
2103
CREATE TABLE IF NOT EXISTS host_ssh (
2104
    host_fqdn TEXT NOT NULL,
2105
    profile_name TEXT NOT NULL DEFAULT 'default',
2106
    username TEXT NOT NULL DEFAULT '',
2107
    port INTEGER NOT NULL DEFAULT 22,
2108
    identity_file TEXT NOT NULL DEFAULT '',
2109
    address TEXT NOT NULL DEFAULT '',
2110
    local_forward_host TEXT NOT NULL DEFAULT '',
2111
    local_forward_port INTEGER,
2112
    remote_forward_host TEXT NOT NULL DEFAULT '',
2113
    remote_forward_port INTEGER,
2114
    notes TEXT NOT NULL DEFAULT '',
2115
    created_at TEXT NOT NULL,
2116
    updated_at TEXT NOT NULL,
2117
    PRIMARY KEY (host_fqdn, profile_name),
2118
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2119
)
Bogdan Timofte authored 3 days ago
2120
SQL
2121
    $dbh->do(<<'SQL');
2122
CREATE TABLE IF NOT EXISTS host_tls (
2123
    host_fqdn TEXT PRIMARY KEY,
2124
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2125
    certificate_id TEXT,
2126
    notes TEXT NOT NULL DEFAULT '',
2127
    created_at TEXT NOT NULL,
2128
    updated_at TEXT NOT NULL,
2129
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE CASCADE,
2130
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2131
)
2132
SQL
2133
    $dbh->do(<<'SQL');
2134
CREATE INDEX IF NOT EXISTS idx_host_tls_certificate
2135
ON host_tls(certificate_id)
Bogdan Timofte authored 4 days ago
2136
SQL
2137
    $dbh->do(<<'SQL');
2138
CREATE TABLE IF NOT EXISTS certificates (
2139
    certificate_id TEXT PRIMARY KEY,
2140
    host_fqdn TEXT,
2141
    common_name TEXT NOT NULL DEFAULT '',
2142
    subject TEXT NOT NULL DEFAULT '',
2143
    issuer TEXT NOT NULL DEFAULT '',
2144
    serial TEXT UNIQUE,
2145
    status TEXT NOT NULL DEFAULT 'issued',
2146
    not_before TEXT NOT NULL DEFAULT '',
2147
    not_after TEXT NOT NULL DEFAULT '',
2148
    fingerprint_sha256 TEXT UNIQUE,
2149
    cert_path TEXT NOT NULL DEFAULT '',
2150
    csr_path TEXT NOT NULL DEFAULT '',
2151
    created_at TEXT NOT NULL,
2152
    updated_at TEXT NOT NULL,
2153
    notes TEXT NOT NULL DEFAULT '',
2154
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2155
)
2156
SQL
2157
    $dbh->do(<<'SQL');
2158
CREATE TABLE IF NOT EXISTS certificate_dns_names (
2159
    certificate_id TEXT NOT NULL,
2160
    dns_name TEXT NOT NULL,
2161
    PRIMARY KEY (certificate_id, dns_name),
2162
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
2163
)
2164
SQL
2165
    $dbh->do(<<'SQL');
2166
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
2167
ON certificate_dns_names(dns_name)
2168
SQL
2169
    $dbh->do(<<'SQL');
2170
CREATE TABLE IF NOT EXISTS vhosts (
2171
    vhost_fqdn TEXT PRIMARY KEY,
2172
    host_fqdn TEXT NOT NULL,
2173
    status TEXT NOT NULL DEFAULT 'active',
2174
    service_name TEXT NOT NULL DEFAULT '',
2175
    upstream_url TEXT NOT NULL DEFAULT '',
2176
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2177
    certificate_id TEXT,
2178
    notes TEXT NOT NULL DEFAULT '',
2179
    created_at TEXT NOT NULL,
2180
    updated_at TEXT NOT NULL,
2181
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2182
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2183
)
2184
SQL
2185
    $dbh->do(<<'SQL');
2186
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
2187
ON vhosts(host_fqdn, status)
2188
SQL
2189
    $dbh->do(<<'SQL');
2190
CREATE TABLE IF NOT EXISTS data_workers (
2191
    worker_id TEXT PRIMARY KEY,
2192
    worker_type TEXT NOT NULL,
2193
    name TEXT NOT NULL DEFAULT '',
2194
    status TEXT NOT NULL DEFAULT 'active',
2195
    source TEXT NOT NULL DEFAULT '',
2196
    last_run_at TEXT,
2197
    notes TEXT NOT NULL DEFAULT '',
2198
    created_at TEXT NOT NULL,
2199
    updated_at TEXT NOT NULL
2200
)
2201
SQL
2202
    $dbh->do(<<'SQL');
2203
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
2204
ON data_workers(worker_type, status)
2205
SQL
2206
    $dbh->do(<<'SQL');
2207
CREATE TABLE IF NOT EXISTS dhcp_leases (
2208
    lease_key TEXT PRIMARY KEY,
2209
    worker_id TEXT NOT NULL,
2210
    host_fqdn TEXT,
2211
    observed_name TEXT NOT NULL DEFAULT '',
2212
    ip_address TEXT NOT NULL,
2213
    mac_address TEXT NOT NULL DEFAULT '',
2214
    lease_state TEXT NOT NULL DEFAULT '',
2215
    first_seen TEXT NOT NULL,
2216
    last_seen TEXT NOT NULL,
2217
    raw TEXT NOT NULL DEFAULT '',
2218
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2219
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2220
)
2221
SQL
2222
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
2223
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
2224
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
2225
    $dbh->do(<<'SQL');
2226
CREATE TABLE IF NOT EXISTS mdns_observations (
2227
    observation_key TEXT PRIMARY KEY,
2228
    worker_id TEXT NOT NULL,
2229
    host_fqdn TEXT,
2230
    observed_name TEXT NOT NULL,
2231
    ip_address TEXT NOT NULL,
2232
    rr_type TEXT NOT NULL DEFAULT 'A',
2233
    ttl INTEGER NOT NULL DEFAULT 0,
2234
    first_seen TEXT NOT NULL,
2235
    last_seen TEXT NOT NULL,
2236
    seen_count INTEGER NOT NULL DEFAULT 1,
2237
    last_peer TEXT NOT NULL DEFAULT '',
2238
    raw TEXT NOT NULL DEFAULT '',
2239
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2240
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2241
)
2242
SQL
2243
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
2244
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
2245
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
2246
    $dbh->do(<<'SQL');
2247
CREATE TABLE IF NOT EXISTS work_orders (
2248
    id TEXT PRIMARY KEY,
2249
    status TEXT NOT NULL DEFAULT 'pending',
2250
    title TEXT NOT NULL DEFAULT '',
2251
    reason TEXT NOT NULL DEFAULT '',
2252
    created_at TEXT NOT NULL,
2253
    confirmed_at TEXT NOT NULL DEFAULT '',
2254
    result TEXT NOT NULL DEFAULT '',
2255
    updated_at TEXT NOT NULL
2256
)
2257
SQL
2258
    $dbh->do(<<'SQL');
2259
CREATE TABLE IF NOT EXISTS work_order_checklist (
2260
    work_order_id TEXT NOT NULL,
2261
    item_id TEXT NOT NULL,
2262
    text TEXT NOT NULL DEFAULT '',
2263
    status TEXT NOT NULL DEFAULT 'pending',
2264
    owner TEXT NOT NULL DEFAULT '',
2265
    notes TEXT NOT NULL DEFAULT '',
2266
    updated_at TEXT NOT NULL DEFAULT '',
2267
    PRIMARY KEY (work_order_id, item_id),
2268
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
2269
)
2270
SQL
2271
    $dbh->do(<<'SQL');
2272
CREATE TABLE IF NOT EXISTS work_order_actions (
2273
    work_order_id TEXT NOT NULL,
2274
    position INTEGER NOT NULL,
2275
    type TEXT NOT NULL,
2276
    host_fqdn TEXT,
2277
    host_legacy_id TEXT NOT NULL DEFAULT '',
2278
    name TEXT NOT NULL DEFAULT '',
2279
    payload TEXT NOT NULL DEFAULT '',
2280
    PRIMARY KEY (work_order_id, position),
2281
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
2282
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2283
)
2284
SQL
Bogdan Timofte authored 4 days ago
2285
}
2286

            
Bogdan Timofte authored 4 days ago
2287
sub seed_database {
2288
    my ($dbh) = @_;
2289
    seed_default_workers($dbh);
2290

            
2291
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2292
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2293
        normalize_registry_policy($registry);
2294
        with_transaction($dbh, sub {
2295
            import_registry_to_db($dbh, $registry, 0);
2296
        });
2297
    }
2298

            
2299
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2300
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2301
        with_transaction($dbh, sub {
2302
            import_work_orders_to_db($dbh, $orders);
2303
        });
2304
    }
2305

            
2306
    seed_mdns_observations_from_yaml($dbh);
2307
}
2308

            
2309
sub with_transaction {
2310
    my ($dbh, $code) = @_;
2311
    return $code->() unless $dbh->{AutoCommit};
2312
    $dbh->begin_work;
2313
    my $ok = eval {
2314
        $code->();
2315
        1;
2316
    };
2317
    if (!$ok) {
2318
        my $err = $@ || 'transaction failed';
2319
        eval { $dbh->rollback };
2320
        die $err;
2321
    }
2322
    $dbh->commit;
2323
}
2324

            
2325
sub db_scalar {
2326
    my ($dbh, $sql, @bind) = @_;
2327
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2328
    return $value || 0;
2329
}
2330

            
2331
sub legacy_document_text {
2332
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2333
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2334
    return $row->{content} if $row && defined $row->{content};
2335
    return read_file($seed_path) if -f $seed_path;
2336
    return $default_text;
2337
}
2338

            
2339
sub load_registry_from_db {
2340
    my $dbh = dbh();
2341
    my $registry = {
2342
        version => 1,
2343
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2344
        policy => {},
2345
        hosts => [],
2346
    };
Bogdan Timofte authored 4 days ago
2347

            
Bogdan Timofte authored 4 days ago
2348
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2349
    $sth->execute;
2350
    while (my $row = $sth->fetchrow_hashref) {
2351
        my $fqdn = $row->{fqdn};
2352
        push @{ $registry->{hosts} }, {
2353
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2354
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2355
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2356
            ip => canonical_ip($row),
2357
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2358
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2359
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2360
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2361
            monitoring => $row->{monitoring},
2362
            notes => $row->{notes},
2363
        };
2364
    }
2365

            
2366
    return $registry;
Bogdan Timofte authored 4 days ago
2367
}
2368

            
Bogdan Timofte authored 4 days ago
2369
sub save_registry_to_db {
2370
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2371
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2372
    with_transaction($dbh, sub {
2373
        import_registry_to_db($dbh, $registry, 1);
2374
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2375
    });
2376
}
2377

            
2378
sub import_registry_to_db {
2379
    my ($dbh, $registry, $retire_missing) = @_;
2380
    my %seen;
2381
    for my $host (@{ $registry->{hosts} || [] }) {
2382
        my $fqdn = upsert_host_to_db($dbh, $host);
2383
        $seen{$fqdn} = 1 if $fqdn;
2384
    }
2385

            
2386
    return unless $retire_missing;
2387
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2388
    $sth->execute('retired');
2389
    while (my ($fqdn) = $sth->fetchrow_array) {
2390
        next if $seen{$fqdn};
2391
        retire_host_in_db($dbh, $fqdn);
2392
    }
2393
}
2394

            
2395
sub upsert_host_to_db {
2396
    my ($dbh, $host) = @_;
2397
    my $now = iso_now();
2398
    my $fqdn = canonical_host_fqdn($host);
2399
    return '' unless $fqdn;
2400
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2401
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2402
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2403
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2404
    my $notes = clean_scalar($host->{notes} || '');
2405

            
Bogdan Timofte authored 4 days ago
2406
    $dbh->do(
Bogdan Timofte authored 4 days ago
2407
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2408
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2409
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2410
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2411
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2412
        undef,
Bogdan Timofte authored 4 days ago
2413
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2414
    );
2415

            
2416
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2417
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2418
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2419
    return $fqdn;
2420
}
2421

            
Bogdan Timofte authored 3 days ago
2422
sub upsert_host_tls_row {
2423
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2424
    $certificate_id = clean_certificate_id($certificate_id || '');
2425
    $dbh->do(
2426
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2427
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2428
        undef,
2429
        $host_fqdn,
2430
        length($certificate_id) ? 'local-ca' : 'none',
2431
        length($certificate_id) ? $certificate_id : undef,
2432
        '',
2433
        $now,
2434
        $now,
2435
    );
2436
}
2437

            
Bogdan Timofte authored 4 days ago
2438
sub sync_host_values {
2439
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2440
    my $now = iso_now();
2441
    my %active = map { $_ => 1 } @$values;
2442
    for my $value (@$values) {
2443
        $dbh->do(
2444
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2445
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2446
            undef,
2447
            $fqdn, $value, $now,
2448
        );
2449
    }
2450

            
2451
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2452
    $sth->execute($fqdn);
2453
    while (my ($value) = $sth->fetchrow_array) {
2454
        next if $active{$value};
2455
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2456
    }
2457
}
2458

            
Bogdan Timofte authored 4 days ago
2459
sub sync_host_aliases_and_vhosts {
2460
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2461
    my $now = iso_now();
2462
    my (%aliases, %vhosts);
2463
    if (my $short = short_alias_for_fqdn($fqdn)) {
2464
        $aliases{$short} = 1;
2465
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2466
    }
Bogdan Timofte authored 4 days ago
2467
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2468
        $name = normalize_dns_name($name);
2469
        next unless length $name;
2470
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2471
        $aliases{$name} = 1;
2472
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2473
        if (my $short = short_alias_for_fqdn($name)) {
2474
            $aliases{$short} = 1;
2475
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2476
        }
2477
    }
2478
    for my $name (@$vhosts_in) {
2479
        $name = normalize_dns_name($name);
2480
        next unless length $name;
2481
        $vhosts{$name} = 1;
2482
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2483
        if (my $short = short_alias_for_fqdn($name)) {
2484
            $aliases{$short} = 1;
2485
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2486
        }
2487
    }
2488

            
2489
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2490
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2491
}
2492

            
2493
sub upsert_alias_to_db {
2494
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2495
    my ($existing_fqdn) = $dbh->selectrow_array(
2496
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2497
        undef,
2498
        $alias,
2499
    );
2500
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2501
        if ($kind eq 'derived-vhost') {
2502
            $dbh->do(
2503
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2504
                undef,
2505
                $now, $alias, $existing_fqdn,
2506
            );
2507
        } else {
2508
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2509
        }
2510
    }
Bogdan Timofte authored 4 days ago
2511
    $dbh->do(
2512
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2513
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2514
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2515
        undef,
2516
        $alias, $fqdn, $kind, $now,
2517
    );
2518
}
2519

            
2520
sub upsert_vhost_to_db {
2521
    my ($dbh, $fqdn, $vhost, $now) = @_;
2522
    my $service = vhost_service_name($vhost);
2523
    $dbh->do(
2524
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2525
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2526
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2527
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2528
        undef,
2529
        $vhost, $fqdn, $service, $now, $now,
2530
    );
2531
}
2532

            
2533
sub retire_missing_names {
2534
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2535
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2536
    $sth->execute($fqdn);
2537
    while (my ($name) = $sth->fetchrow_array) {
2538
        next if $active->{$name};
2539
        if ($table eq 'host_aliases') {
2540
            $dbh->do(
2541
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2542
                undef, $now, $fqdn, $name,
2543
            );
2544
        } else {
2545
            $dbh->do(
2546
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2547
                undef, $now, $fqdn, $name,
2548
            );
2549
        }
2550
    }
2551
}
2552

            
2553
sub retire_host_in_db {
2554
    my ($dbh, $fqdn) = @_;
2555
    my $now = iso_now();
2556
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2557
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2558
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2559
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2560
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2561
}
2562

            
Bogdan Timofte authored 4 days ago
2563
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2564
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2565
    my @names;
Bogdan Timofte authored 4 days ago
2566
    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");
2567
    $aliases->execute($fqdn);
2568
    while (my ($name) = $aliases->fetchrow_array) {
2569
        push @names, $name;
2570
    }
Bogdan Timofte authored 4 days ago
2571
    return unique_preserve(@names);
2572
}
2573

            
2574
sub active_vhosts_for_host {
2575
    my ($dbh, $fqdn) = @_;
2576
    my @names;
Bogdan Timofte authored 4 days ago
2577
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2578
    $vhosts->execute($fqdn);
2579
    while (my ($name) = $vhosts->fetchrow_array) {
2580
        push @names, $name;
2581
    }
2582
    return unique_preserve(@names);
2583
}
2584

            
2585
sub active_values_for_host {
2586
    my ($dbh, $table, $column, $fqdn) = @_;
2587
    my @values;
2588
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2589
    $sth->execute($fqdn);
2590
    while (my ($value) = $sth->fetchrow_array) {
2591
        push @values, $value;
2592
    }
2593
    return @values;
2594
}
2595

            
2596
sub load_work_orders_from_db {
2597
    my $dbh = dbh();
2598
    my $orders = { version => 1, work_orders => [] };
2599
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2600
    $sth->execute;
2601
    while (my $row = $sth->fetchrow_hashref) {
2602
        my $wo = {
2603
            id => $row->{id},
2604
            status => $row->{status},
2605
            title => $row->{title},
2606
            reason => $row->{reason},
2607
            created_at => $row->{created_at},
2608
            checklist => [],
2609
            actions => [],
2610
        };
2611
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2612
        $wo->{result} = $row->{result} if length($row->{result} || '');
2613

            
2614
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2615
        $items->execute($row->{id});
2616
        while (my $item = $items->fetchrow_hashref) {
2617
            my %copy = (
2618
                id => $item->{item_id},
2619
                text => $item->{text},
2620
                status => $item->{status},
2621
            );
2622
            for my $key (qw(owner notes updated_at)) {
2623
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2624
            }
2625
            push @{ $wo->{checklist} }, \%copy;
2626
        }
2627

            
2628
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2629
        $actions->execute($row->{id});
2630
        while (my $action = $actions->fetchrow_hashref) {
2631
            my %copy = ( type => $action->{type} );
2632
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2633
            $copy{name} = $action->{name} if length($action->{name} || '');
2634
            push @{ $wo->{actions} }, \%copy;
2635
        }
2636

            
2637
        push @{ $orders->{work_orders} }, $wo;
2638
    }
2639
    return $orders;
2640
}
2641

            
2642
sub save_work_orders_to_db {
2643
    my ($orders) = @_;
2644
    my $dbh = dbh();
2645
    with_transaction($dbh, sub {
2646
        import_work_orders_to_db($dbh, $orders);
2647
    });
2648
}
2649

            
2650
sub import_work_orders_to_db {
2651
    my ($dbh, $orders) = @_;
2652
    my $now = iso_now();
2653
    my %seen;
2654
    for my $wo (@{ $orders->{work_orders} || [] }) {
2655
        my $id = clean_scalar($wo->{id} || '');
2656
        next unless $id;
2657
        $seen{$id} = 1;
2658
        $dbh->do(
2659
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2660
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2661
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2662
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2663
            undef,
2664
            $id,
2665
            clean_scalar($wo->{status} || 'pending'),
2666
            clean_scalar($wo->{title} || ''),
2667
            clean_scalar($wo->{reason} || ''),
2668
            clean_scalar($wo->{created_at} || $now),
2669
            clean_scalar($wo->{confirmed_at} || ''),
2670
            clean_scalar($wo->{result} || ''),
2671
            $now,
2672
        );
2673
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2674
        for my $item (@{ $wo->{checklist} || [] }) {
2675
            $dbh->do(
2676
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2677
                undef,
2678
                $id,
2679
                clean_scalar($item->{id} || ''),
2680
                clean_scalar($item->{text} || ''),
2681
                clean_scalar($item->{status} || 'pending'),
2682
                clean_scalar($item->{owner} || ''),
2683
                clean_scalar($item->{notes} || ''),
2684
                clean_scalar($item->{updated_at} || ''),
2685
            );
2686
        }
2687
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2688
        my $position = 0;
2689
        for my $action (@{ $wo->{actions} || [] }) {
2690
            my $legacy_id = clean_id($action->{host_id} || '');
2691
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2692
            $dbh->do(
2693
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2694
                undef,
2695
                $id,
2696
                $position++,
2697
                clean_scalar($action->{type} || ''),
2698
                $host_fqdn || undef,
2699
                $legacy_id,
2700
                normalize_dns_name($action->{name} || ''),
2701
                '',
2702
            );
2703
        }
2704
    }
2705
}
2706

            
2707
sub seed_default_workers {
2708
    my ($dbh) = @_;
2709
    my $now = iso_now();
2710
    my @workers = (
2711
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2712
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2713
    );
2714
    for my $worker (@workers) {
2715
        $dbh->do(
2716
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2717
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2718
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2719
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2720
            undef,
2721
            @$worker,
2722
            $now,
2723
            $now,
2724
        );
2725
    }
2726
}
2727

            
2728
sub seed_mdns_observations_from_yaml {
2729
    my ($dbh) = @_;
2730
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2731
    my $path = "$project_dir/var/mdns-observations.yaml";
2732
    return unless -f $path;
2733
    my $db = parse_mdns_observations_yaml(read_file($path));
2734
    with_transaction($dbh, sub {
2735
        for my $observation (@{ $db->{observations} || [] }) {
2736
            $dbh->do(
2737
                '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) '
2738
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2739
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2740
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2741
                undef,
2742
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2743
                clean_scalar($observation->{name} || ''),
2744
                clean_scalar($observation->{ip} || ''),
2745
                int($observation->{ttl} || 0),
2746
                clean_scalar($observation->{first_seen} || iso_now()),
2747
                clean_scalar($observation->{last_seen} || iso_now()),
2748
                int($observation->{seen_count} || 1),
2749
                clean_scalar($observation->{last_peer} || ''),
2750
            );
2751
        }
2752
    });
2753
}
2754

            
2755
sub parse_mdns_observations_yaml {
2756
    my ($text) = @_;
2757
    my %db = ( observations => [] );
2758
    my ($section, $current);
2759
    for my $line (split /\n/, $text || '') {
2760
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2761
        if ($line =~ /^observations:\s*$/) {
2762
            $section = 'observations';
2763
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2764
            $current = { key => yaml_unquote($1) };
2765
            push @{ $db{observations} }, $current;
2766
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2767
            $current->{$1} = yaml_unquote($2);
2768
        }
2769
    }
2770
    return \%db;
2771
}
2772

            
2773
sub set_schema_meta {
2774
    my ($dbh, $key, $value) = @_;
2775
    $dbh->do(
2776
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2777
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2778
        undef,
2779
        $key,
2780
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2781
        iso_now(),
2782
    );
2783
}
2784

            
Bogdan Timofte authored 4 days ago
2785
sub fqdn_for_legacy_id {
2786
    my ($dbh, $legacy_id) = @_;
2787
    return '' unless length($legacy_id || '');
2788
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2789
    return $fqdn || '';
2790
}
2791

            
2792
sub canonical_host_fqdn {
2793
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2794
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2795
    return $fqdn if length $fqdn;
2796
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2797
    for my $name (@names) {
2798
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2799
    }
2800
    for my $name (@names) {
2801
        return $name if $name =~ /\./ && !name_is_vhost($name);
2802
    }
2803
    my $id = clean_id($host->{id} || '');
2804
    return $id ? "$id.madagascar.xdev.ro" : '';
2805
}
2806

            
2807
sub legacy_id_from_fqdn {
2808
    my ($fqdn) = @_;
2809
    $fqdn = normalize_dns_name($fqdn);
2810
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2811
    $fqdn =~ s/\..*\z//;
2812
    return clean_id($fqdn);
2813
}
2814

            
2815
sub normalize_dns_name {
2816
    my ($name) = @_;
2817
    $name = lc clean_scalar($name || '');
2818
    $name =~ s/\.\z//;
2819
    return $name;
2820
}
2821

            
2822
sub name_is_vhost {
2823
    my ($name) = @_;
2824
    $name = normalize_dns_name($name);
2825
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2826
}
2827

            
2828
sub vhost_service_name {
2829
    my ($name) = @_;
2830
    $name = normalize_dns_name($name);
2831
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2832
    return '';
2833
}
2834

            
2835
sub short_alias_for_fqdn {
2836
    my ($name) = @_;
2837
    $name = normalize_dns_name($name);
2838
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2839
    return '';
2840
}
2841

            
Bogdan Timofte authored 4 days ago
2842
sub normalize_registry_policy {
2843
    my ($registry) = @_;
2844
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2845
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2846
    $registry->{policy}{runtime_database} = $opt{db};
2847
}
2848

            
2849
sub default_hosts_yaml {
2850
    return <<'YAML';
2851
version: 1
2852
updated_at: ""
2853
policy:
Bogdan Timofte authored 4 days ago
2854
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2855
hosts:
2856
YAML
2857
}
2858

            
2859
sub default_work_orders_yaml {
2860
    return <<'YAML';
2861
version: 1
2862
work_orders:
2863
YAML
2864
}
2865

            
2866
sub ensure_parent_dir {
2867
    my ($path) = @_;
2868
    my $dir = dirname($path);
2869
    make_path($dir) unless -d $dir;
2870
}
2871

            
Xdev Host Manager authored a week ago
2872
sub url_decode {
2873
    my ($value) = @_;
2874
    $value = '' unless defined $value;
2875
    $value =~ tr/+/ /;
2876
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2877
    return $value;
2878
}
2879

            
2880
sub random_hex {
2881
    my ($bytes) = @_;
2882
    if (open my $fh, '<:raw', '/dev/urandom') {
2883
        read($fh, my $raw, $bytes);
2884
        close $fh;
2885
        return unpack('H*', $raw);
2886
    }
2887
    return sha256_hex(rand() . time() . $$);
2888
}
2889

            
2890
sub iso_now {
2891
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2892
}
2893

            
Bogdan Timofte authored 6 days ago
2894
sub build_info {
2895
    my %info = (
2896
        revision => '',
2897
        branch => '',
2898
        built_at => '',
2899
        deployed_at => '',
2900
        dirty => '',
2901
    );
2902

            
2903
    if ($ENV{HOST_MANAGER_BUILD}) {
2904
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2905
        return \%info;
2906
    }
2907

            
2908
    my $build_file = "$project_dir/BUILD";
2909
    if (-f $build_file) {
2910
        for my $line (split /\n/, read_file($build_file)) {
2911
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2912
            $info{$1} = clean_scalar($2);
2913
        }
2914
        return \%info if $info{revision} || $info{built_at};
2915
    }
2916

            
2917
    my $revision = git_value('rev-parse --short=12 HEAD');
2918
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2919
    $info{revision} = $revision if $revision;
2920
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2921
    return \%info;
2922
}
2923

            
2924
sub git_value {
2925
    my ($args) = @_;
2926
    return '' unless -d "$project_dir/.git";
2927
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2928
    my $value = <$fh> || '';
2929
    close $fh;
2930
    chomp $value;
2931
    return clean_scalar($value);
2932
}
2933

            
2934
sub build_label {
2935
    my $info = build_info();
2936
    my $revision = $info->{revision} || 'unknown';
2937
    my $branch = $info->{branch} || '';
2938
    $branch = '' if $branch eq 'HEAD';
2939
    my $label = $branch ? "$branch $revision" : $revision;
2940
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2941
    return $label;
2942
}
2943

            
2944
sub build_title {
2945
    my $info = build_info();
2946
    my $label = build_label();
2947
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2948
    return $stamp ? "$label deployed $stamp" : $label;
2949
}
2950

            
Bogdan Timofte authored 4 days ago
2951
sub build_revision {
2952
    my $info = build_info();
2953
    return $info->{revision} || 'unknown';
2954
}
2955

            
2956
sub build_details {
2957
    my $info = build_info();
2958
    my %details = (
2959
        app => 'Madagascar Local Authority',
2960
        revision => $info->{revision} || 'unknown',
2961
        branch => $info->{branch} || '',
2962
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2963
        built_at => $info->{built_at} || '',
2964
        deployed_at => $info->{deployed_at} || '',
2965
        label => build_label(),
2966
        title => build_title(),
2967
    );
2968
    return json_encode(\%details);
2969
}
2970

            
Bogdan Timofte authored 6 days ago
2971
sub html_escape {
2972
    my ($value) = @_;
2973
    $value = '' unless defined $value;
2974
    $value =~ s/&/&amp;/g;
2975
    $value =~ s/</&lt;/g;
2976
    $value =~ s/>/&gt;/g;
2977
    $value =~ s/"/&quot;/g;
2978
    $value =~ s/'/&#039;/g;
2979
    return $value;
2980
}
2981

            
Xdev Host Manager authored a week ago
2982
sub app_html {
Bogdan Timofte authored 4 days ago
2983
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2984
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2985
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2986
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2987
<!doctype html>
2988
<html lang="ro">
2989
<head>
2990
  <meta charset="utf-8">
2991
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2992
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2993
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2994
  <style>
2995
    :root {
2996
      color-scheme: light;
2997
      --ink: #152033;
2998
      --muted: #647084;
2999
      --line: #d8dee8;
3000
      --soft: #f4f6f9;
3001
      --panel: #ffffff;
3002
      --accent: #1267d8;
3003
      --bad: #b42318;
3004
      --warn: #946200;
3005
      --ok: #137333;
3006
    }
3007
    * { box-sizing: border-box; }
3008
    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
3009

            
3010
    /* ── Login screen ── */
3011
    #login-screen {
3012
      display: flex;
Xdev Host Manager authored a week ago
3013
      align-items: flex-start;
Xdev Host Manager authored a week ago
3014
      justify-content: center;
3015
      min-height: 100dvh;
Xdev Host Manager authored a week ago
3016
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
3017
      background: #13182a;
Xdev Host Manager authored a week ago
3018
      overflow: auto;
Xdev Host Manager authored a week ago
3019
    }
3020
    .login-card {
Xdev Host Manager authored a week ago
3021
      --otp-size: 48px;
Xdev Host Manager authored a week ago
3022
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
3023
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
3024
      background: #fff;
3025
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
3026
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
3027
         below the first box, sits inside the card instead of spilling past it. */
3028
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
3029
      width: 100%;
Xdev Host Manager authored a week ago
3030
      max-width: 680px;
Bogdan Timofte authored 6 days ago
3031
      min-height: 360px;
Xdev Host Manager authored a week ago
3032
      display: grid;
Xdev Host Manager authored a week ago
3033
      align-content: start;
3034
      justify-items: center;
3035
      gap: 28px;
Xdev Host Manager authored a week ago
3036
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
3037
    }
Xdev Host Manager authored a week ago
3038
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
3039
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
3040
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
3041
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
3042
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
3043
    }
Xdev Host Manager authored a week ago
3044
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
3045
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
3046
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
3047
    .login-card form {
3048
      display: grid;
3049
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
3050
      justify-self: center;
Bogdan Timofte authored a week ago
3051
      padding-bottom: 0;
Xdev Host Manager authored a week ago
3052
    }
Xdev Host Manager authored a week ago
3053
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
3054
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
3055
       giving the password manager a username anchor and an aggregated OTP target
3056
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
3057
    .pm-helper-fields {
3058
      position: absolute;
3059
      left: -10000px;
3060
      top: auto;
3061
      width: 1px;
3062
      height: 1px;
3063
      overflow: hidden;
3064
      opacity: 0.01;
3065
    }
3066
    .pm-helper-fields input {
3067
      width: 1px;
3068
      height: 1px;
3069
      padding: 0;
3070
      border: 0;
3071
    }
Bogdan Timofte authored 4 days ago
3072
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
3073
       hint was what made Safari mark the whole group and re-present its OTP
3074
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
3075
    .otp-row {
3076
      display: flex;
3077
      gap: var(--otp-gap);
3078
      justify-content: center;
3079
    }
Bogdan Timofte authored 4 days ago
3080
    .otp-row input {
Xdev Host Manager authored a week ago
3081
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
3082
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
3083
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
3084
      transition: border-color .15s, background .15s;
3085
    }
Bogdan Timofte authored 4 days ago
3086
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
3087
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
3088
    #login-error {
3089
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
3090
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
3091
    }
3092
    @media (max-width: 760px) {
3093
      .login-card {
Xdev Host Manager authored a week ago
3094
        max-width: 520px;
Xdev Host Manager authored a week ago
3095
        min-height: 0;
Bogdan Timofte authored 4 days ago
3096
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
3097
        gap: 26px;
3098
      }
3099
      .login-card .brand h1 { font-size: 24px; }
3100
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
3101
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3102
    }
Xdev Host Manager authored a week ago
3103
    @media (max-width: 430px) {
3104
      #login-screen { padding: 24px 16px 120px; }
3105
      .login-card {
3106
        --otp-size: 42px;
Xdev Host Manager authored a week ago
3107
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
3108
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
3109
      }
Bogdan Timofte authored 4 days ago
3110
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
3111
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3112
    }
3113
    @media (max-height: 720px) {
3114
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
3115
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
3116
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3117
    }
Xdev Host Manager authored a week ago
3118

            
3119
    /* ── App shell (hidden until authenticated) ── */
3120
    #app { display: none; }
Bogdan Timofte authored 5 days ago
3121
    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
3122
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
3123
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
3124
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
3125
    nav a:hover { color: var(--ink); background: var(--soft); }
3126
    nav a.active { color: var(--accent); background: #e8f0fe; }
3127
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
3128
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
3129
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
3130
    .page { display: grid; gap: 16px; }
3131
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
3132
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
3133
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
3134
    .panel { overflow: hidden; }
3135
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
3136
    .panel-head h2 { margin: 0; font-size: 14px; }
3137
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
3138
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
3139
    button, input, select, textarea { font: inherit; }
3140
    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; }
3141
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
3142
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
3143
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
3144
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
3145
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
3146
    textarea { min-height: 74px; resize: vertical; }
3147
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
3148
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
3149
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
3150
    tr:hover td { background: #f8fafc; }
3151
    .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; }
3152
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
3153
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
3154
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
3155
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
3156
    .pill.canonical { font-weight: 700; }
3157
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
3158
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
3159
    .span2 { grid-column: 1 / -1; }
3160
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
3161
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
3162
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
3163
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
3164
    .ca-fingerprint { overflow-wrap: anywhere; }
3165
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
3166
    .build-control {
Bogdan Timofte authored 6 days ago
3167
      position: fixed;
3168
      right: 10px;
3169
      bottom: 8px;
3170
      z-index: 5;
Bogdan Timofte authored 4 days ago
3171
      display: inline-flex;
3172
      align-items: center;
3173
      gap: 4px;
3174
    }
3175
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
3176
      color: rgba(255,255,255,.46);
3177
      background: rgba(19,24,42,.28);
3178
      border: 1px solid rgba(255,255,255,.08);
3179
      border-radius: 4px;
3180
      font-size: 10px;
3181
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
3182
    }
3183
    .build-badge {
3184
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
3185
      cursor: text;
3186
      user-select: text;
Bogdan Timofte authored 6 days ago
3187
    }
Bogdan Timofte authored 4 days ago
3188
    .build-copy {
3189
      min-height: 0;
3190
      padding: 2px 5px;
3191
      cursor: pointer;
3192
    }
3193
    .build-copy:hover {
3194
      color: rgba(255,255,255,.72);
3195
      border-color: rgba(255,255,255,.24);
3196
    }
3197
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
3198
      color: rgba(100,112,132,.58);
3199
      background: rgba(255,255,255,.72);
3200
      border-color: rgba(216,222,232,.72);
3201
    }
Bogdan Timofte authored 4 days ago
3202
    body.is-app .build-copy:hover {
3203
      color: rgba(21,32,51,.78);
3204
      border-color: rgba(100,112,132,.42);
3205
    }
Xdev Host Manager authored a week ago
3206
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
3207
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
3208
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
3209
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
3210
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
3211
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
3212
    .work-order-actions { gap: 4px; }
3213
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
3214
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
3215
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
3216
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
3217
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
3218
    .debug-table-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
Bogdan Timofte authored 4 days ago
3219
    .debug-table-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 58px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
Bogdan Timofte authored 4 days ago
3220
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
3221
    .debug-table-card.active { border-color: var(--accent); background: #e8f0fe; box-shadow: inset 0 0 0 1px var(--accent); }
Bogdan Timofte authored 4 days ago
3222
    .debug-table-card-main { display: grid; align-content: center; justify-items: start; gap: 5px; min-width: 0; min-height: 42px; width: 100%; padding: 4px 6px; border: 0; background: transparent; text-align: left; }
3223
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
3224
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
3225
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3226
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
3227
    .debug-table-copy::before, .debug-table-copy::after { content: ""; position: absolute; width: 12px; height: 14px; border: 1.6px solid currentColor; border-radius: 2px; box-sizing: border-box; }
3228
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
3229
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
3230
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3231
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
3232
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
3233
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
3234
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 3 days ago
3235
    .host-alias-cell { display: grid; gap: 5px; min-width: 0; }
3236
    .host-alias-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3237
    .host-alias-pill { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin: 0; }
3238
    .host-alias-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3239
    .host-alias-remove, .host-alias-add { min-height: 28px; padding: 3px 7px; font-size: 12px; }
3240
    .host-alias-remove { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--bad); }
3241
    .host-alias-remove:hover { background: transparent; }
3242
    .host-cert-cell { min-width: 0; }
Bogdan Timofte authored 4 days ago
3243
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3244
    #page-vhosts .host-tools { flex-wrap: wrap; }
3245
    #page-vhosts .host-tools input { max-width: 280px; }
3246
    #page-vhosts .stats { justify-content: flex-end; }
Bogdan Timofte authored 3 days ago
3247
    #page-vhosts .table-wrap { overflow-x: visible; }
3248
    #page-vhosts table { min-width: 0; }
Bogdan Timofte authored 3 days ago
3249
    #page-vhosts th, #page-vhosts td { overflow-wrap: normal; }
3250
    #page-vhosts .pill.vhost { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; }
Bogdan Timofte authored 3 days ago
3251
    .vhost-name-cell { display: grid; gap: 5px; min-width: 0; }
3252
    .vhost-name-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-width: 0; }
3253
    .vhost-delete { min-height: 28px; padding: 3px 7px; color: var(--bad); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3254
    .vhost-host { display: grid; gap: 2px; }
3255
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
3256
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
3257
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 3 days ago
3258
    .vhost-cert { display: grid; gap: 5px; min-width: 0; }
3259
    .vhost-cert-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; }
3260
    .vhost-cert-select { width: 100%; max-width: 100%; min-height: 34px; }
3261
    .vhost-cert-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
3262
    .vhost-cert-links { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
3263
    .vhost-cert-links .linkbtn { padding: 3px 7px; font-size: 12px; }
3264
    .vhost-cert-validity { font-size: 12px; }
Bogdan Timofte authored 4 days ago
3265
    .vhost-inline-editor { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
Bogdan Timofte authored 3 days ago
3266
    .host-inline-row td { padding: 0; background: #fff; }
3267
    .host-inline-editor-shell { background: #fff; }
3268
    .host-inline-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fafbfc; }
3269
    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3270
    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
3271
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
3272
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
3273
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
3274
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
3275
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
3276
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
3277
      #message { max-width: 100%; }
3278
      .panel-head { align-items: stretch; flex-direction: column; }
3279
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
3280
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
3281
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 3 days ago
3282
      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3283
      .host-inline-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
3284
      .debug-controls { align-items: stretch; }
Xdev Host Manager authored a week ago
3285
      .grid { grid-template-columns: 1fr; }
3286
      table { min-width: 760px; }
3287
      .table-wrap { overflow-x: auto; }
3288
    }
3289
  </style>
3290
</head>
Bogdan Timofte authored 6 days ago
3291
<body class="is-login">
Xdev Host Manager authored a week ago
3292

            
Xdev Host Manager authored a week ago
3293
  <!-- ── Login screen ── -->
3294
  <div id="login-screen">
3295
    <div class="login-card">
3296
      <div class="brand">
3297
        <div class="icon">
Xdev Host Manager authored a week ago
3298
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3299
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3300
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3301
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3302
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3303
            <path d="M26 20h8M26 32h8M26 44h8"/>
3304
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3305
          </svg>
3306
        </div>
Xdev Host Manager authored a week ago
3307
        <h1>Madagascar Local Authority</h1>
3308
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3309
      </div>
Bogdan Timofte authored 4 days ago
3310
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3311
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3312
        <div class="pm-helper-fields" aria-hidden="true">
3313
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3314
          <input type="hidden" id="otp-hidden" name="otp">
3315
        </div>
Xdev Host Manager authored a week ago
3316
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3317
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3318
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3319
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3320
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3321
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3322
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3323
        </div>
3324
      </form>
3325
    </div>
3326
  </div>
3327

            
3328
  <!-- ── App (shown after login) ── -->
3329
  <div id="app">
3330
    <header>
Xdev Host Manager authored a week ago
3331
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3332
      <nav aria-label="Sections">
3333
        <a href="/overview" data-page-link="overview">Overview</a>
3334
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3335
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3336
        <a href="/dns" data-page-link="dns">DNS</a>
3337
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3338
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3339
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3340
      </nav>
Xdev Host Manager authored a week ago
3341
      <div class="header-right">
3342
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3343
        <span id="message" class="muted"></span>
3344
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3345
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3346
      </div>
Xdev Host Manager authored a week ago
3347
    </header>
3348
    <main>
Bogdan Timofte authored 5 days ago
3349
      <section class="page" id="page-overview" data-page="overview">
3350
        <section class="panel">
3351
          <div class="panel-head">
3352
            <h2>Overview</h2>
3353
            <div class="stats" id="stats"></div>
3354
          </div>
3355
          <div class="problems" id="problems"></div>
3356
        </section>
Xdev Host Manager authored a week ago
3357
      </section>
3358

            
Bogdan Timofte authored 5 days ago
3359
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3360
        <section class="panel">
3361
          <div class="panel-head">
3362
            <h2>Hosts</h2>
3363
            <div class="host-tools">
3364
              <input id="filter" placeholder="filter">
3365
              <button type="button" id="new-host">New host</button>
3366
            </div>
3367
          </div>
3368
          <div class="table-wrap">
3369
            <table>
3370
              <thead>
3371
                <tr>
Bogdan Timofte authored 4 days ago
3372
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 3 days ago
3373
                  <th>Aliases</th>
Bogdan Timofte authored 5 days ago
3374
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored 3 days ago
3375
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 5 days ago
3376
                  <th style="width: 110px">Monitoring</th>
3377
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 3 days ago
3378
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3379
                </tr>
3380
              </thead>
3381
              <tbody id="hosts"></tbody>
3382
            </table>
3383
          </div>
3384
        </section>
Xdev Host Manager authored a week ago
3385
      </section>
Xdev Host Manager authored a week ago
3386

            
Bogdan Timofte authored 4 days ago
3387
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3388
        <section class="panel">
3389
          <div class="panel-head">
3390
            <h2>Vhosts</h2>
3391
            <div class="host-tools">
3392
              <input id="vhost-filter" placeholder="filter">
3393
              <div class="stats" id="vhost-stats"></div>
3394
            </div>
3395
          </div>
Bogdan Timofte authored 4 days ago
3396
          <div class="vhost-inline-editor">
3397
            <input id="vhost-new-name" placeholder="vhost fqdn">
3398
            <select id="vhost-new-host"></select>
3399
            <button type="button" id="vhost-add">Add</button>
3400
          </div>
Bogdan Timofte authored 4 days ago
3401
          <div class="table-wrap">
3402
            <table>
3403
              <thead>
3404
                <tr>
Bogdan Timofte authored 3 days ago
3405
                  <th style="width: 22%">Vhost</th>
Bogdan Timofte authored 3 days ago
3406
                  <th style="width: 28%">Host</th>
3407
                  <th style="width: 34%">Certificate</th>
Bogdan Timofte authored 3 days ago
3408
                  <th style="width: 8%">Monitoring</th>
3409
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
3410
                </tr>
3411
              </thead>
3412
              <tbody id="vhosts"></tbody>
3413
            </table>
3414
          </div>
3415
        </section>
3416
      </section>
3417

            
Bogdan Timofte authored 5 days ago
3418
      <section class="page" id="page-dns" data-page="dns" hidden>
3419
        <section class="toolbar">
3420
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3421
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3422
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3423
          <button id="write-tsv">Write local-hosts.tsv</button>
3424
        </section>
Xdev Host Manager authored a week ago
3425
      </section>
3426

            
Bogdan Timofte authored 5 days ago
3427
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3428
        <section class="panel">
3429
          <div class="panel-head">
3430
            <h2>Work Orders</h2>
3431
            <div class="stats" id="wo-stats"></div>
3432
          </div>
3433
          <div class="problems" id="work-orders"></div>
3434
        </section>
Xdev Host Manager authored a week ago
3435
      </section>
3436

            
Bogdan Timofte authored 5 days ago
3437
      <section class="page" id="page-ca" data-page="ca" hidden>
3438
        <section class="panel">
3439
          <div class="panel-head">
3440
            <h2>Local Certificate Authority</h2>
3441
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3442
          </div>
3443
          <div class="problems" id="ca-status"></div>
3444
        </section>
3445
        <section class="panel">
3446
          <div class="panel-head">
3447
            <h2>Issued Certificates</h2>
3448
            <div class="stats" id="ca-certs-summary"></div>
3449
          </div>
3450
          <div class="table-wrap">
3451
            <table>
3452
              <thead>
3453
                <tr>
3454
                  <th style="width: 150px">Name</th>
3455
                  <th>DNS names</th>
3456
                  <th style="width: 210px">Validity</th>
3457
                  <th style="width: 180px">Serial</th>
3458
                  <th>Fingerprint</th>
3459
                  <th style="width: 110px">Download</th>
3460
                </tr>
3461
              </thead>
3462
              <tbody id="ca-certs"></tbody>
3463
            </table>
3464
          </div>
3465
        </section>
Xdev Host Manager authored a week ago
3466
      </section>
Bogdan Timofte authored 4 days ago
3467

            
3468
      <section class="page" id="page-debug" data-page="debug" hidden>
3469
        <section class="panel">
3470
          <div class="panel-head">
3471
            <h2>Database</h2>
3472
            <div class="stats" id="debug-db-stats"></div>
3473
          </div>
3474
          <div class="toolbar">
3475
            <div class="debug-controls">
3476
              <button type="button" id="debug-db-refresh">Refresh</button>
3477
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3478
            </div>
3479
          </div>
Bogdan Timofte authored 4 days ago
3480
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3481
        </section>
3482
        <section class="debug-section">
3483
          <section class="panel">
3484
            <div class="panel-head">
3485
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3486
              <div class="debug-table-head-actions">
3487
                <div class="stats" id="debug-table-stats"></div>
3488
                <div class="debug-table-exports">
3489
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3490
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3491
                </div>
3492
              </div>
Bogdan Timofte authored 4 days ago
3493
            </div>
3494
            <div class="table-wrap" id="debug-table-rows"></div>
3495
          </section>
3496
          <section class="panel">
3497
            <div class="panel-head">
3498
              <h2>Columns</h2>
3499
            </div>
3500
            <div class="table-wrap" id="debug-table-columns"></div>
3501
          </section>
3502
          <section class="panel">
3503
            <div class="panel-head">
3504
              <h2>Indexes</h2>
3505
            </div>
3506
            <div class="table-wrap" id="debug-table-indexes"></div>
3507
          </section>
3508
          <section class="panel">
3509
            <div class="panel-head">
3510
              <h2>Foreign Keys</h2>
3511
            </div>
3512
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3513
          </section>
3514
        </section>
3515
      </section>
Bogdan Timofte authored 5 days ago
3516
    </main>
Xdev Host Manager authored a week ago
3517

            
3518
  </div>
3519

            
Bogdan Timofte authored 4 days ago
3520
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3521
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3522
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3523
  </div>
Bogdan Timofte authored 6 days ago
3524

            
Xdev Host Manager authored a week ago
3525
  <script>
Bogdan Timofte authored 3 days ago
3526
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3527
    let hostFormSnapshot = '';
Bogdan Timofte authored 3 days ago
3528
    let hostFormBusy = false;
3529
    let hostFormMode = 'new';
Bogdan Timofte authored 3 days ago
3530
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3531

            
3532
    const $ = (id) => document.getElementById(id);
3533
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 3 days ago
3534
    const hostFormShell = document.createElement('div');
3535
    hostFormShell.id = 'host-form-shell';
3536
    hostFormShell.className = 'host-inline-editor-shell';
3537
    hostFormShell.hidden = true;
3538
    hostFormShell.innerHTML = `
3539
      <div class="host-inline-editor-head">
3540
        <h2 id="host-form-title">New host</h2>
3541
        <div class="host-inline-editor-tools">
3542
          <button type="button" id="cancel-host-form">Close</button>
3543
        </div>
3544
      </div>
3545
      <form id="host-form" class="grid">
Bogdan Timofte authored 3 days ago
3546
        <label>Legacy ID<input name="id" required></label>
Bogdan Timofte authored 3 days ago
3547
        <label>FQDN<input name="fqdn" required></label>
3548
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3549
        <label>IP<input name="ip" required></label>
3550
        <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3551
        <label>Roles<input name="roles"></label>
3552
        <label>Sources<input name="sources"></label>
3553
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3554
        <label>Notes<input name="notes"></label>
3555
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3556
        <div class="span2 form-actions">
3557
          <button class="primary" type="submit" id="save-host">Save host</button>
3558
          <button class="danger" type="button" id="delete-host">Delete host</button>
3559
        </div>
3560
      </form>`;
3561
    const hostForm = hostFormShell.querySelector('#host-form');
3562
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3563
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3564
    const saveHostButton = hostFormShell.querySelector('#save-host');
3565
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3566
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
3567
    const hostEditorRow = document.createElement('tr');
3568
    hostEditorRow.className = 'host-inline-row';
3569
    const hostEditorCell = document.createElement('td');
3570
    hostEditorCell.colSpan = 7;
3571
    hostEditorRow.appendChild(hostEditorCell);
3572
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
3573
    const PAGE_PATHS = {
3574
      '/': 'overview',
3575
      '/overview': 'overview',
3576
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3577
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3578
      '/dns': 'dns',
3579
      '/work-orders': 'work-orders',
3580
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3581
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3582
    };
Xdev Host Manager authored a week ago
3583

            
Bogdan Timofte authored 4 days ago
3584
    function isAuthLost(error) {
3585
      return !!(error && error.authLost);
3586
    }
3587

            
3588
    function authLostError(message) {
3589
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3590
      error.authLost = true;
3591
      return error;
3592
    }
3593

            
3594
    function handleAuthLost(message) {
3595
      state.authenticated = false;
3596
      msg('');
3597
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3598
    }
3599

            
Bogdan Timofte authored 4 days ago
3600
    async function ensureAuthenticated(message) {
3601
      if (!state.authenticated) {
3602
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3603
        return false;
3604
      }
3605
      const session = await api('/api/session');
3606
      state.authenticated = session.authenticated;
3607
      if (!state.authenticated) {
3608
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3609
        return false;
3610
      }
3611
      return true;
3612
    }
3613

            
Xdev Host Manager authored a week ago
3614
    async function api(path, options = {}) {
3615
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3616
      let body = {};
3617
      try {
3618
        body = await res.json();
3619
      } catch (_) {
3620
        body = {};
3621
      }
3622
      const errorCode = body.error || '';
3623
      if (!res.ok) {
3624
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3625
          const error = authLostError();
3626
          handleAuthLost(error.message);
3627
          throw error;
3628
        }
3629
        throw new Error(errorCode || res.statusText);
3630
      }
Xdev Host Manager authored a week ago
3631
      return body;
3632
    }
3633

            
Bogdan Timofte authored 5 days ago
3634
    function currentPage() {
3635
      return PAGE_PATHS[window.location.pathname] || 'overview';
3636
    }
3637

            
3638
    function showPage(page, push = false) {
3639
      const target = page || 'overview';
3640
      document.querySelectorAll('[data-page]').forEach(section => {
3641
        section.hidden = section.dataset.page !== target;
3642
      });
3643
      document.querySelectorAll('[data-page-link]').forEach(link => {
3644
        link.classList.toggle('active', link.dataset.pageLink === target);
3645
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3646
      });
3647
      if (push) {
3648
        const href = target === 'overview' ? '/overview' : '/' + target;
3649
        history.pushState({ page: target }, '', href);
3650
      }
Bogdan Timofte authored 4 days ago
3651
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3652
        renderDebugDatabase().catch(e => {
3653
          if (!isAuthLost(e)) msg(e.message);
3654
        });
Bogdan Timofte authored 4 days ago
3655
      }
Bogdan Timofte authored 5 days ago
3656
    }
3657

            
Xdev Host Manager authored a week ago
3658
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3659
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3660
      document.body.classList.remove('is-app');
3661
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3662
      $('app').style.display = 'none';
3663
      $('login-screen').style.display = 'flex';
3664
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3665
      clearOtp();
Xdev Host Manager authored a week ago
3666
    }
3667

            
3668
    function showApp() {
Bogdan Timofte authored 6 days ago
3669
      document.body.classList.remove('is-login');
3670
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3671
      $('login-screen').style.display = 'none';
3672
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3673
      showPage(currentPage());
Xdev Host Manager authored a week ago
3674
    }
3675

            
Xdev Host Manager authored a week ago
3676
    async function refresh() {
3677
      const session = await api('/api/session');
3678
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3679
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3680
      showApp();
Xdev Host Manager authored a week ago
3681
      const data = await api('/api/hosts');
3682
      state.hosts = data.hosts || [];
Bogdan Timofte authored 3 days ago
3683
      state.vhosts = data.vhosts || [];
3684
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3685
      state.problems = data.problems || [];
3686
      render(data);
Xdev Host Manager authored a week ago
3687
      await renderCa();
Xdev Host Manager authored a week ago
3688
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3689
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3690
    }
3691

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

            
Xdev Host Manager authored a week ago
3695
      $('stats').innerHTML = [
3696
        ['hosts', data.counts.hosts],
Bogdan Timofte authored 4 days ago
3697
        ['vhosts', data.counts.vhosts || vhostRows().length],
Xdev Host Manager authored a week ago
3698
        ['problems', data.counts.problems],
3699
      ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3700

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

            
3705
      renderHosts();
Bogdan Timofte authored 4 days ago
3706
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3707
      renderVhosts();
Xdev Host Manager authored a week ago
3708
    }
3709

            
Xdev Host Manager authored a week ago
3710
    async function renderCa() {
3711
      try {
3712
        const status = await api('/api/ca/status');
3713
        if (!status.initialized) {
3714
          $('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
3715
          $('ca-certs-summary').innerHTML = '';
3716
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3717
          return;
3718
        }
3719
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 3 days ago
3720
        state.certificates = certs.map(cert => ({
3721
          ...cert,
3722
          id: cert.id || cert.name || '',
3723
          name: cert.name || cert.id || '',
3724
          has_private_key: !!cert.has_private_key
3725
        }));
Bogdan Timofte authored 5 days ago
3726
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3727
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3728
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3729
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3730
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3731
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3732
            <div>
3733
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3734
              <span>${certs.length} issued certificate(s)</span>
3735
            </div>
Xdev Host Manager authored a week ago
3736
          </div>`;
Bogdan Timofte authored 5 days ago
3737
        $('ca-certs-summary').innerHTML = [
3738
          ['issued', certs.length],
3739
          ['expiring', certs.filter(cert => {
3740
            const days = daysUntil(cert.not_after);
3741
            return days !== null && days >= 0 && days <= 30;
3742
          }).length],
3743
          ['expired', certs.filter(cert => {
3744
            const days = daysUntil(cert.not_after);
3745
            return days !== null && days < 0;
3746
          }).length],
3747
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3748
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3749
          const days = daysUntil(cert.not_after);
3750
          const dnsNames = cert.dns_names || [];
3751
          const dnsHtml = dnsNames.length
3752
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3753
            : '<span class="muted">No DNS SANs reported.</span>';
3754
          return `<tr>
3755
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3756
            <td>${dnsHtml}</td>
3757
            <td>
3758
              <div class="ca-detail">
3759
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3760
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3761
              </div>
3762
            </td>
3763
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3764
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 3 days ago
3765
            <td>
3766
              <div class="vhost-cert-links">
3767
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
3768
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
3769
              </div>
3770
            </td>
Bogdan Timofte authored 5 days ago
3771
          </tr>`;
3772
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3773
      } catch (e) {
Bogdan Timofte authored 4 days ago
3774
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3775
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3776
        $('ca-certs-summary').innerHTML = '';
3777
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3778
      }
3779
    }
3780

            
Bogdan Timofte authored 5 days ago
3781
    function daysUntil(dateText) {
3782
      const time = Date.parse(dateText || '');
3783
      if (!Number.isFinite(time)) return null;
3784
      return Math.ceil((time - Date.now()) / 86400000);
3785
    }
3786

            
3787
    function certStatusClass(days) {
3788
      if (days === null) return '';
3789
      if (days < 0) return 'bad';
3790
      if (days <= 30) return 'warn';
3791
      return 'ok';
3792
    }
3793

            
3794
    function certStatusLabel(days) {
3795
      if (days === null) return 'validity unknown';
3796
      if (days < 0) return 'expired';
3797
      if (days === 0) return 'expires today';
3798
      return `${days}d remaining`;
3799
    }
3800

            
Xdev Host Manager authored a week ago
3801
    async function renderWorkOrders() {
3802
      try {
3803
        const data = await api('/api/work-orders');
3804
        state.workOrders = data.work_orders || [];
3805
        $('wo-stats').innerHTML = [
3806
          ['pending', data.counts.pending],
3807
          ['total', data.counts.work_orders],
3808
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3809

            
3810
        if (!state.workOrders.length) {
3811
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3812
          return;
3813
        }
3814

            
3815
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3816
          const checklist = wo.checklist || [];
3817
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3818
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3819
          const checklistHtml = checklist.map(item => {
3820
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3821
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3822
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3823
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3824
            </label>`;
3825
          }).join('');
Xdev Host Manager authored a week ago
3826
          const actions = (wo.actions || []).map(a => {
3827
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3828
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3829
          }).join('');
3830
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3831
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3832
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3833
            : '';
Bogdan Timofte authored 6 days ago
3834
          return `<div class="problem work-order-card">
3835
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3836
              <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
3837
              ${button}
3838
            </div>
Bogdan Timofte authored 6 days ago
3839
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3840
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3841
            <div class="work-order-checklist">${checklistHtml}</div>
3842
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3843
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3844
          </div>`;
3845
        }).join('');
Xdev Host Manager authored a week ago
3846
        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
3847
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3848
      } catch (e) {
Bogdan Timofte authored 4 days ago
3849
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3850
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3851
      }
3852
    }
3853

            
Bogdan Timofte authored 4 days ago
3854
    async function renderDebugDatabase() {
3855
      if (!state.authenticated) return;
3856
      const data = await api('/api/debug/database/tables');
3857
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3858
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3859
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3860
      $('debug-db-stats').innerHTML = [
3861
        ['tables', data.counts ? data.counts.tables : tables.length],
3862
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3863
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3864
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3865
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3866
      if (selected) {
3867
        await renderDebugTable(selected);
3868
      } else {
3869
        clearDebugTable();
3870
      }
3871
    }
3872

            
Bogdan Timofte authored 4 days ago
3873
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3874
      $('debug-db-tables').innerHTML = tables.length
3875
        ? tables.map(table => {
3876
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3877
            const ref = debugTableReference(database, table.name);
3878
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3879
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3880
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3881
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3882
              </button>
Bogdan Timofte authored 4 days ago
3883
              <button type="button" class="debug-table-copy" data-debug-table-ref="${escapeHtml(ref)}" title="${escapeHtml(ref)}" aria-label="Copy full table reference for ${escapeHtml(table.name)}"></button>
Bogdan Timofte authored 4 days ago
3884
            </div>`;
Bogdan Timofte authored 4 days ago
3885
          }).join('')
3886
        : '<div class="ca-empty muted">No database tables found.</div>';
3887
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3888
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3889
          if (!isAuthLost(e)) msg(e.message);
3890
        }));
3891
      });
Bogdan Timofte authored 4 days ago
3892
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3893
        button.addEventListener('click', async () => {
3894
          try {
3895
            await copyText(button.dataset.debugTableRef || '');
3896
            msg('table reference copied');
3897
          } catch (e) {
3898
            msg('copy failed');
3899
          }
3900
        });
3901
      });
3902
    }
3903

            
3904
    function debugTableReference(database, tableName) {
3905
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3906
    }
3907

            
3908
    async function selectDebugTable(tableName) {
3909
      state.debugTable = tableName || '';
3910
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3911
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3912
        const card = button.closest('.debug-table-card');
3913
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3914
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3915
      });
3916
      if (state.debugTable) await renderDebugTable(state.debugTable);
3917
    }
3918

            
3919
    function clearDebugTable() {
3920
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3921
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3922
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3923
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3924
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3925
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3926
    }
3927

            
3928
    async function renderDebugTable(tableName) {
3929
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3930
      if (data.error) throw new Error(data.error);
3931
      $('debug-table-stats').innerHTML = [
3932
        ['table', data.table || tableName],
3933
        ['rows', data.row_count || 0],
3934
        ['shown', (data.rows || []).length],
3935
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3936
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3937
      renderDebugRows(data);
3938
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3939
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3940
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3941
    }
3942

            
Bogdan Timofte authored 4 days ago
3943
    function updateDebugExportLinks(tableName) {
3944
      const encoded = encodeURIComponent(tableName || '');
3945
      [
3946
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3947
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3948
      ].forEach(([id, href]) => {
3949
        const link = $(id);
3950
        const enabled = !!tableName;
3951
        link.href = enabled ? href : '#';
3952
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3953
      });
3954
    }
3955

            
Bogdan Timofte authored 4 days ago
3956
    function renderDebugRows(data) {
3957
      const rows = data.rows || [];
3958
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3959
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3960
    }
3961

            
3962
    function renderDebugObjectTable(rows, preferredKeys) {
3963
      const keys = preferredKeys && preferredKeys.length
3964
        ? preferredKeys
3965
        : Array.from(rows.reduce((set, row) => {
3966
            Object.keys(row || {}).forEach(key => set.add(key));
3967
            return set;
3968
          }, new Set()));
3969
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3970
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3971
      const body = rows.length
3972
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3973
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3974
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3975
    }
3976

            
3977
    function debugCell(value) {
3978
      if (value === null || value === undefined) return 'NULL';
3979
      if (Array.isArray(value)) return value.join(', ');
3980
      if (typeof value === 'object') return JSON.stringify(value);
3981
      return String(value);
3982
    }
3983

            
Xdev Host Manager authored a week ago
3984
    async function updateWorkOrderChecklist(id, itemId, checked) {
3985
      try {
3986
        await api('/api/work-orders/checklist', {
3987
          method: 'POST',
3988
          headers: { 'Content-Type': 'application/json' },
3989
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3990
        });
3991
        msg('work order updated');
3992
        await refresh();
Bogdan Timofte authored 4 days ago
3993
      } catch (e) {
3994
        if (isAuthLost(e)) return;
3995
        msg(e.message);
3996
        await refresh().catch(refreshError => {
3997
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3998
        });
3999
      }
Xdev Host Manager authored a week ago
4000
    }
4001

            
Xdev Host Manager authored a week ago
4002
    async function confirmWorkOrder(id) {
4003
      const typed = prompt(`Type ${id} to confirm this work order`);
4004
      if (typed !== id) return;
4005
      try {
4006
        await api('/api/work-orders/confirm', {
4007
          method: 'POST',
4008
          headers: { 'Content-Type': 'application/json' },
4009
          body: JSON.stringify({ id, confirm: typed })
4010
        });
4011
        msg('work order confirmed; local-hosts.tsv written');
4012
        await refresh();
Bogdan Timofte authored 4 days ago
4013
      } catch (e) {
4014
        if (isAuthLost(e)) return;
4015
        msg(e.message);
4016
      }
Xdev Host Manager authored a week ago
4017
    }
4018

            
Xdev Host Manager authored a week ago
4019
    function renderHosts() {
4020
      const filter = $('filter').value.toLowerCase();
4021
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
4022
        .slice()
Bogdan Timofte authored 3 days ago
4023
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
4024
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
4025
        .map(h => {
4026
          const problems = state.problems.filter(p => p.host_id === h.id);
4027
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 3 days ago
4028
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 4 days ago
4029
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 3 days ago
4030
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
4031
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored 3 days ago
4032
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
4033
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4034
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 3 days ago
4035
            <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
Xdev Host Manager authored a week ago
4036
          </tr>`;
4037
        }).join('');
Bogdan Timofte authored 4 days ago
4038
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
4039
        editHost(button.dataset.edit).catch(e => {
4040
          if (!isAuthLost(e)) msg(e.message);
4041
        });
4042
      }));
Bogdan Timofte authored 3 days ago
4043
      document.querySelectorAll('[data-host-alias-add]').forEach(button => button.addEventListener('click', () => {
4044
        addHostAlias(button.dataset.hostAliasAdd || '').catch(e => {
4045
          if (!isAuthLost(e)) msg(e.message);
4046
        });
4047
      }));
4048
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4049
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4050
          if (!isAuthLost(e)) msg(e.message);
4051
        });
4052
      }));
4053
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4054
        select.addEventListener('change', () => {
4055
          setHostCertificateFromSelect(select).catch(e => {
4056
            if (!isAuthLost(e)) msg(e.message);
4057
            select.value = select.dataset.currentCertificate || '';
4058
          });
4059
        });
4060
      });
4061
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4062
        button.addEventListener('click', () => {
4063
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4064
            if (!isAuthLost(e)) msg(e.message);
4065
          });
4066
        });
4067
      });
Bogdan Timofte authored 3 days ago
4068
      mountHostEditor();
Xdev Host Manager authored a week ago
4069
    }
4070

            
Bogdan Timofte authored 3 days ago
4071
    function renderHostAliasCell(host) {
4072
      const canonical = host.fqdn ? `<span class="pill canonical host-alias-pill"><span class="host-alias-label">${escapeHtml(host.fqdn)}</span></span>` : '';
4073
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4074
        <span class="host-alias-label">${escapeHtml(name)}</span>
4075
        <button type="button" class="host-alias-remove" data-host-alias-remove="${escapeHtml(host.fqdn || '')}" data-host-alias-name="${escapeHtml(name)}" title="Delete ${escapeHtml(name)}">x</button>
4076
      </span>`).join('');
4077
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived host-alias-pill" title="derived alias">
4078
        <span class="host-alias-label">${escapeHtml(name)}</span>
4079
      </span>`).join('');
4080
      return `<div class="host-alias-cell">
4081
        <div class="host-alias-list">${canonical}${aliases}${derivedAliases}<button type="button" class="host-alias-add" data-host-alias-add="${escapeHtml(host.fqdn || '')}" title="Add alias">+</button></div>
4082
      </div>`;
4083
    }
4084

            
4085
    function renderHostCertificateCell(host) {
4086
      const cert = host.certificate || {};
Bogdan Timofte authored 3 days ago
4087
      const certId = host.certificate_id || certificateIdOf(cert) || '';
Bogdan Timofte authored 3 days ago
4088
      const row = hostCertificateRow(host);
4089
      const links = certId ? `<div class="vhost-cert-links">
4090
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4091
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4092
      </div>` : '';
4093
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4094
      return `<div class="vhost-cert">
4095
        <div class="vhost-cert-main">
4096
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4097
            ${renderCertificateOptions(certId, row)}
4098
          </select>
4099
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4100
        </div>
4101
        <div class="vhost-cert-meta">${links}${validity}</div>
4102
      </div>`;
4103
    }
4104

            
4105
    function hostCertificateRow(host) {
4106
      return {
4107
        host_fqdn: host.fqdn || '',
4108
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4109
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4110
        certificate_id: host.certificate_id || '',
4111
        certificate: host.certificate || null,
4112
      };
Bogdan Timofte authored 4 days ago
4113
    }
4114

            
4115
    function vhostRows() {
Bogdan Timofte authored 3 days ago
4116
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4117
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4118
        vhost,
4119
        host_id: host.id || '',
4120
        host_fqdn: host.fqdn || '',
4121
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4122
        monitoring: host.monitoring || '',
4123
        status: host.status || '',
Bogdan Timofte authored 3 days ago
4124
        certificate_id: '',
4125
        certificate: null,
Bogdan Timofte authored 4 days ago
4126
      })));
4127
    }
4128

            
4129
    function renderVhosts() {
4130
      const input = $('vhost-filter');
4131
      const filter = input ? input.value.toLowerCase() : '';
4132
      const rows = vhostRows()
4133
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
4134
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
4135
      $('vhost-stats').innerHTML = [
4136
        ['shown', rows.length],
4137
        ['total', vhostRows().length],
4138
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4139
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
Bogdan Timofte authored 3 days ago
4140
        <td>${renderVhostNameCell(row)}</td>
Bogdan Timofte authored 4 days ago
4141
        <td>
4142
          <div class="vhost-host">
4143
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
4144
              ${renderVhostHostOptions(row.host_fqdn)}
4145
            </select>
4146
          </div>
4147
        </td>
Bogdan Timofte authored 3 days ago
4148
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
4149
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
4150
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 3 days ago
4151
      </tr>`).join('') : '<tr><td colspan="5" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
4152
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
4153
        select.addEventListener('change', () => {
4154
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
4155
            if (!isAuthLost(e)) msg(e.message);
4156
            select.value = select.dataset.currentHost || '';
4157
          });
Bogdan Timofte authored 4 days ago
4158
        });
Bogdan Timofte authored 4 days ago
4159
      });
Bogdan Timofte authored 4 days ago
4160
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
4161
        button.addEventListener('click', () => {
4162
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
4163
            if (!isAuthLost(e)) msg(e.message);
4164
          });
4165
        });
4166
      });
Bogdan Timofte authored 3 days ago
4167
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
4168
        select.addEventListener('change', () => {
4169
          setVhostCertificateFromSelect(select).catch(e => {
4170
            if (!isAuthLost(e)) msg(e.message);
4171
            select.value = select.dataset.currentCertificate || '';
4172
          });
4173
        });
4174
      });
4175
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
4176
        button.addEventListener('click', () => {
Bogdan Timofte authored 3 days ago
4177
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 3 days ago
4178
            if (!isAuthLost(e)) msg(e.message);
4179
          });
4180
        });
4181
      });
4182
    }
4183

            
Bogdan Timofte authored 3 days ago
4184
    function renderVhostNameCell(row) {
4185
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4186
      return `<div class="vhost-name-cell">
4187
        <div class="vhost-name-main">
4188
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4189
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4190
        </div>
4191
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4192
      </div>`;
4193
    }
4194

            
Bogdan Timofte authored 3 days ago
4195
    function renderVhostCertificateCell(row) {
4196
      const cert = row.certificate || {};
4197
      const certId = row.certificate_id || cert.id || cert.name || '';
4198
      const links = certId ? `<div class="vhost-cert-links">
4199
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4200
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4201
      </div>` : '';
4202
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4203
      return `<div class="vhost-cert">
4204
        <div class="vhost-cert-main">
4205
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 3 days ago
4206
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 3 days ago
4207
          </select>
4208
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4209
        </div>
4210
        <div class="vhost-cert-meta">${links}${validity}</div>
4211
      </div>`;
Bogdan Timofte authored 4 days ago
4212
    }
4213

            
4214
    function renderVhostEditor() {
4215
      const select = $('vhost-new-host');
4216
      const current = select.value || '';
4217
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4218
    }
4219

            
4220
    function renderVhostHostOptions(selectedHostFqdn) {
4221
      return state.hosts
4222
        .slice()
4223
        .filter(host => (host.status || '') !== 'retired')
4224
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4225
        .map(host => {
4226
          const fqdn = host.fqdn || '';
4227
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4228
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4229
        }).join('');
Bogdan Timofte authored 4 days ago
4230
    }
4231

            
Bogdan Timofte authored 3 days ago
4232
    function renderCertificateOptions(selectedCertificateId, row) {
4233
      const byId = new Map();
4234
      (state.certificates || []).forEach(cert => {
Bogdan Timofte authored 3 days ago
4235
        const id = certificateIdOf(cert);
Bogdan Timofte authored 3 days ago
4236
        if (id) byId.set(id, cert);
4237
      });
4238
      if (row && row.certificate) {
Bogdan Timofte authored 3 days ago
4239
        const id = certificateIdOf(row.certificate);
Bogdan Timofte authored 3 days ago
4240
        if (id && !byId.has(id)) byId.set(id, row.certificate);
4241
      }
4242
      const certs = Array.from(byId.values())
Bogdan Timofte authored 3 days ago
4243
        .filter(cert => certMatchesRow(cert, row) || certificateIdOf(cert) === selectedCertificateId)
Bogdan Timofte authored 3 days ago
4244
        .sort((a, b) => {
4245
          const ar = certRelevance(a, row);
4246
          const br = certRelevance(b, row);
4247
          if (ar !== br) return ar - br;
4248
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4249
        });
Bogdan Timofte authored 3 days ago
4250
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 3 days ago
4251
        const id = certificateIdOf(cert);
Bogdan Timofte authored 3 days ago
4252
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 3 days ago
4253
        const selected = id === selectedCertificateId ? ' selected' : '';
4254
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
4255
      }));
4256
      return options.join('');
4257
    }
4258

            
Bogdan Timofte authored 3 days ago
4259
    function certificateIdOf(cert) {
Bogdan Timofte authored 3 days ago
4260
      return cert ? (cert.id || cert.name || '') : '';
4261
    }
4262

            
4263
    function certDnsNames(cert) {
4264
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4265
        .map(name => String(name || '').toLowerCase())
4266
        .filter(Boolean);
4267
    }
4268

            
4269
    function certRelevance(cert, row) {
4270
      if (!row) return 9;
4271
      const names = new Set(certDnsNames(cert));
Bogdan Timofte authored 3 days ago
4272
      const id = String(certificateIdOf(cert)).toLowerCase();
Bogdan Timofte authored 3 days ago
4273
      const commonName = String(cert.common_name || '').toLowerCase();
4274
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 3 days ago
4275
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 3 days ago
4276
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 3 days ago
4277
      const aliasNames = []
4278
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4279
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4280
        .map(name => String(name || '').toLowerCase())
4281
        .filter(Boolean);
4282
      if (vhost) {
4283
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4284
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4285
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4286
        return 9;
4287
      }
4288
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4289
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 3 days ago
4290
      return 9;
4291
    }
4292

            
4293
    function certMatchesRow(cert, row) {
4294
      return certRelevance(cert, row) < 9;
4295
    }
4296

            
4297
    function compactCertificateLabel(cert, row) {
4298
      const relevance = certRelevance(cert, row);
Bogdan Timofte authored 3 days ago
4299
      const id = String(certificateIdOf(cert));
Bogdan Timofte authored 3 days ago
4300
      const days = daysUntil(cert.not_after);
4301
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
4302
      const timestamp = id.match(/-(\d{14})$/);
Bogdan Timofte authored 3 days ago
4303
      if (row && row.vhost) {
4304
        if (relevance === 0) return `vhost${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4305
        if (relevance === 1) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4306
        if (relevance === 2) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4307
      } else {
4308
        if (relevance === 0) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4309
        if (relevance === 1) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4310
      }
Bogdan Timofte authored 3 days ago
4311
      return `${shortCertificateName(cert)}${suffix}`;
4312
    }
4313

            
4314
    function shortCertificateName(cert) {
4315
      const name = String(cert.common_name || cert.name || cert.id || '');
4316
      const suffix = '.madagascar.xdev.ro';
4317
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4318
    }
4319

            
Bogdan Timofte authored 4 days ago
4320
    function shortAliasForFqdn(name) {
4321
      const suffix = '.madagascar.xdev.ro';
4322
      name = String(name || '').toLowerCase();
4323
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
4324
    }
4325

            
Bogdan Timofte authored 3 days ago
4326
    function hostByFqdn(fqdn) {
4327
      fqdn = String(fqdn || '').toLowerCase();
4328
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4329
    }
4330

            
4331
    function hostUpsertPayload(host, overrides = {}) {
4332
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4333
      const payload = {
4334
        id: host.id || '',
4335
        fqdn: host.fqdn || '',
4336
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4337
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4338
        aliases,
4339
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
4340
        sources: Array.isArray(overrides.sources) ? overrides.sources : (host.sources || []),
4341
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4342
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4343
      };
4344
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4345
      return payload;
4346
    }
4347

            
4348
    async function addHostAlias(hostFqdn) {
4349
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4350
      const host = hostByFqdn(hostFqdn);
4351
      if (!host) return;
4352
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4353
      if (!alias) return;
4354
      if (alias === String(host.fqdn || '').toLowerCase()) {
4355
        msg('fqdn-ul hostului este deja prezent');
4356
        return;
4357
      }
4358
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4359
      await api('/api/hosts/upsert', {
4360
        method: 'POST',
4361
        headers: { 'Content-Type': 'application/json' },
4362
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4363
      });
4364
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4365
      await refresh();
4366
    }
4367

            
4368
    async function removeHostAlias(hostFqdn, alias) {
4369
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4370
      const host = hostByFqdn(hostFqdn);
4371
      alias = String(alias || '').trim().toLowerCase();
4372
      if (!host || !alias) return;
4373
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4374
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4375
      await api('/api/hosts/upsert', {
4376
        method: 'POST',
4377
        headers: { 'Content-Type': 'application/json' },
4378
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4379
      });
4380
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4381
      await refresh();
4382
    }
4383

            
4384
    async function setHostCertificateFromSelect(select) {
4385
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4386
        select.value = select.dataset.currentCertificate || '';
4387
        return;
4388
      }
4389
      const hostFqdn = select.dataset.hostCertSelect || '';
4390
      const certificateId = select.value || '';
4391
      const current = select.dataset.currentCertificate || '';
4392
      if (!hostFqdn || certificateId === current) return;
4393
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4394
        select.value = current;
4395
        return;
4396
      }
4397
      select.disabled = true;
4398
      try {
4399
        await api('/api/hosts/certificate', {
4400
          method: 'POST',
4401
          headers: { 'Content-Type': 'application/json' },
4402
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4403
        });
4404
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4405
        await refresh();
4406
      } finally {
4407
        select.disabled = false;
4408
      }
4409
    }
4410

            
4411
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4412
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4413
      if (!hostFqdn) return;
4414
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4415
      if (button) button.disabled = true;
4416
      try {
4417
        const result = await api('/api/hosts/issue-certificate', {
4418
          method: 'POST',
4419
          headers: { 'Content-Type': 'application/json' },
4420
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4421
        });
4422
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4423
        await refresh();
4424
      } finally {
4425
        if (button) button.disabled = false;
4426
      }
4427
    }
4428

            
Bogdan Timofte authored 4 days ago
4429
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4430
      const vhost = select.dataset.vhostSelect || '';
4431
      const fromHost = select.dataset.currentHost || '';
4432
      const toHost = select.value || '';
4433
      if (!vhost || !toHost || toHost === fromHost) return;
4434
      select.disabled = true;
4435
      try {
4436
        await api('/api/vhosts/reassign', {
4437
          method: 'POST',
4438
          headers: { 'Content-Type': 'application/json' },
4439
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4440
        });
4441
        msg(`vhost ${vhost} moved`);
4442
        await refresh();
4443
      } finally {
4444
        select.disabled = false;
4445
      }
4446
    }
4447

            
Bogdan Timofte authored 4 days ago
4448
    async function addVhostInline() {
4449
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4450
      const nameInput = $('vhost-new-name');
4451
      const hostSelect = $('vhost-new-host');
4452
      const vhost = (nameInput.value || '').trim().toLowerCase();
4453
      const hostFqdn = hostSelect.value || '';
4454
      if (!vhost || !hostFqdn) return;
4455
      $('vhost-add').disabled = true;
4456
      nameInput.disabled = true;
4457
      hostSelect.disabled = true;
4458
      try {
4459
        await api('/api/vhosts/upsert', {
4460
          method: 'POST',
4461
          headers: { 'Content-Type': 'application/json' },
4462
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4463
        });
4464
        nameInput.value = '';
4465
        msg(`vhost ${vhost} saved`);
4466
        await refresh();
4467
      } finally {
4468
        $('vhost-add').disabled = false;
4469
        nameInput.disabled = false;
4470
        hostSelect.disabled = false;
4471
      }
4472
    }
4473

            
Bogdan Timofte authored 3 days ago
4474
    async function setVhostCertificateFromSelect(select) {
4475
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4476
        select.value = select.dataset.currentCertificate || '';
4477
        return;
4478
      }
4479
      const vhost = select.dataset.vhostCertSelect || '';
4480
      const certificateId = select.value || '';
4481
      const current = select.dataset.currentCertificate || '';
4482
      if (!vhost || certificateId === current) return;
4483
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4484
        select.value = current;
4485
        return;
4486
      }
4487
      select.disabled = true;
4488
      try {
4489
        await api('/api/vhosts/certificate', {
4490
          method: 'POST',
4491
          headers: { 'Content-Type': 'application/json' },
4492
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4493
        });
4494
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4495
        await refresh();
4496
      } finally {
4497
        select.disabled = false;
4498
      }
4499
    }
4500

            
Bogdan Timofte authored 3 days ago
4501
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 3 days ago
4502
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4503
      if (!vhost) return;
4504
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4505
      if (button) button.disabled = true;
4506
      try {
4507
        const result = await api('/api/vhosts/issue-certificate', {
4508
          method: 'POST',
4509
          headers: { 'Content-Type': 'application/json' },
4510
          body: JSON.stringify({ vhost_fqdn: vhost }),
4511
        });
4512
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4513
        await refresh();
4514
      } finally {
4515
        if (button) button.disabled = false;
4516
      }
4517
    }
4518

            
Bogdan Timofte authored 4 days ago
4519
    async function deleteVhostInline(vhost) {
4520
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4521
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4522
      await api('/api/vhosts/delete', {
4523
        method: 'POST',
4524
        headers: { 'Content-Type': 'application/json' },
4525
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4526
      });
4527
      msg(`vhost ${vhost} deleted`);
4528
      await refresh();
4529
    }
4530

            
Bogdan Timofte authored 4 days ago
4531
    async function editHost(id) {
4532
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4533
      const host = state.hosts.find(h => h.id === id);
4534
      if (!host) return;
Bogdan Timofte authored 3 days ago
4535
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4536
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4537
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4538
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4539
      hostField('roles').value = (host.roles || []).join(' ');
4540
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 3 days ago
4541
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4542
    }
4543

            
Bogdan Timofte authored 4 days ago
4544
    async function newHost() {
4545
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 3 days ago
4546
      if (!canSwitchHostEditor('__new__')) return;
4547
      resetHostForm(true);
4548
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4549
    }
4550

            
Bogdan Timofte authored 3 days ago
4551
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 3 days ago
4552
      hostFormMode = mode || 'new';
Bogdan Timofte authored 3 days ago
4553
      hostEditorTarget = target || '';
4554
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 3 days ago
4555
      syncHostFormActions();
Bogdan Timofte authored 3 days ago
4556
      renderHosts();
4557
      hostFormSnapshot = hostFormState();
4558
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 3 days ago
4559
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4560
    }
4561

            
Bogdan Timofte authored 3 days ago
4562
    function resetHostForm(force = false) {
Bogdan Timofte authored 3 days ago
4563
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 3 days ago
4564
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4565
      clearHostFormMessage();
Bogdan Timofte authored 3 days ago
4566
      hostField('status').value = 'active';
4567
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 3 days ago
4568
      hostFormSnapshot = force ? '' : hostFormState();
4569
    }
4570

            
4571
    function closeHostForm(force = false) {
4572
      if (hostFormBusy && !force) return;
4573
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4574
      hostEditorTarget = '';
4575
      hostFormMode = 'new';
4576
      hostFormSnapshot = '';
4577
      clearHostFormMessage();
4578
      syncHostFormActions();
4579
      mountHostEditor();
4580
    }
4581

            
4582
    function canSwitchHostEditor(target) {
4583
      if (hostFormBusy) return false;
4584
      if (!hostEditorTarget) return true;
4585
      if (!hostFormDirty()) return true;
4586
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4587
      return confirm('Discard unsaved host changes?');
4588
    }
4589

            
4590
    function mountHostEditor() {
4591
      hostEditorRow.remove();
4592
      if (!hostEditorTarget) {
4593
        hostFormShell.hidden = true;
4594
        return;
4595
      }
4596
      hostEditorCell.colSpan = 7;
4597
      const tbody = $('hosts');
4598
      if (!tbody) return;
4599
      if (hostEditorTarget === '__new__') {
4600
        tbody.prepend(hostEditorRow);
4601
      } else {
4602
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4603
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4604
        if (targetRow) targetRow.after(hostEditorRow);
4605
        else tbody.prepend(hostEditorRow);
4606
      }
4607
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4608
    }
4609

            
4610
    function hostField(name) {
Bogdan Timofte authored 3 days ago
4611
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4612
    }
4613

            
4614
    function hostFormState() {
Bogdan Timofte authored 3 days ago
4615
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4616
    }
4617

            
4618
    function hostFormDirty() {
Bogdan Timofte authored 3 days ago
4619
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4620
    }
4621

            
4622
    function setHostFormBusy(busy) {
Bogdan Timofte authored 3 days ago
4623
      hostFormBusy = !!busy;
4624
      syncHostFormActions();
4625
    }
4626

            
4627
    function syncHostFormActions() {
Bogdan Timofte authored 3 days ago
4628
      saveHostButton.disabled = hostFormBusy;
4629
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4630
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4631
    }
4632

            
4633
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 3 days ago
4634
      hostFormMessage.textContent = text || '';
4635
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4636
    }
4637

            
4638
    function clearHostFormMessage() {
4639
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4640
    }
4641

            
4642
    function formObject(form) {
4643
      return Object.fromEntries(new FormData(form).entries());
4644
    }
4645

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

            
Bogdan Timofte authored 6 days ago
4651
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4652

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

            
4658
    if (loginAccount) {
4659
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4660
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4661
      loginAccount.addEventListener('input', () => {
4662
        const value = (loginAccount.value || '').trim();
4663
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4664
      });
4665
    }
4666

            
Xdev Host Manager authored a week ago
4667
    function setOtpDigit(idx, value) {
4668
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4669
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4670
      otpDigits[idx].classList.toggle('filled', !!digit);
4671
    }
4672

            
Bogdan Timofte authored 4 days ago
4673
    // Move focus to the next empty box: forward from idx, then wrapping to the
4674
    // start. This lets out-of-order entry continue (e.g. after the last box,
4675
    // jump back to the first still-empty box). Stays put when all boxes are full.
4676
    function advanceFocus(idx) {
4677
      for (let i = idx + 1; i < otpDigits.length; i++) {
4678
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4679
      }
4680
      for (let i = 0; i <= idx; i++) {
4681
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4682
      }
4683
    }
4684

            
Bogdan Timofte authored 4 days ago
4685
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4686
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4687
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4688
      const digits = (text || '').replace(/\D/g, '').split('');
4689
      if (!digits.length) return;
4690
      let last = startIdx;
4691
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4692
        last = startIdx + i;
4693
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4694
      }
Bogdan Timofte authored 4 days ago
4695
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4696
      advanceFocus(last);
Xdev Host Manager authored a week ago
4697
      maybeSubmitOtp();
4698
    }
4699

            
Bogdan Timofte authored 4 days ago
4700
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4701
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4702
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4703
    function maybeSubmitOtp() {
4704
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4705
    }
4706
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4707
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4708
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4709
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4710
      // an unknown operator, so Safari's autofill anchor on the username stays.
4711
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4712
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4713
    }
4714

            
Bogdan Timofte authored 4 days ago
4715
    otpDigits.forEach((input, idx) => {
4716
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4717
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4718
        // A single box may receive several digits at once (autofill / typing fast).
4719
        if (input.value.replace(/\D/g, '').length > 1) {
4720
          fillOtp(input.value, idx);
4721
          return;
4722
        }
4723
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4724
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4725
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4726
        maybeSubmitOtp();
4727
      });
Bogdan Timofte authored 4 days ago
4728

            
4729
      input.addEventListener('paste', (e) => {
4730
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4731
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4732
        const text = (e.clipboardData || window.clipboardData).getData('text');
4733
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4734
      });
Bogdan Timofte authored 4 days ago
4735

            
4736
      input.addEventListener('keydown', (e) => {
4737
        if (e.key === 'Backspace') {
4738
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4739
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4740
          if (input.value) { setOtpDigit(idx, ''); }
4741
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4742
          syncOtpFields();
4743
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4744
          e.preventDefault();
4745
          otpDigits[idx - 1].focus();
4746
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4747
          e.preventDefault();
4748
          otpDigits[idx + 1].focus();
4749
        }
4750
      });
4751
    });
4752

            
Bogdan Timofte authored 4 days ago
4753
    // Focus the first OTP box only for a returning operator (username known).
4754
    // For an unknown operator, leave focus on the username field so Safari can
4755
    // present its OTP autofill anchored there without being dismissed by a focus
4756
    // change (pbx-admin pattern).
4757
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4758
    else if (loginAccount) loginAccount.focus();
4759
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4760

            
Bogdan Timofte authored 5 days ago
4761
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4762
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4763
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4764
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4765
        showPage(link.dataset.pageLink, true);
4766
      });
4767
    });
4768

            
Bogdan Timofte authored 4 days ago
4769
    window.addEventListener('popstate', () => {
4770
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4771
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4772
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4773
    });
Bogdan Timofte authored 5 days ago
4774

            
Bogdan Timofte authored 4 days ago
4775
    async function copyText(text) {
4776
      if (navigator.clipboard && window.isSecureContext) {
4777
        await navigator.clipboard.writeText(text);
4778
        return;
4779
      }
4780
      const input = document.createElement('textarea');
4781
      input.value = text;
4782
      input.setAttribute('readonly', '');
4783
      input.style.position = 'fixed';
4784
      input.style.left = '-10000px';
4785
      document.body.appendChild(input);
4786
      input.select();
4787
      document.execCommand('copy');
4788
      document.body.removeChild(input);
4789
    }
4790

            
4791
    $('copy-build').addEventListener('click', async () => {
4792
      try {
4793
        await copyText($('copy-build').dataset.buildDetails || '');
4794
        if (state.authenticated) msg('build details copied');
4795
      } catch (e) {
4796
        if (state.authenticated) msg('copy failed');
4797
      }
4798
    });
4799

            
Xdev Host Manager authored a week ago
4800
    $('login-form').addEventListener('submit', async (event) => {
4801
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4802
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4803
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4804
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4805
      try {
Xdev Host Manager authored a week ago
4806
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4807
        await refresh();
Xdev Host Manager authored a week ago
4808
      } catch (e) {
4809
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4810
      } finally {
Xdev Host Manager authored a week ago
4811
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4812
      }
Xdev Host Manager authored a week ago
4813
    });
4814

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

            
Bogdan Timofte authored 4 days ago
4820
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4821
      if (!isAuthLost(e)) msg(e.message);
4822
    }));
Xdev Host Manager authored a week ago
4823
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4824
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4825
    $('vhost-add').addEventListener('click', () => {
4826
      addVhostInline().catch(e => {
4827
        if (!isAuthLost(e)) msg(e.message);
4828
      });
4829
    });
4830
    $('vhost-new-name').addEventListener('keydown', (event) => {
4831
      if (event.key !== 'Enter') return;
4832
      event.preventDefault();
4833
      addVhostInline().catch(e => {
4834
        if (!isAuthLost(e)) msg(e.message);
4835
      });
4836
    });
Bogdan Timofte authored 4 days ago
4837
    $('new-host').addEventListener('click', () => {
4838
      newHost().catch(e => {
4839
        if (!isAuthLost(e)) msg(e.message);
4840
      });
4841
    });
Bogdan Timofte authored 4 days ago
4842
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4843
      if (!isAuthLost(e)) msg(e.message);
4844
    }));
Bogdan Timofte authored 3 days ago
4845
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4846

            
Bogdan Timofte authored 3 days ago
4847
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4848
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4849
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4850
      setHostFormBusy(true);
4851
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4852
      try {
Bogdan Timofte authored 3 days ago
4853
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4854
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4855
        msg('host saved');
4856
        await refresh();
Bogdan Timofte authored 3 days ago
4857
        const host = state.hosts.find(entry => entry.id === savedId);
4858
        if (host) {
4859
          clearHostFormMessage();
4860
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4861
          hostField('aliases').value = (host.aliases || []).join('\n');
4862
          hostField('roles').value = (host.roles || []).join(' ');
4863
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 3 days ago
4864
          activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 3 days ago
4865
        } else {
Bogdan Timofte authored 3 days ago
4866
          closeHostForm(true);
Bogdan Timofte authored 3 days ago
4867
        }
Bogdan Timofte authored 5 days ago
4868
      } catch (e) {
Bogdan Timofte authored 4 days ago
4869
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4870
        setHostFormMessage(e.message, true);
4871
        msg(e.message);
4872
      } finally {
4873
        setHostFormBusy(false);
4874
      }
4875
    });
4876

            
Bogdan Timofte authored 3 days ago
4877
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4878
      setHostFormMessage('Complete the required host fields before saving.', true);
4879
    }, true);
4880

            
Bogdan Timofte authored 3 days ago
4881
    hostForm.addEventListener('input', () => {
4882
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4883
    });
4884

            
Bogdan Timofte authored 3 days ago
4885
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4886
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4887
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4888
      setHostFormBusy(true);
4889
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4890
      try {
4891
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4892
        msg('host deleted');
4893
        await refresh();
Bogdan Timofte authored 3 days ago
4894
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4895
      } catch (e) {
Bogdan Timofte authored 4 days ago
4896
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4897
        setHostFormMessage(e.message, true);
4898
        msg(e.message);
4899
      } finally {
4900
        setHostFormBusy(false);
4901
      }
Xdev Host Manager authored a week ago
4902
    });
4903

            
Bogdan Timofte authored 3 days ago
4904
    resetHostForm(true);
4905
    closeHostForm(true);
Bogdan Timofte authored 3 days ago
4906

            
Xdev Host Manager authored a week ago
4907
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4908
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4909
      try {
4910
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4911
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4912
      } catch (e) {
4913
        if (!isAuthLost(e)) msg(e.message);
4914
      }
Xdev Host Manager authored a week ago
4915
    });
4916

            
Bogdan Timofte authored 4 days ago
4917
    refresh().catch(e => {
4918
      if (!isAuthLost(e)) showLogin(e.message);
4919
    });
Xdev Host Manager authored a week ago
4920
  </script>
4921
</body>
4922
</html>
4923
HTML
Bogdan Timofte authored 6 days ago
4924
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4925
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4926
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4927
    return $html;
Xdev Host Manager authored a week ago
4928
}