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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
254
    return send_json($client, 404, { error => 'not_found' });
255
}
256

            
Bogdan Timofte authored 5 days ago
257
sub app_page_path {
258
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
259
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
260
}
261

            
Xdev Host Manager authored a week ago
262
sub load_registry {
Bogdan Timofte authored 4 days ago
263
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
264
    normalize_registry_policy($registry);
265
    return $registry;
Xdev Host Manager authored a week ago
266
}
267

            
268
sub save_registry {
269
    my ($registry) = @_;
270
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
271
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
272
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
273
}
274

            
Xdev Host Manager authored a week ago
275
sub load_work_orders {
Bogdan Timofte authored 4 days ago
276
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
277
}
278

            
279
sub save_work_orders {
280
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
281
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
282
}
283

            
284
sub work_orders_payload {
285
    my ($orders) = @_;
286
    my $pending = 0;
287
    for my $wo (@{ $orders->{work_orders} || [] }) {
288
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
289
    }
290
    return {
291
        version => $orders->{version},
292
        work_orders => $orders->{work_orders} || [],
293
        counts => {
294
            work_orders => scalar @{ $orders->{work_orders} || [] },
295
            pending => $pending,
296
        },
297
    };
298
}
299

            
300
sub confirm_work_order {
301
    my ($client, $payload) = @_;
302
    my $id = clean_scalar($payload->{id} || '');
303
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
304
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
305

            
306
    my $orders = load_work_orders();
307
    my $work_order;
308
    for my $wo (@{ $orders->{work_orders} || [] }) {
309
        if (($wo->{id} || '') eq $id) {
310
            $work_order = $wo;
311
            last;
312
        }
313
    }
314
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
315
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
316
    my $incomplete = incomplete_work_order_items($work_order);
317
    return send_json($client, 409, {
318
        error => 'work_order_incomplete',
319
        incomplete => $incomplete,
320
    }) if @$incomplete;
Xdev Host Manager authored a week ago
321

            
322
    my $registry = load_registry();
323
    my $results = apply_work_order($registry, $work_order);
324
    $work_order->{status} = 'confirmed';
325
    $work_order->{confirmed_at} = iso_now();
326
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
327

            
328
    save_registry($registry);
329
    save_work_orders($orders);
330
    backup_file($opt{local_hosts_tsv});
331
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
332

            
333
    return send_json($client, 200, {
334
        ok => json_bool(1),
335
        work_order => $work_order,
336
        results => $results,
337
        local_hosts_tsv => $opt{local_hosts_tsv},
338
    });
339
}
340

            
Xdev Host Manager authored a week ago
341
sub update_work_order_checklist {
342
    my ($client, $payload) = @_;
343
    my $id = clean_scalar($payload->{id} || '');
344
    my $item_id = clean_scalar($payload->{item_id} || '');
345
    my $status = clean_scalar($payload->{status} || '');
346
    my $notes = clean_scalar($payload->{notes} || '');
347
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
348
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
349
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
350

            
351
    my $orders = load_work_orders();
352
    my $work_order;
353
    for my $wo (@{ $orders->{work_orders} || [] }) {
354
        if (($wo->{id} || '') eq $id) {
355
            $work_order = $wo;
356
            last;
357
        }
358
    }
359
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
360
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
361

            
362
    my $item;
363
    for my $candidate (@{ $work_order->{checklist} || [] }) {
364
        if (($candidate->{id} || '') eq $item_id) {
365
            $item = $candidate;
366
            last;
367
        }
368
    }
369
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
370

            
371
    $item->{status} = $status;
372
    $item->{updated_at} = iso_now();
373
    $item->{notes} = $notes if length $notes;
374
    save_work_orders($orders);
375
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
376
}
377

            
378
sub incomplete_work_order_items {
379
    my ($work_order) = @_;
380
    my @incomplete;
381
    for my $item (@{ $work_order->{checklist} || [] }) {
382
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
383
    }
384
    return \@incomplete;
385
}
386

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

            
Xdev Host Manager authored a week ago
418
sub registry_payload {
419
    my ($registry) = @_;
420
    my $problems = analyze_hosts($registry->{hosts});
Bogdan Timofte authored 3 days ago
421
    my $dbh = dbh();
Bogdan Timofte authored 3 days ago
422
    my %host_tls = host_tls_payloads($dbh);
423
    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }) } @{ $registry->{hosts} };
Bogdan Timofte authored 3 days ago
424
    my @vhosts = vhost_payloads($dbh);
425
    my @certificates = certificate_payloads($dbh);
Bogdan Timofte authored 4 days ago
426
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
427
    return {
428
        version => $registry->{version},
429
        updated_at => $registry->{updated_at},
430
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
431
        hosts => \@hosts,
Bogdan Timofte authored 3 days ago
432
        vhosts => \@vhosts,
433
        certificates => \@certificates,
Xdev Host Manager authored a week ago
434
        problems => $problems,
435
        counts => {
436
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 3 days ago
437
            vhosts => scalar(@vhosts) || $vhost_count,
Xdev Host Manager authored a week ago
438
            problems => scalar @$problems,
439
        },
440
    };
441
}
442

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

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

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

            
559
sub certificate_dns_names {
560
    my ($dbh, $certificate_id) = @_;
561
    my @names;
562
    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
563
    $sth->execute($certificate_id);
564
    while (my ($name) = $sth->fetchrow_array) {
565
        push @names, $name;
566
    }
567
    return @names;
568
}
569

            
Xdev Host Manager authored a week ago
570
sub upsert_host {
571
    my ($client, $payload) = @_;
572
    my $id = clean_id($payload->{id} || '');
573
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
574

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

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

            
582
    my $registry = load_registry();
Bogdan Timofte authored 4 days ago
583
    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
584
    my @vhosts = defined $payload->{vhosts}
585
        ? clean_vhost_names($payload)
586
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
587
    my %host = (
588
        id => $id,
Bogdan Timofte authored 4 days ago
589
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
590
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
591
        ip => $ip,
592
        aliases => \@aliases,
593
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
594
        roles => [ clean_list($payload->{roles}) ],
595
        sources => [ clean_list($payload->{sources}) ],
596
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
597
        notes => clean_scalar($payload->{notes} || ''),
598
    );
599

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

            
622
sub delete_host {
623
    my ($client, $id) = @_;
624
    $id = clean_id($id);
625
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
626

            
627
    my $registry = load_registry();
628
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
629
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
630
    $registry->{hosts} = \@kept;
631
    save_registry($registry);
632
    return send_json($client, 200, { ok => json_bool(1) });
633
}
634

            
Bogdan Timofte authored 4 days ago
635
sub reassign_vhost {
636
    my ($client, $payload) = @_;
637
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
638
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
639
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
640
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
641

            
642
    my $dbh = dbh();
643
    my ($current_fqdn) = $dbh->selectrow_array(
644
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
645
        undef,
646
        $vhost,
647
    );
648
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
649
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
650
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
651

            
652
    my $result = eval {
653
        with_transaction($dbh, sub {
654
            my $now = iso_now();
655
            $dbh->do(
656
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
657
                undef,
658
                $target_fqdn, $now, $vhost,
659
            );
660

            
661
            my $registry = load_registry_from_db();
662
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
663
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
664

            
665
            upsert_host_to_db($dbh, $target_host) if $target_host;
666
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
667
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
668
        });
669
        1;
670
    };
671
    if (!$result) {
672
        my $err = $@ || 'vhost_reassign_failed';
673
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
674
    }
675
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
676
}
677

            
Bogdan Timofte authored 4 days ago
678
sub upsert_vhost {
679
    my ($client, $payload) = @_;
680
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
681
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
682
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
683
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
684

            
685
    my $dbh = dbh();
686
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
687
    my ($current_fqdn) = $dbh->selectrow_array(
688
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
689
        undef,
690
        $vhost,
691
    );
692

            
693
    my $result = eval {
694
        with_transaction($dbh, sub {
695
            my $now = iso_now();
696
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
697

            
698
            my $registry = load_registry_from_db();
699
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
700
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
701

            
702
            upsert_host_to_db($dbh, $target_host) if $target_host;
703
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
704
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
705
        });
706
        1;
707
    };
708
    if (!$result) {
709
        my $err = $@ || 'vhost_upsert_failed';
710
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
711
    }
712
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
713
}
714

            
715
sub delete_vhost {
716
    my ($client, $payload) = @_;
717
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
718
    my $confirm = normalize_dns_name($payload->{confirm} || '');
719
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
720
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
721

            
722
    my $dbh = dbh();
723
    my ($current_fqdn) = $dbh->selectrow_array(
724
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
725
        undef,
726
        $vhost,
727
    );
728
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
729

            
730
    my $result = eval {
731
        with_transaction($dbh, sub {
732
            my $now = iso_now();
733
            $dbh->do(
734
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
735
                undef,
736
                $now, $vhost,
737
            );
738

            
739
            my $registry = load_registry_from_db();
740
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
741
            upsert_host_to_db($dbh, $current_host) if $current_host;
742
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
743
        });
744
        1;
745
    };
746
    if (!$result) {
747
        my $err = $@ || 'vhost_delete_failed';
748
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
749
    }
750
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
751
}
752

            
Bogdan Timofte authored 3 days ago
753
sub set_host_certificate {
754
    my ($client, $payload) = @_;
755
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
756
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
757
    my $certificate_id = clean_certificate_id($raw_certificate_id);
758
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
759
    return send_json($client, 400, { error => 'invalid_certificate' })
760
        if length($raw_certificate_id) && !length($certificate_id);
761

            
762
    my $dbh = dbh();
763
    return send_json($client, 404, { error => 'host_not_found' })
764
        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
765
    if (length $certificate_id) {
766
        return send_json($client, 400, { error => 'invalid_certificate' })
767
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
768
    }
769

            
770
    my $now = iso_now();
771
    with_transaction($dbh, sub {
772
        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
773
        set_schema_meta($dbh, 'registry_updated_at', $now);
774
    });
775
    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
776
}
777

            
778
sub issue_host_certificate {
779
    my ($client, $payload) = @_;
780
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
781
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
782

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

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

            
808
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
809
    return send_json($client, 200, {
810
        ok => json_bool(1),
811
        host_fqdn => $host_fqdn,
812
        certificate_id => $certificate_id,
813
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
814
    });
815
}
816

            
Bogdan Timofte authored 3 days ago
817
sub set_vhost_certificate {
818
    my ($client, $payload) = @_;
819
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
820
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
821
    my $certificate_id = clean_certificate_id($raw_certificate_id);
822
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
823
    return send_json($client, 400, { error => 'invalid_certificate' })
824
        if length($raw_certificate_id) && !length($certificate_id);
825

            
826
    my $dbh = dbh();
827
    return send_json($client, 404, { error => 'vhost_not_found' })
828
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
829
    if (length $certificate_id) {
830
        return send_json($client, 400, { error => 'invalid_certificate' })
831
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
832
    }
833

            
834
    my $now = iso_now();
835
    $dbh->do(
836
        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
837
        undef,
838
        length($certificate_id) ? $certificate_id : undef,
839
        length($certificate_id) ? 'local-ca' : 'none',
840
        $now,
841
        $vhost,
842
        'active',
843
    );
844
    set_schema_meta($dbh, 'registry_updated_at', $now);
845
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
846
}
847

            
848
sub issue_vhost_certificate {
849
    my ($client, $payload) = @_;
850
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
851
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
852

            
853
    my $dbh = dbh();
854
    my ($host_fqdn) = $dbh->selectrow_array(
855
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
856
        undef,
857
        $vhost,
858
    );
859
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
860

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

            
883
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
884
    return send_json($client, 200, {
885
        ok => json_bool(1),
886
        vhost_fqdn => $vhost,
887
        host_fqdn => $host_fqdn,
888
        certificate_id => $certificate_id,
889
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
890
    });
891
}
892

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

            
Xdev Host Manager authored a week ago
920
sub host_payload {
Bogdan Timofte authored 3 days ago
921
    my ($host, $tls) = @_;
Xdev Host Manager authored a week ago
922
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
923
    $copy{fqdn} = canonical_host_fqdn($host);
924
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
925
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
926
    $copy{declared_names} = [ declared_dns_names($host) ];
927
    $copy{aliases} = [ declared_alias_names($host) ];
928
    $copy{derived_aliases} = [ derived_alias_names($host) ];
929
    $copy{vhosts} = [ declared_vhost_names($host) ];
930
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Bogdan Timofte authored 3 days ago
931
    $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
932
    $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
Xdev Host Manager authored a week ago
933
    return \%copy;
934
}
935

            
936
sub effective_names {
937
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
938
    my @names = declared_dns_names($host);
939
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
940
    return unique_preserve(@names);
941
}
942

            
Bogdan Timofte authored 3 days ago
943
sub host_dns_names {
944
    my ($host) = @_;
945
    my @names;
946
    my $fqdn = canonical_host_fqdn($host);
947
    push @names, $fqdn if length $fqdn;
948
    push @names, declared_alias_names($host), derived_alias_names($host);
949
    return unique_preserve(@names);
950
}
951

            
952
sub vhost_cname_records {
953
    my ($host) = @_;
954
    my $target = canonical_host_fqdn($host);
955
    return () unless length $target;
956
    my @records;
957
    for my $vhost (declared_vhost_names($host)) {
958
        push @records, [ $vhost, $target ];
959
        if (my $short = short_alias_for_fqdn($vhost)) {
960
            push @records, [ $short, $target ];
961
        }
962
    }
963
    my %seen;
964
    return grep { !$seen{$_->[0]}++ } @records;
965
}
966

            
Bogdan Timofte authored 4 days ago
967
sub declared_dns_names {
968
    my ($host) = @_;
969
    my @names;
970
    my $fqdn = canonical_host_fqdn($host);
971
    push @names, $fqdn if length $fqdn;
972
    push @names, declared_alias_names($host);
973
    push @names, declared_vhost_names($host);
974
    return unique_preserve(@names);
975
}
976

            
977
sub declared_alias_names {
978
    my ($host) = @_;
979
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
980
}
981

            
982
sub declared_vhost_names {
983
    my ($host) = @_;
984
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
985
}
986

            
987
sub declared_dns_names_legacy {
988
    my ($host) = @_;
989
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
990
}
991

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

            
1022
sub derived_alias_names {
Xdev Host Manager authored a week ago
1023
    my ($host) = @_;
1024
    my @derived;
Bogdan Timofte authored 4 days ago
1025
    my $fqdn = canonical_host_fqdn($host);
1026
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1027
    for my $name (declared_alias_names($host)) {
1028
        push @derived, short_alias_for_fqdn($name);
1029
    }
1030
    return unique_preserve(grep { length $_ } @derived);
1031
}
1032

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

            
1042
sub clean_alias_names {
1043
    my ($payload) = @_;
1044
    return clean_name_bucket($payload->{aliases})
1045
        if defined $payload->{aliases};
1046
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1047
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1048
}
1049

            
1050
sub clean_vhost_names {
1051
    my ($payload) = @_;
1052
    return clean_name_bucket($payload->{vhosts})
1053
        if defined $payload->{vhosts};
1054
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1055
    return grep { name_is_vhost($_) } @legacy;
1056
}
1057

            
1058
sub clean_name_bucket {
1059
    my ($value) = @_;
1060
    my @names = clean_list($value);
1061
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1062
}
1063

            
1064
sub remove_derived_names {
1065
    my @names = @_;
1066
    my %derived;
1067
    for my $name (@names) {
1068
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1069
        $derived{$1} = 1;
1070
    }
1071
    return grep { !$derived{$_} } @names;
1072
}
1073

            
1074
sub unique_preserve {
1075
    my @values = @_;
1076
    my %seen;
1077
    return grep { !$seen{$_}++ } @values;
1078
}
1079

            
Bogdan Timofte authored 4 days ago
1080
sub canonical_ip {
1081
    my ($host) = @_;
1082
    return '' unless $host && ref($host) eq 'HASH';
1083
    for my $key (qw(ip dns_ip hosts_ip)) {
1084
        my $value = clean_scalar($host->{$key} || '');
1085
        return $value if length $value;
1086
    }
1087
    return '';
1088
}
1089

            
Xdev Host Manager authored a week ago
1090
sub problem {
1091
    my ($host, $code, $message) = @_;
1092
    return { host_id => $host->{id}, code => $code, message => $message };
1093
}
1094

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

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

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

            
1177
sub debug_database_table_payload {
1178
    my ($table, $limit) = @_;
1179
    my $dbh = dbh();
1180
    $table = clean_scalar($table);
1181
    return { error => 'missing_table' } unless length $table;
1182
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1183
    $limit = int($limit || 100);
1184
    $limit = 1 if $limit < 1;
1185
    $limit = 500 if $limit > 500;
1186

            
1187
    my $quoted = $dbh->quote_identifier($table);
1188
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1189
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1190
    my @index_details;
1191
    for my $index (@$indexes) {
1192
        my $index_name = $index->{name} || '';
1193
        next unless length $index_name;
1194
        my $quoted_index = $dbh->quote_identifier($index_name);
1195
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1196
        push @index_details, {
1197
            name => $index_name,
1198
            unique => int($index->{unique} || 0),
1199
            origin => $index->{origin} || '',
1200
            partial => int($index->{partial} || 0),
1201
            columns => [ map { $_->{name} || '' } @$index_columns ],
1202
        };
1203
    }
1204
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1205
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1206
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1207

            
1208
    return {
1209
        database => $opt{db},
1210
        table => $table,
1211
        generated_at => iso_now(),
1212
        limit => $limit,
1213
        row_count => int($row_count || 0),
1214
        columns => $columns,
1215
        indexes => \@index_details,
1216
        foreign_keys => $foreign_keys,
1217
        rows => $rows,
1218
    };
1219
}
1220

            
Bogdan Timofte authored 4 days ago
1221
sub debug_database_table_export_payload {
1222
    my ($table) = @_;
1223
    my $dbh = dbh();
1224
    $table = clean_scalar($table);
1225
    return { error => 'missing_table' } unless length $table;
1226
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1227

            
1228
    my $quoted = $dbh->quote_identifier($table);
1229
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1230
    my @column_names = map { $_->{name} || '' } @$columns;
1231
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1232
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1233

            
1234
    return {
1235
        database => $opt{db},
1236
        table => $table,
1237
        generated_at => iso_now(),
1238
        row_count => int($row_count || 0),
1239
        columns => \@column_names,
1240
        rows => $rows,
1241
    };
1242
}
1243

            
1244
sub render_debug_table_csv {
1245
    my ($export) = @_;
1246
    my @columns = @{ $export->{columns} || [] };
1247
    my @lines = (join(',', map { csv_cell($_) } @columns));
1248
    for my $row (@{ $export->{rows} || [] }) {
1249
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1250
    }
1251
    return join("\n", @lines) . "\n";
1252
}
1253

            
1254
sub csv_cell {
1255
    my ($value) = @_;
1256
    $value = '' unless defined $value;
1257
    $value = "$value";
1258
    $value =~ s/"/""/g;
1259
    return qq("$value") if $value =~ /[",\r\n]/;
1260
    return $value;
1261
}
1262

            
1263
sub debug_table_export_filename {
1264
    my ($table, $extension) = @_;
1265
    $table = clean_scalar($table || 'table');
1266
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1267
    $table = 'table' unless length $table;
1268
    return "debug-$table.$extension";
1269
}
1270

            
Bogdan Timofte authored 4 days ago
1271
sub debug_table_exists {
1272
    my ($dbh, $table) = @_;
1273
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1274
    my ($exists) = $dbh->selectrow_array(
1275
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1276
        undef,
1277
        $table,
1278
    );
1279
    return $exists ? 1 : 0;
1280
}
1281

            
1282
sub sum {
1283
    my $total = 0;
1284
    $total += $_ || 0 for @_;
1285
    return $total;
1286
}
1287

            
Xdev Host Manager authored a week ago
1288
sub ca_script_path {
1289
    return "$project_dir/scripts/ca_manager.sh";
1290
}
1291

            
1292
sub ca_dir {
1293
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1294
}
1295

            
1296
sub ca_cert_path {
1297
    return ca_dir() . "/certs/ca.cert.pem";
1298
}
1299

            
Bogdan Timofte authored 5 days ago
1300
sub ca_issued_cert_path {
1301
    my ($name) = @_;
1302
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1303
    return ca_dir() . "/issued/$name.cert.pem";
1304
}
1305

            
Bogdan Timofte authored 3 days ago
1306
sub ca_issued_key_path {
1307
    my ($name) = @_;
1308
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1309
    return ca_dir() . "/issued/$name.key.pem";
1310
}
1311

            
Bogdan Timofte authored 3 days ago
1312
sub ca_private_key_exists {
1313
    my ($name) = @_;
1314
    return 0 unless clean_certificate_id($name || '');
1315
    return -f ca_issued_key_path($name) ? 1 : 0;
1316
}
1317

            
Bogdan Timofte authored 3 days ago
1318
sub ca_manager_output {
1319
    my (@args) = @_;
Xdev Host Manager authored a week ago
1320
    my $script = ca_script_path();
1321
    die "CA manager script is missing\n" unless -x $script;
1322
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 3 days ago
1323
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1324
    local $/;
1325
    my $out = <$fh>;
1326
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 3 days ago
1327
    return $out || '';
1328
}
1329

            
1330
sub ca_manager_json {
1331
    my ($command) = @_;
1332
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1333
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1334
    sync_certificates_from_json($out) if $command eq 'list-json';
1335
    return $out;
1336
}
1337

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

            
1391
sub infer_certificate_host_fqdn {
1392
    my ($dbh, $dns_names) = @_;
1393
    for my $name (@$dns_names) {
1394
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1395
        return $fqdn if $fqdn;
1396
    }
1397
    for my $name (@$dns_names) {
1398
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1399
        return $fqdn if $fqdn;
1400
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1401
        return $fqdn if $fqdn;
1402
    }
1403
    return '';
Xdev Host Manager authored a week ago
1404
}
1405

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

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

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

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

            
Xdev Host Manager authored a week ago
1586
sub request_payload {
1587
    my ($headers, $body) = @_;
1588
    my $type = $headers->{'content-type'} || '';
1589
    if ($type =~ m{application/json}) {
1590
        return json_decode($body || '{}');
1591
    }
1592
    return { parse_params($body || '') };
1593
}
1594

            
1595
sub json_bool {
1596
    my ($value) = @_;
1597
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1598
}
1599

            
1600
sub json_encode {
1601
    my ($value) = @_;
1602
    if (!defined $value) {
1603
        return 'null';
1604
    }
1605
    my $ref = ref($value);
1606
    if (!$ref) {
1607
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1608
        return json_string($value);
1609
    }
1610
    if ($ref eq 'HostManager::JSONBool') {
1611
        return $$value ? 'true' : 'false';
1612
    }
1613
    if ($ref eq 'ARRAY') {
1614
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1615
    }
1616
    if ($ref eq 'HASH') {
1617
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1618
    }
1619
    return json_string("$value");
1620
}
1621

            
1622
sub json_string {
1623
    my ($value) = @_;
1624
    $value = '' unless defined $value;
1625
    $value =~ s/\\/\\\\/g;
1626
    $value =~ s/"/\\"/g;
1627
    $value =~ s/\n/\\n/g;
1628
    $value =~ s/\r/\\r/g;
1629
    $value =~ s/\t/\\t/g;
1630
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1631
    return qq("$value");
1632
}
1633

            
1634
sub json_decode {
1635
    my ($text) = @_;
1636
    my $i = 0;
1637
    my $len = length($text);
1638
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1639

            
1640
    $skip_ws = sub {
1641
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1642
    };
1643

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

            
1681
    $parse_number = sub {
1682
        my $start = $i;
1683
        $i++ if substr($text, $i, 1) eq '-';
1684
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1685
        if ($i < $len && substr($text, $i, 1) eq '.') {
1686
            $i++;
1687
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1688
        }
1689
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1690
            $i++;
1691
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1692
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1693
        }
1694
        return 0 + substr($text, $start, $i - $start);
1695
    };
1696

            
1697
    $parse_array = sub {
1698
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1699
        $i++;
1700
        my @out;
1701
        $skip_ws->();
1702
        if ($i < $len && substr($text, $i, 1) eq ']') {
1703
            $i++;
1704
            return \@out;
1705
        }
1706
        while (1) {
1707
            push @out, $parse_value->();
1708
            $skip_ws->();
1709
            my $ch = substr($text, $i++, 1);
1710
            last if $ch eq ']';
1711
            die "Expected JSON array comma\n" unless $ch eq ',';
1712
        }
1713
        return \@out;
1714
    };
1715

            
1716
    $parse_object = sub {
1717
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1718
        $i++;
1719
        my %out;
1720
        $skip_ws->();
1721
        if ($i < $len && substr($text, $i, 1) eq '}') {
1722
            $i++;
1723
            return \%out;
1724
        }
1725
        while (1) {
1726
            $skip_ws->();
1727
            my $key = $parse_string->();
1728
            $skip_ws->();
1729
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1730
            $out{$key} = $parse_value->();
1731
            $skip_ws->();
1732
            my $ch = substr($text, $i++, 1);
1733
            last if $ch eq '}';
1734
            die "Expected JSON object comma\n" unless $ch eq ',';
1735
        }
1736
        return \%out;
1737
    };
1738

            
1739
    $parse_value = sub {
1740
        $skip_ws->();
1741
        die "Unexpected end of JSON\n" if $i >= $len;
1742
        my $ch = substr($text, $i, 1);
1743
        return $parse_string->() if $ch eq '"';
1744
        return $parse_object->() if $ch eq '{';
1745
        return $parse_array->() if $ch eq '[';
1746
        if (substr($text, $i, 4) eq 'true') {
1747
            $i += 4;
1748
            return json_bool(1);
1749
        }
1750
        if (substr($text, $i, 5) eq 'false') {
1751
            $i += 5;
1752
            return json_bool(0);
1753
        }
1754
        if (substr($text, $i, 4) eq 'null') {
1755
            $i += 4;
1756
            return undef;
1757
        }
1758
        return $parse_number->() if $ch =~ /[-0-9]/;
1759
        die "Unexpected JSON token\n";
1760
    };
1761

            
1762
    my $value = $parse_value->();
1763
    $skip_ws->();
1764
    die "Trailing JSON content\n" if $i != $len;
1765
    return $value;
1766
}
1767

            
1768
sub parse_params {
1769
    my ($text) = @_;
1770
    my %out;
1771
    for my $pair (split /&/, $text) {
1772
        next unless length $pair;
1773
        my ($k, $v) = split /=/, $pair, 2;
1774
        $out{url_decode($k)} = url_decode($v || '');
1775
    }
1776
    return %out;
1777
}
1778

            
1779
sub clean_id {
1780
    my ($value) = @_;
1781
    $value = lc clean_scalar($value);
1782
    $value =~ s/[^a-z0-9_.-]+/-/g;
1783
    $value =~ s/^-+|-+$//g;
1784
    return $value;
1785
}
1786

            
Bogdan Timofte authored 3 days ago
1787
sub clean_certificate_id {
1788
    my ($value) = @_;
1789
    $value = clean_scalar($value);
1790
    return '' unless length $value;
1791
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1792
}
1793

            
Xdev Host Manager authored a week ago
1794
sub clean_scalar {
1795
    my ($value) = @_;
1796
    $value = '' unless defined $value;
1797
    $value =~ s/[\r\n\t]+/ /g;
1798
    $value =~ s/^\s+|\s+$//g;
1799
    return $value;
1800
}
1801

            
1802
sub clean_list {
1803
    my ($value) = @_;
1804
    return () unless defined $value;
1805
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1806
    my @clean;
1807
    for my $item (@items) {
1808
        $item = clean_scalar($item);
1809
        push @clean, $item if length $item;
1810
    }
1811
    return @clean;
1812
}
1813

            
1814
sub yq {
1815
    my ($value) = @_;
1816
    $value = '' unless defined $value;
1817
    $value =~ s/\\/\\\\/g;
1818
    $value =~ s/"/\\"/g;
1819
    return qq("$value");
1820
}
1821

            
1822
sub yaml_unquote {
1823
    my ($value) = @_;
1824
    $value = '' unless defined $value;
1825
    $value =~ s/^\s+|\s+$//g;
1826
    if ($value =~ /^"(.*)"$/) {
1827
        $value = $1;
1828
        $value =~ s/\\"/"/g;
1829
        $value =~ s/\\\\/\\/g;
1830
    }
1831
    return $value;
1832
}
1833

            
1834
sub verify_totp {
1835
    my ($secret, $otp) = @_;
1836
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1837
    my $key = eval { base32_decode($secret) };
1838
    return 0 if $@ || !length $key;
1839
    my $counter = int(time() / 30);
1840
    for my $offset (-1, 0, 1) {
1841
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1842
    }
1843
    return 0;
1844
}
1845

            
1846
sub totp_code {
1847
    my ($key, $counter) = @_;
1848
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1849
    my $hash = hmac_sha1($msg, $key);
1850
    my $offset = ord(substr($hash, -1)) & 0x0f;
1851
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1852
    return sprintf('%06d', $bin % 1_000_000);
1853
}
1854

            
1855
sub base32_decode {
1856
    my ($text) = @_;
1857
    $text = uc($text || '');
1858
    $text =~ s/[^A-Z2-7]//g;
1859
    my %map;
1860
    my @chars = ('A'..'Z', '2'..'7');
1861
    @map{@chars} = (0..31);
1862
    my ($bits, $value, $out) = (0, 0, '');
1863
    for my $char (split //, $text) {
1864
        die "Invalid base32\n" unless exists $map{$char};
1865
        $value = ($value << 5) | $map{$char};
1866
        $bits += 5;
1867
        while ($bits >= 8) {
1868
            $bits -= 8;
1869
            $out .= chr(($value >> $bits) & 0xff);
1870
        }
1871
    }
1872
    return $out;
1873
}
1874

            
1875
sub create_session {
1876
    my $nonce = random_hex(24);
1877
    my $expires = int(time() + 8 * 3600);
1878
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1879
    my $token = "$nonce:$expires:$sig";
1880
    $sessions{$token} = $expires;
1881
    return $token;
1882
}
1883

            
1884
sub is_authenticated {
1885
    my ($headers) = @_;
1886
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1887
    return 0 unless $token;
1888
    my ($nonce, $expires, $sig) = split /:/, $token;
1889
    return 0 unless $nonce && $expires && $sig;
1890
    return 0 if $expires < time();
1891
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1892
    return exists $sessions{$token};
1893
}
1894

            
1895
sub expire_session {
1896
    my ($headers) = @_;
1897
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1898
    delete $sessions{$token} if $token;
1899
}
1900

            
1901
sub cookie_value {
1902
    my ($cookie, $name) = @_;
1903
    for my $part (split /;\s*/, $cookie) {
1904
        my ($k, $v) = split /=/, $part, 2;
1905
        return $v if defined $k && $k eq $name;
1906
    }
1907
    return '';
1908
}
1909

            
1910
sub send_json {
1911
    my ($client, $status, $payload, $extra_headers) = @_;
1912
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1913
}
1914

            
Xdev Host Manager authored a week ago
1915
sub send_json_raw {
1916
    my ($client, $status, $json_body, $extra_headers) = @_;
1917
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1918
}
1919

            
Xdev Host Manager authored a week ago
1920
sub send_html {
1921
    my ($client, $status, $html) = @_;
1922
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1923
}
1924

            
1925
sub send_text {
1926
    my ($client, $status, $text) = @_;
1927
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1928
}
1929

            
1930
sub send_download {
1931
    my ($client, $status, $content, $type, $filename) = @_;
1932
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1933
}
1934

            
1935
sub send_file {
1936
    my ($client, $path, $type, $filename) = @_;
1937
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1938
    return send_download($client, 200, read_file($path), $type, $filename);
1939
}
1940

            
1941
sub send_response {
1942
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1943
    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
1944
    $body = '' unless defined $body;
1945
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1946
    print $client "Content-Type: $type\r\n";
1947
    print $client "Content-Length: " . length($body) . "\r\n";
1948
    print $client "Cache-Control: no-store\r\n";
1949
    print $client "$_\r\n" for @{ $extra_headers || [] };
1950
    print $client "Connection: close\r\n\r\n";
1951
    print $client $body;
1952
}
1953

            
1954
sub read_file {
1955
    my ($path) = @_;
1956
    open my $fh, '<', $path or die "Cannot read $path: $!";
1957
    local $/;
1958
    return <$fh>;
1959
}
1960

            
1961
sub write_file {
1962
    my ($path, $content) = @_;
1963
    open my $fh, '>', $path or die "Cannot write $path: $!";
1964
    print {$fh} $content;
1965
    close $fh or die "Cannot close $path: $!";
1966
}
1967

            
1968
sub backup_file {
1969
    my ($path) = @_;
1970
    return unless -f $path;
1971
    my $backup_dir = "$project_dir/backups/host-manager";
1972
    make_path($backup_dir) unless -d $backup_dir;
1973
    my $name = $path;
1974
    $name =~ s{.*/}{};
1975
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1976
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1977
}
1978

            
Bogdan Timofte authored 4 days ago
1979
my $db_handle;
Bogdan Timofte authored 4 days ago
1980
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1981

            
1982
sub dbh {
1983
    return $db_handle if $db_handle;
1984
    ensure_parent_dir($opt{db});
1985
    $db_handle = DBI->connect(
1986
        "dbi:SQLite:dbname=$opt{db}",
1987
        '',
1988
        '',
1989
        {
1990
            RaiseError => 1,
1991
            PrintError => 0,
1992
            AutoCommit => 1,
1993
            sqlite_unicode => 1,
1994
        },
1995
    ) or die "Cannot open SQLite database $opt{db}\n";
1996
    $db_handle->do('PRAGMA journal_mode = WAL');
1997
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1998
    create_database_schema($db_handle);
1999
    seed_database($db_handle) unless $db_seeded++;
2000
    return $db_handle;
2001
}
2002

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

            
Bogdan Timofte authored 4 days ago
2278
sub seed_database {
2279
    my ($dbh) = @_;
2280
    seed_default_workers($dbh);
2281

            
2282
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2283
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2284
        normalize_registry_policy($registry);
2285
        with_transaction($dbh, sub {
2286
            import_registry_to_db($dbh, $registry, 0);
2287
        });
2288
    }
2289

            
2290
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2291
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2292
        with_transaction($dbh, sub {
2293
            import_work_orders_to_db($dbh, $orders);
2294
        });
2295
    }
2296

            
2297
    seed_mdns_observations_from_yaml($dbh);
2298
}
2299

            
2300
sub with_transaction {
2301
    my ($dbh, $code) = @_;
2302
    return $code->() unless $dbh->{AutoCommit};
2303
    $dbh->begin_work;
2304
    my $ok = eval {
2305
        $code->();
2306
        1;
2307
    };
2308
    if (!$ok) {
2309
        my $err = $@ || 'transaction failed';
2310
        eval { $dbh->rollback };
2311
        die $err;
2312
    }
2313
    $dbh->commit;
2314
}
2315

            
2316
sub db_scalar {
2317
    my ($dbh, $sql, @bind) = @_;
2318
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2319
    return $value || 0;
2320
}
2321

            
2322
sub legacy_document_text {
2323
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2324
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2325
    return $row->{content} if $row && defined $row->{content};
2326
    return read_file($seed_path) if -f $seed_path;
2327
    return $default_text;
2328
}
2329

            
2330
sub load_registry_from_db {
2331
    my $dbh = dbh();
2332
    my $registry = {
2333
        version => 1,
2334
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2335
        policy => {},
2336
        hosts => [],
2337
    };
Bogdan Timofte authored 4 days ago
2338

            
Bogdan Timofte authored 4 days ago
2339
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2340
    $sth->execute;
2341
    while (my $row = $sth->fetchrow_hashref) {
2342
        my $fqdn = $row->{fqdn};
2343
        push @{ $registry->{hosts} }, {
2344
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2345
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2346
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2347
            ip => canonical_ip($row),
2348
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2349
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2350
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2351
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2352
            monitoring => $row->{monitoring},
2353
            notes => $row->{notes},
2354
        };
2355
    }
2356

            
2357
    return $registry;
Bogdan Timofte authored 4 days ago
2358
}
2359

            
Bogdan Timofte authored 4 days ago
2360
sub save_registry_to_db {
2361
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2362
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2363
    with_transaction($dbh, sub {
2364
        import_registry_to_db($dbh, $registry, 1);
2365
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2366
    });
2367
}
2368

            
2369
sub import_registry_to_db {
2370
    my ($dbh, $registry, $retire_missing) = @_;
2371
    my %seen;
2372
    for my $host (@{ $registry->{hosts} || [] }) {
2373
        my $fqdn = upsert_host_to_db($dbh, $host);
2374
        $seen{$fqdn} = 1 if $fqdn;
2375
    }
2376

            
2377
    return unless $retire_missing;
2378
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2379
    $sth->execute('retired');
2380
    while (my ($fqdn) = $sth->fetchrow_array) {
2381
        next if $seen{$fqdn};
2382
        retire_host_in_db($dbh, $fqdn);
2383
    }
2384
}
2385

            
2386
sub upsert_host_to_db {
2387
    my ($dbh, $host) = @_;
2388
    my $now = iso_now();
2389
    my $fqdn = canonical_host_fqdn($host);
2390
    return '' unless $fqdn;
2391
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2392
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2393
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2394
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2395
    my $notes = clean_scalar($host->{notes} || '');
2396

            
Bogdan Timofte authored 4 days ago
2397
    $dbh->do(
Bogdan Timofte authored 4 days ago
2398
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2399
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2400
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2401
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2402
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2403
        undef,
Bogdan Timofte authored 4 days ago
2404
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2405
    );
2406

            
2407
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2408
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2409
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2410
    return $fqdn;
2411
}
2412

            
Bogdan Timofte authored 3 days ago
2413
sub upsert_host_tls_row {
2414
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2415
    $certificate_id = clean_certificate_id($certificate_id || '');
2416
    $dbh->do(
2417
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2418
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2419
        undef,
2420
        $host_fqdn,
2421
        length($certificate_id) ? 'local-ca' : 'none',
2422
        length($certificate_id) ? $certificate_id : undef,
2423
        '',
2424
        $now,
2425
        $now,
2426
    );
2427
}
2428

            
Bogdan Timofte authored 4 days ago
2429
sub sync_host_values {
2430
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2431
    my $now = iso_now();
2432
    my %active = map { $_ => 1 } @$values;
2433
    for my $value (@$values) {
2434
        $dbh->do(
2435
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2436
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2437
            undef,
2438
            $fqdn, $value, $now,
2439
        );
2440
    }
2441

            
2442
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2443
    $sth->execute($fqdn);
2444
    while (my ($value) = $sth->fetchrow_array) {
2445
        next if $active{$value};
2446
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2447
    }
2448
}
2449

            
Bogdan Timofte authored 4 days ago
2450
sub sync_host_aliases_and_vhosts {
2451
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2452
    my $now = iso_now();
2453
    my (%aliases, %vhosts);
2454
    if (my $short = short_alias_for_fqdn($fqdn)) {
2455
        $aliases{$short} = 1;
2456
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2457
    }
Bogdan Timofte authored 4 days ago
2458
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2459
        $name = normalize_dns_name($name);
2460
        next unless length $name;
2461
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2462
        $aliases{$name} = 1;
2463
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2464
        if (my $short = short_alias_for_fqdn($name)) {
2465
            $aliases{$short} = 1;
2466
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2467
        }
2468
    }
2469
    for my $name (@$vhosts_in) {
2470
        $name = normalize_dns_name($name);
2471
        next unless length $name;
2472
        $vhosts{$name} = 1;
2473
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2474
        if (my $short = short_alias_for_fqdn($name)) {
2475
            $aliases{$short} = 1;
2476
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2477
        }
2478
    }
2479

            
2480
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2481
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2482
}
2483

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

            
2511
sub upsert_vhost_to_db {
2512
    my ($dbh, $fqdn, $vhost, $now) = @_;
2513
    my $service = vhost_service_name($vhost);
2514
    $dbh->do(
2515
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2516
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2517
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2518
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2519
        undef,
2520
        $vhost, $fqdn, $service, $now, $now,
2521
    );
2522
}
2523

            
2524
sub retire_missing_names {
2525
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2526
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2527
    $sth->execute($fqdn);
2528
    while (my ($name) = $sth->fetchrow_array) {
2529
        next if $active->{$name};
2530
        if ($table eq 'host_aliases') {
2531
            $dbh->do(
2532
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2533
                undef, $now, $fqdn, $name,
2534
            );
2535
        } else {
2536
            $dbh->do(
2537
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2538
                undef, $now, $fqdn, $name,
2539
            );
2540
        }
2541
    }
2542
}
2543

            
2544
sub retire_host_in_db {
2545
    my ($dbh, $fqdn) = @_;
2546
    my $now = iso_now();
2547
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2548
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2549
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2550
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2551
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2552
}
2553

            
Bogdan Timofte authored 4 days ago
2554
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2555
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2556
    my @names;
Bogdan Timofte authored 4 days ago
2557
    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");
2558
    $aliases->execute($fqdn);
2559
    while (my ($name) = $aliases->fetchrow_array) {
2560
        push @names, $name;
2561
    }
Bogdan Timofte authored 4 days ago
2562
    return unique_preserve(@names);
2563
}
2564

            
2565
sub active_vhosts_for_host {
2566
    my ($dbh, $fqdn) = @_;
2567
    my @names;
Bogdan Timofte authored 4 days ago
2568
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2569
    $vhosts->execute($fqdn);
2570
    while (my ($name) = $vhosts->fetchrow_array) {
2571
        push @names, $name;
2572
    }
2573
    return unique_preserve(@names);
2574
}
2575

            
2576
sub active_values_for_host {
2577
    my ($dbh, $table, $column, $fqdn) = @_;
2578
    my @values;
2579
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2580
    $sth->execute($fqdn);
2581
    while (my ($value) = $sth->fetchrow_array) {
2582
        push @values, $value;
2583
    }
2584
    return @values;
2585
}
2586

            
2587
sub load_work_orders_from_db {
2588
    my $dbh = dbh();
2589
    my $orders = { version => 1, work_orders => [] };
2590
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2591
    $sth->execute;
2592
    while (my $row = $sth->fetchrow_hashref) {
2593
        my $wo = {
2594
            id => $row->{id},
2595
            status => $row->{status},
2596
            title => $row->{title},
2597
            reason => $row->{reason},
2598
            created_at => $row->{created_at},
2599
            checklist => [],
2600
            actions => [],
2601
        };
2602
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2603
        $wo->{result} = $row->{result} if length($row->{result} || '');
2604

            
2605
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2606
        $items->execute($row->{id});
2607
        while (my $item = $items->fetchrow_hashref) {
2608
            my %copy = (
2609
                id => $item->{item_id},
2610
                text => $item->{text},
2611
                status => $item->{status},
2612
            );
2613
            for my $key (qw(owner notes updated_at)) {
2614
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2615
            }
2616
            push @{ $wo->{checklist} }, \%copy;
2617
        }
2618

            
2619
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2620
        $actions->execute($row->{id});
2621
        while (my $action = $actions->fetchrow_hashref) {
2622
            my %copy = ( type => $action->{type} );
2623
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2624
            $copy{name} = $action->{name} if length($action->{name} || '');
2625
            push @{ $wo->{actions} }, \%copy;
2626
        }
2627

            
2628
        push @{ $orders->{work_orders} }, $wo;
2629
    }
2630
    return $orders;
2631
}
2632

            
2633
sub save_work_orders_to_db {
2634
    my ($orders) = @_;
2635
    my $dbh = dbh();
2636
    with_transaction($dbh, sub {
2637
        import_work_orders_to_db($dbh, $orders);
2638
    });
2639
}
2640

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

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

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

            
2746
sub parse_mdns_observations_yaml {
2747
    my ($text) = @_;
2748
    my %db = ( observations => [] );
2749
    my ($section, $current);
2750
    for my $line (split /\n/, $text || '') {
2751
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2752
        if ($line =~ /^observations:\s*$/) {
2753
            $section = 'observations';
2754
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2755
            $current = { key => yaml_unquote($1) };
2756
            push @{ $db{observations} }, $current;
2757
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2758
            $current->{$1} = yaml_unquote($2);
2759
        }
2760
    }
2761
    return \%db;
2762
}
2763

            
2764
sub set_schema_meta {
2765
    my ($dbh, $key, $value) = @_;
2766
    $dbh->do(
2767
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2768
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2769
        undef,
2770
        $key,
2771
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2772
        iso_now(),
2773
    );
2774
}
2775

            
Bogdan Timofte authored 4 days ago
2776
sub fqdn_for_legacy_id {
2777
    my ($dbh, $legacy_id) = @_;
2778
    return '' unless length($legacy_id || '');
2779
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2780
    return $fqdn || '';
2781
}
2782

            
2783
sub canonical_host_fqdn {
2784
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2785
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2786
    return $fqdn if length $fqdn;
2787
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2788
    for my $name (@names) {
2789
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2790
    }
2791
    for my $name (@names) {
2792
        return $name if $name =~ /\./ && !name_is_vhost($name);
2793
    }
2794
    my $id = clean_id($host->{id} || '');
2795
    return $id ? "$id.madagascar.xdev.ro" : '';
2796
}
2797

            
2798
sub legacy_id_from_fqdn {
2799
    my ($fqdn) = @_;
2800
    $fqdn = normalize_dns_name($fqdn);
2801
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2802
    $fqdn =~ s/\..*\z//;
2803
    return clean_id($fqdn);
2804
}
2805

            
2806
sub normalize_dns_name {
2807
    my ($name) = @_;
2808
    $name = lc clean_scalar($name || '');
2809
    $name =~ s/\.\z//;
2810
    return $name;
2811
}
2812

            
2813
sub name_is_vhost {
2814
    my ($name) = @_;
2815
    $name = normalize_dns_name($name);
2816
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2817
}
2818

            
2819
sub vhost_service_name {
2820
    my ($name) = @_;
2821
    $name = normalize_dns_name($name);
2822
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2823
    return '';
2824
}
2825

            
2826
sub short_alias_for_fqdn {
2827
    my ($name) = @_;
2828
    $name = normalize_dns_name($name);
2829
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2830
    return '';
2831
}
2832

            
Bogdan Timofte authored 4 days ago
2833
sub normalize_registry_policy {
2834
    my ($registry) = @_;
2835
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2836
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2837
    $registry->{policy}{runtime_database} = $opt{db};
2838
}
2839

            
2840
sub default_hosts_yaml {
2841
    return <<'YAML';
2842
version: 1
2843
updated_at: ""
2844
policy:
Bogdan Timofte authored 4 days ago
2845
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2846
hosts:
2847
YAML
2848
}
2849

            
2850
sub default_work_orders_yaml {
2851
    return <<'YAML';
2852
version: 1
2853
work_orders:
2854
YAML
2855
}
2856

            
2857
sub ensure_parent_dir {
2858
    my ($path) = @_;
2859
    my $dir = dirname($path);
2860
    make_path($dir) unless -d $dir;
2861
}
2862

            
Xdev Host Manager authored a week ago
2863
sub url_decode {
2864
    my ($value) = @_;
2865
    $value = '' unless defined $value;
2866
    $value =~ tr/+/ /;
2867
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2868
    return $value;
2869
}
2870

            
2871
sub random_hex {
2872
    my ($bytes) = @_;
2873
    if (open my $fh, '<:raw', '/dev/urandom') {
2874
        read($fh, my $raw, $bytes);
2875
        close $fh;
2876
        return unpack('H*', $raw);
2877
    }
2878
    return sha256_hex(rand() . time() . $$);
2879
}
2880

            
2881
sub iso_now {
2882
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2883
}
2884

            
Bogdan Timofte authored 6 days ago
2885
sub build_info {
2886
    my %info = (
2887
        revision => '',
2888
        branch => '',
2889
        built_at => '',
2890
        deployed_at => '',
2891
        dirty => '',
2892
    );
2893

            
2894
    if ($ENV{HOST_MANAGER_BUILD}) {
2895
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2896
        return \%info;
2897
    }
2898

            
2899
    my $build_file = "$project_dir/BUILD";
2900
    if (-f $build_file) {
2901
        for my $line (split /\n/, read_file($build_file)) {
2902
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2903
            $info{$1} = clean_scalar($2);
2904
        }
2905
        return \%info if $info{revision} || $info{built_at};
2906
    }
2907

            
2908
    my $revision = git_value('rev-parse --short=12 HEAD');
2909
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2910
    $info{revision} = $revision if $revision;
2911
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2912
    return \%info;
2913
}
2914

            
2915
sub git_value {
2916
    my ($args) = @_;
2917
    return '' unless -d "$project_dir/.git";
2918
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2919
    my $value = <$fh> || '';
2920
    close $fh;
2921
    chomp $value;
2922
    return clean_scalar($value);
2923
}
2924

            
2925
sub build_label {
2926
    my $info = build_info();
2927
    my $revision = $info->{revision} || 'unknown';
2928
    my $branch = $info->{branch} || '';
2929
    $branch = '' if $branch eq 'HEAD';
2930
    my $label = $branch ? "$branch $revision" : $revision;
2931
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2932
    return $label;
2933
}
2934

            
2935
sub build_title {
2936
    my $info = build_info();
2937
    my $label = build_label();
2938
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2939
    return $stamp ? "$label deployed $stamp" : $label;
2940
}
2941

            
Bogdan Timofte authored 4 days ago
2942
sub build_revision {
2943
    my $info = build_info();
2944
    return $info->{revision} || 'unknown';
2945
}
2946

            
2947
sub build_details {
2948
    my $info = build_info();
2949
    my %details = (
2950
        app => 'Madagascar Local Authority',
2951
        revision => $info->{revision} || 'unknown',
2952
        branch => $info->{branch} || '',
2953
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2954
        built_at => $info->{built_at} || '',
2955
        deployed_at => $info->{deployed_at} || '',
2956
        label => build_label(),
2957
        title => build_title(),
2958
    );
2959
    return json_encode(\%details);
2960
}
2961

            
Bogdan Timofte authored 6 days ago
2962
sub html_escape {
2963
    my ($value) = @_;
2964
    $value = '' unless defined $value;
2965
    $value =~ s/&/&amp;/g;
2966
    $value =~ s/</&lt;/g;
2967
    $value =~ s/>/&gt;/g;
2968
    $value =~ s/"/&quot;/g;
2969
    $value =~ s/'/&#039;/g;
2970
    return $value;
2971
}
2972

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

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

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

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

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

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

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

            
Bogdan Timofte authored 5 days ago
3409
      <section class="page" id="page-dns" data-page="dns" hidden>
3410
        <section class="toolbar">
3411
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3412
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3413
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3414
          <button id="write-tsv">Write local-hosts.tsv</button>
3415
        </section>
Xdev Host Manager authored a week ago
3416
      </section>
3417

            
Bogdan Timofte authored 5 days ago
3418
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3419
        <section class="panel">
3420
          <div class="panel-head">
3421
            <h2>Work Orders</h2>
3422
            <div class="stats" id="wo-stats"></div>
3423
          </div>
3424
          <div class="problems" id="work-orders"></div>
3425
        </section>
Xdev Host Manager authored a week ago
3426
      </section>
3427

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

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

            
3509
  </div>
3510

            
Bogdan Timofte authored 4 days ago
3511
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3512
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3513
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3514
  </div>
Bogdan Timofte authored 6 days ago
3515

            
Xdev Host Manager authored a week ago
3516
  <script>
Bogdan Timofte authored 3 days ago
3517
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3518
    let hostFormSnapshot = '';
Bogdan Timofte authored 3 days ago
3519
    let hostFormBusy = false;
3520
    let hostFormMode = 'new';
Bogdan Timofte authored 3 days ago
3521
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3522

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

            
Bogdan Timofte authored 4 days ago
3575
    function isAuthLost(error) {
3576
      return !!(error && error.authLost);
3577
    }
3578

            
3579
    function authLostError(message) {
3580
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3581
      error.authLost = true;
3582
      return error;
3583
    }
3584

            
3585
    function handleAuthLost(message) {
3586
      state.authenticated = false;
3587
      msg('');
3588
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3589
    }
3590

            
Bogdan Timofte authored 4 days ago
3591
    async function ensureAuthenticated(message) {
3592
      if (!state.authenticated) {
3593
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3594
        return false;
3595
      }
3596
      const session = await api('/api/session');
3597
      state.authenticated = session.authenticated;
3598
      if (!state.authenticated) {
3599
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3600
        return false;
3601
      }
3602
      return true;
3603
    }
3604

            
Xdev Host Manager authored a week ago
3605
    async function api(path, options = {}) {
3606
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3607
      let body = {};
3608
      try {
3609
        body = await res.json();
3610
      } catch (_) {
3611
        body = {};
3612
      }
3613
      const errorCode = body.error || '';
3614
      if (!res.ok) {
3615
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3616
          const error = authLostError();
3617
          handleAuthLost(error.message);
3618
          throw error;
3619
        }
3620
        throw new Error(errorCode || res.statusText);
3621
      }
Xdev Host Manager authored a week ago
3622
      return body;
3623
    }
3624

            
Bogdan Timofte authored 5 days ago
3625
    function currentPage() {
3626
      return PAGE_PATHS[window.location.pathname] || 'overview';
3627
    }
3628

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

            
Xdev Host Manager authored a week ago
3649
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3650
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3651
      document.body.classList.remove('is-app');
3652
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3653
      $('app').style.display = 'none';
3654
      $('login-screen').style.display = 'flex';
3655
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3656
      clearOtp();
Xdev Host Manager authored a week ago
3657
    }
3658

            
3659
    function showApp() {
Bogdan Timofte authored 6 days ago
3660
      document.body.classList.remove('is-login');
3661
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3662
      $('login-screen').style.display = 'none';
3663
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3664
      showPage(currentPage());
Xdev Host Manager authored a week ago
3665
    }
3666

            
Xdev Host Manager authored a week ago
3667
    async function refresh() {
3668
      const session = await api('/api/session');
3669
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3670
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3671
      showApp();
Xdev Host Manager authored a week ago
3672
      const data = await api('/api/hosts');
3673
      state.hosts = data.hosts || [];
Bogdan Timofte authored 3 days ago
3674
      state.vhosts = data.vhosts || [];
3675
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3676
      state.problems = data.problems || [];
3677
      render(data);
Xdev Host Manager authored a week ago
3678
      await renderCa();
Xdev Host Manager authored a week ago
3679
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3680
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3681
    }
3682

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

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

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

            
3696
      renderHosts();
Bogdan Timofte authored 4 days ago
3697
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3698
      renderVhosts();
Xdev Host Manager authored a week ago
3699
    }
3700

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

            
Bogdan Timofte authored 5 days ago
3772
    function daysUntil(dateText) {
3773
      const time = Date.parse(dateText || '');
3774
      if (!Number.isFinite(time)) return null;
3775
      return Math.ceil((time - Date.now()) / 86400000);
3776
    }
3777

            
3778
    function certStatusClass(days) {
3779
      if (days === null) return '';
3780
      if (days < 0) return 'bad';
3781
      if (days <= 30) return 'warn';
3782
      return 'ok';
3783
    }
3784

            
3785
    function certStatusLabel(days) {
3786
      if (days === null) return 'validity unknown';
3787
      if (days < 0) return 'expired';
3788
      if (days === 0) return 'expires today';
3789
      return `${days}d remaining`;
3790
    }
3791

            
Xdev Host Manager authored a week ago
3792
    async function renderWorkOrders() {
3793
      try {
3794
        const data = await api('/api/work-orders');
3795
        state.workOrders = data.work_orders || [];
3796
        $('wo-stats').innerHTML = [
3797
          ['pending', data.counts.pending],
3798
          ['total', data.counts.work_orders],
3799
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3800

            
3801
        if (!state.workOrders.length) {
3802
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3803
          return;
3804
        }
3805

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

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

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

            
3895
    function debugTableReference(database, tableName) {
3896
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3897
    }
3898

            
3899
    async function selectDebugTable(tableName) {
3900
      state.debugTable = tableName || '';
3901
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3902
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3903
        const card = button.closest('.debug-table-card');
3904
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3905
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3906
      });
3907
      if (state.debugTable) await renderDebugTable(state.debugTable);
3908
    }
3909

            
3910
    function clearDebugTable() {
3911
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3912
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3913
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3914
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3915
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3916
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3917
    }
3918

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

            
Bogdan Timofte authored 4 days ago
3934
    function updateDebugExportLinks(tableName) {
3935
      const encoded = encodeURIComponent(tableName || '');
3936
      [
3937
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3938
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3939
      ].forEach(([id, href]) => {
3940
        const link = $(id);
3941
        const enabled = !!tableName;
3942
        link.href = enabled ? href : '#';
3943
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3944
      });
3945
    }
3946

            
Bogdan Timofte authored 4 days ago
3947
    function renderDebugRows(data) {
3948
      const rows = data.rows || [];
3949
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3950
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3951
    }
3952

            
3953
    function renderDebugObjectTable(rows, preferredKeys) {
3954
      const keys = preferredKeys && preferredKeys.length
3955
        ? preferredKeys
3956
        : Array.from(rows.reduce((set, row) => {
3957
            Object.keys(row || {}).forEach(key => set.add(key));
3958
            return set;
3959
          }, new Set()));
3960
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3961
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3962
      const body = rows.length
3963
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3964
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3965
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3966
    }
3967

            
3968
    function debugCell(value) {
3969
      if (value === null || value === undefined) return 'NULL';
3970
      if (Array.isArray(value)) return value.join(', ');
3971
      if (typeof value === 'object') return JSON.stringify(value);
3972
      return String(value);
3973
    }
3974

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

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

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

            
Bogdan Timofte authored 3 days ago
4062
    function renderHostAliasCell(host) {
4063
      const canonical = host.fqdn ? `<span class="pill canonical host-alias-pill"><span class="host-alias-label">${escapeHtml(host.fqdn)}</span></span>` : '';
4064
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4065
        <span class="host-alias-label">${escapeHtml(name)}</span>
4066
        <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>
4067
      </span>`).join('');
4068
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived host-alias-pill" title="derived alias">
4069
        <span class="host-alias-label">${escapeHtml(name)}</span>
4070
      </span>`).join('');
4071
      return `<div class="host-alias-cell">
4072
        <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>
4073
      </div>`;
4074
    }
4075

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

            
4096
    function hostCertificateRow(host) {
4097
      return {
4098
        host_fqdn: host.fqdn || '',
4099
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4100
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4101
        certificate_id: host.certificate_id || '',
4102
        certificate: host.certificate || null,
4103
      };
Bogdan Timofte authored 4 days ago
4104
    }
4105

            
4106
    function vhostRows() {
Bogdan Timofte authored 3 days ago
4107
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4108
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4109
        vhost,
4110
        host_id: host.id || '',
4111
        host_fqdn: host.fqdn || '',
4112
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4113
        monitoring: host.monitoring || '',
4114
        status: host.status || '',
Bogdan Timofte authored 3 days ago
4115
        certificate_id: '',
4116
        certificate: null,
Bogdan Timofte authored 4 days ago
4117
      })));
4118
    }
4119

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

            
Bogdan Timofte authored 3 days ago
4175
    function renderVhostNameCell(row) {
4176
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4177
      return `<div class="vhost-name-cell">
4178
        <div class="vhost-name-main">
4179
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4180
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4181
        </div>
4182
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4183
      </div>`;
4184
    }
4185

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

            
4205
    function renderVhostEditor() {
4206
      const select = $('vhost-new-host');
4207
      const current = select.value || '';
4208
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4209
    }
4210

            
4211
    function renderVhostHostOptions(selectedHostFqdn) {
4212
      return state.hosts
4213
        .slice()
4214
        .filter(host => (host.status || '') !== 'retired')
4215
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4216
        .map(host => {
4217
          const fqdn = host.fqdn || '';
4218
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4219
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4220
        }).join('');
Bogdan Timofte authored 4 days ago
4221
    }
4222

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

            
Bogdan Timofte authored 3 days ago
4250
    function certificateIdOf(cert) {
Bogdan Timofte authored 3 days ago
4251
      return cert ? (cert.id || cert.name || '') : '';
4252
    }
4253

            
4254
    function certDnsNames(cert) {
4255
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4256
        .map(name => String(name || '').toLowerCase())
4257
        .filter(Boolean);
4258
    }
4259

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

            
4284
    function certMatchesRow(cert, row) {
4285
      return certRelevance(cert, row) < 9;
4286
    }
4287

            
4288
    function compactCertificateLabel(cert, row) {
4289
      const relevance = certRelevance(cert, row);
Bogdan Timofte authored 3 days ago
4290
      const id = String(certificateIdOf(cert));
Bogdan Timofte authored 3 days ago
4291
      const days = daysUntil(cert.not_after);
4292
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
4293
      const timestamp = id.match(/-(\d{14})$/);
Bogdan Timofte authored 3 days ago
4294
      if (row && row.vhost) {
4295
        if (relevance === 0) return `vhost${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4296
        if (relevance === 1) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4297
        if (relevance === 2) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4298
      } else {
4299
        if (relevance === 0) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4300
        if (relevance === 1) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4301
      }
Bogdan Timofte authored 3 days ago
4302
      return `${shortCertificateName(cert)}${suffix}`;
4303
    }
4304

            
4305
    function shortCertificateName(cert) {
4306
      const name = String(cert.common_name || cert.name || cert.id || '');
4307
      const suffix = '.madagascar.xdev.ro';
4308
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4309
    }
4310

            
Bogdan Timofte authored 4 days ago
4311
    function shortAliasForFqdn(name) {
4312
      const suffix = '.madagascar.xdev.ro';
4313
      name = String(name || '').toLowerCase();
4314
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
4315
    }
4316

            
Bogdan Timofte authored 3 days ago
4317
    function hostByFqdn(fqdn) {
4318
      fqdn = String(fqdn || '').toLowerCase();
4319
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4320
    }
4321

            
4322
    function hostUpsertPayload(host, overrides = {}) {
4323
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4324
      const payload = {
4325
        id: host.id || '',
4326
        fqdn: host.fqdn || '',
4327
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4328
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4329
        aliases,
4330
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
4331
        sources: Array.isArray(overrides.sources) ? overrides.sources : (host.sources || []),
4332
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4333
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4334
      };
4335
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4336
      return payload;
4337
    }
4338

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

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

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

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

            
Bogdan Timofte authored 4 days ago
4420
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4421
      const vhost = select.dataset.vhostSelect || '';
4422
      const fromHost = select.dataset.currentHost || '';
4423
      const toHost = select.value || '';
4424
      if (!vhost || !toHost || toHost === fromHost) return;
4425
      select.disabled = true;
4426
      try {
4427
        await api('/api/vhosts/reassign', {
4428
          method: 'POST',
4429
          headers: { 'Content-Type': 'application/json' },
4430
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4431
        });
4432
        msg(`vhost ${vhost} moved`);
4433
        await refresh();
4434
      } finally {
4435
        select.disabled = false;
4436
      }
4437
    }
4438

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

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

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

            
Bogdan Timofte authored 4 days ago
4510
    async function deleteVhostInline(vhost) {
4511
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4512
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4513
      await api('/api/vhosts/delete', {
4514
        method: 'POST',
4515
        headers: { 'Content-Type': 'application/json' },
4516
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4517
      });
4518
      msg(`vhost ${vhost} deleted`);
4519
      await refresh();
4520
    }
4521

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

            
Bogdan Timofte authored 4 days ago
4535
    async function newHost() {
4536
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 3 days ago
4537
      if (!canSwitchHostEditor('__new__')) return;
4538
      resetHostForm(true);
4539
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4540
    }
4541

            
Bogdan Timofte authored 3 days ago
4542
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 3 days ago
4543
      hostFormMode = mode || 'new';
Bogdan Timofte authored 3 days ago
4544
      hostEditorTarget = target || '';
4545
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 3 days ago
4546
      syncHostFormActions();
Bogdan Timofte authored 3 days ago
4547
      renderHosts();
4548
      hostFormSnapshot = hostFormState();
4549
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 3 days ago
4550
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4551
    }
4552

            
Bogdan Timofte authored 3 days ago
4553
    function resetHostForm(force = false) {
Bogdan Timofte authored 3 days ago
4554
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 3 days ago
4555
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4556
      clearHostFormMessage();
Bogdan Timofte authored 3 days ago
4557
      hostField('status').value = 'active';
4558
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 3 days ago
4559
      hostFormSnapshot = force ? '' : hostFormState();
4560
    }
4561

            
4562
    function closeHostForm(force = false) {
4563
      if (hostFormBusy && !force) return;
4564
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4565
      hostEditorTarget = '';
4566
      hostFormMode = 'new';
4567
      hostFormSnapshot = '';
4568
      clearHostFormMessage();
4569
      syncHostFormActions();
4570
      mountHostEditor();
4571
    }
4572

            
4573
    function canSwitchHostEditor(target) {
4574
      if (hostFormBusy) return false;
4575
      if (!hostEditorTarget) return true;
4576
      if (!hostFormDirty()) return true;
4577
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4578
      return confirm('Discard unsaved host changes?');
4579
    }
4580

            
4581
    function mountHostEditor() {
4582
      hostEditorRow.remove();
4583
      if (!hostEditorTarget) {
4584
        hostFormShell.hidden = true;
4585
        return;
4586
      }
4587
      hostEditorCell.colSpan = 7;
4588
      const tbody = $('hosts');
4589
      if (!tbody) return;
4590
      if (hostEditorTarget === '__new__') {
4591
        tbody.prepend(hostEditorRow);
4592
      } else {
4593
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4594
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4595
        if (targetRow) targetRow.after(hostEditorRow);
4596
        else tbody.prepend(hostEditorRow);
4597
      }
4598
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4599
    }
4600

            
4601
    function hostField(name) {
Bogdan Timofte authored 3 days ago
4602
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4603
    }
4604

            
4605
    function hostFormState() {
Bogdan Timofte authored 3 days ago
4606
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4607
    }
4608

            
4609
    function hostFormDirty() {
Bogdan Timofte authored 3 days ago
4610
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4611
    }
4612

            
4613
    function setHostFormBusy(busy) {
Bogdan Timofte authored 3 days ago
4614
      hostFormBusy = !!busy;
4615
      syncHostFormActions();
4616
    }
4617

            
4618
    function syncHostFormActions() {
Bogdan Timofte authored 3 days ago
4619
      saveHostButton.disabled = hostFormBusy;
4620
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4621
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4622
    }
4623

            
4624
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 3 days ago
4625
      hostFormMessage.textContent = text || '';
4626
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4627
    }
4628

            
4629
    function clearHostFormMessage() {
4630
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4631
    }
4632

            
4633
    function formObject(form) {
4634
      return Object.fromEntries(new FormData(form).entries());
4635
    }
4636

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

            
Bogdan Timofte authored 6 days ago
4642
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4643

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

            
4649
    if (loginAccount) {
4650
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4651
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4652
      loginAccount.addEventListener('input', () => {
4653
        const value = (loginAccount.value || '').trim();
4654
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4655
      });
4656
    }
4657

            
Xdev Host Manager authored a week ago
4658
    function setOtpDigit(idx, value) {
4659
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4660
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4661
      otpDigits[idx].classList.toggle('filled', !!digit);
4662
    }
4663

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

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

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

            
Bogdan Timofte authored 4 days ago
4706
    otpDigits.forEach((input, idx) => {
4707
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4708
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4709
        // A single box may receive several digits at once (autofill / typing fast).
4710
        if (input.value.replace(/\D/g, '').length > 1) {
4711
          fillOtp(input.value, idx);
4712
          return;
4713
        }
4714
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4715
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4716
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4717
        maybeSubmitOtp();
4718
      });
Bogdan Timofte authored 4 days ago
4719

            
4720
      input.addEventListener('paste', (e) => {
4721
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4722
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4723
        const text = (e.clipboardData || window.clipboardData).getData('text');
4724
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4725
      });
Bogdan Timofte authored 4 days ago
4726

            
4727
      input.addEventListener('keydown', (e) => {
4728
        if (e.key === 'Backspace') {
4729
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4730
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4731
          if (input.value) { setOtpDigit(idx, ''); }
4732
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4733
          syncOtpFields();
4734
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4735
          e.preventDefault();
4736
          otpDigits[idx - 1].focus();
4737
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4738
          e.preventDefault();
4739
          otpDigits[idx + 1].focus();
4740
        }
4741
      });
4742
    });
4743

            
Bogdan Timofte authored 4 days ago
4744
    // Focus the first OTP box only for a returning operator (username known).
4745
    // For an unknown operator, leave focus on the username field so Safari can
4746
    // present its OTP autofill anchored there without being dismissed by a focus
4747
    // change (pbx-admin pattern).
4748
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4749
    else if (loginAccount) loginAccount.focus();
4750
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4751

            
Bogdan Timofte authored 5 days ago
4752
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4753
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4754
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4755
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4756
        showPage(link.dataset.pageLink, true);
4757
      });
4758
    });
4759

            
Bogdan Timofte authored 4 days ago
4760
    window.addEventListener('popstate', () => {
4761
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4762
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4763
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4764
    });
Bogdan Timofte authored 5 days ago
4765

            
Bogdan Timofte authored 4 days ago
4766
    async function copyText(text) {
4767
      if (navigator.clipboard && window.isSecureContext) {
4768
        await navigator.clipboard.writeText(text);
4769
        return;
4770
      }
4771
      const input = document.createElement('textarea');
4772
      input.value = text;
4773
      input.setAttribute('readonly', '');
4774
      input.style.position = 'fixed';
4775
      input.style.left = '-10000px';
4776
      document.body.appendChild(input);
4777
      input.select();
4778
      document.execCommand('copy');
4779
      document.body.removeChild(input);
4780
    }
4781

            
4782
    $('copy-build').addEventListener('click', async () => {
4783
      try {
4784
        await copyText($('copy-build').dataset.buildDetails || '');
4785
        if (state.authenticated) msg('build details copied');
4786
      } catch (e) {
4787
        if (state.authenticated) msg('copy failed');
4788
      }
4789
    });
4790

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

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

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

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

            
Bogdan Timofte authored 3 days ago
4868
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4869
      setHostFormMessage('Complete the required host fields before saving.', true);
4870
    }, true);
4871

            
Bogdan Timofte authored 3 days ago
4872
    hostForm.addEventListener('input', () => {
4873
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4874
    });
4875

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

            
Bogdan Timofte authored 3 days ago
4895
    resetHostForm(true);
4896
    closeHostForm(true);
Bogdan Timofte authored 3 days ago
4897

            
Xdev Host Manager authored a week ago
4898
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4899
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4900
      try {
4901
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4902
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4903
      } catch (e) {
4904
        if (!isAuthLost(e)) msg(e.message);
4905
      }
Xdev Host Manager authored a week ago
4906
    });
4907

            
Bogdan Timofte authored 4 days ago
4908
    refresh().catch(e => {
4909
      if (!isAuthLost(e)) showLogin(e.message);
4910
    });
Xdev Host Manager authored a week ago
4911
  </script>
4912
</body>
4913
</html>
4914
HTML
Bogdan Timofte authored 6 days ago
4915
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4916
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4917
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4918
    return $html;
Xdev Host Manager authored a week ago
4919
}